diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index dfbabc0d0d..475db6de2b 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -78,6 +78,9 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ Pokemon::add_tests_MapData(ret); Pokemon::add_tests_RuntimeMap(ret); Pokemon::add_tests_RouteLog(ret); + Pokemon::add_tests_ScreenEvents(ret); + Pokemon::add_tests_Interrupts(ret); + Pokemon::add_tests_Transitions(ret); Pokemon::add_tests_AutoStory(ret); NintendoSwitch::add_tests_CheckOnlineDetector(ret); NintendoSwitch::add_tests_FailedToConnectDetector(ret); diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h index 0379857071..eaef37cb79 100644 --- a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h @@ -15,6 +15,12 @@ namespace PokemonAutomation{ namespace Pokemon{ +// Registers unit tests for the shared screen events and the judgements over them. +void add_tests_ScreenEvents(UnitTestDatabase& database); +// The timed screen-event log, the warp judge and the interrupt classifier. +void add_tests_Interrupts(UnitTestDatabase& database); +// The transition tracker and the games' fade profiles. +void add_tests_Transitions(UnitTestDatabase& database); // Registers unit tests for story event sequencing and execution. void add_tests_StoryEvents(UnitTestDatabase& database); // Registers unit tests for story segment running, verification, and branching. diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts.cpp new file mode 100644 index 0000000000..e93ce9f5c9 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts.cpp @@ -0,0 +1,278 @@ +/* Interrupts + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Pokemon_Interrupts.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +const char* warp_judgement_name(WarpJudgement judgement){ + switch (judgement){ + case WarpJudgement::NO_FADE: return "no fade"; + case WarpJudgement::FADE_SHOWING: return "fade still showing"; + case WarpJudgement::TOO_SHORT: return "fade too short"; + case WarpJudgement::SAME_SCENE: return "same scene after the fade"; + case WarpJudgement::WARPED: return "warped"; + case WarpJudgement::FLASH: return "an encounter's flash"; + case WarpJudgement::WHITE_UNSURE: return "a white, flash or fade"; + } + return "?"; +} + +const char* interrupt_kind_name(InterruptKind kind){ + switch (kind){ + case InterruptKind::NONE: return "none"; + case InterruptKind::WILD_ENCOUNTER: return "wild encounter"; + case InterruptKind::TRAINER_SIGHT: return "trainer sight"; + case InterruptKind::DIALOG: return "dialog"; + case InterruptKind::WARP: return "warp"; + case InterruptKind::UNSETTLED: return "unsettled"; + } + return "?"; +} + + +namespace{ + +using Changes = std::vector; + +Changes changes_in(const ScreenEventLog& log, InterruptWindow window){ + Changes out; + for (const ScreenEventChange& change : log.changes()){ + if (window.start <= change.time && change.time <= window.end){ + out.push_back(change); + } + } + return out; +} + +bool rose(const ScreenEventChange& change, ScreenEvent event){ + return change.event == event && change.edge == ScreenEventEdge::ROSE; +} +bool fade_rose(const ScreenEventChange& change){ + return rose(change, ScreenEvent::FADE_BLACK) || rose(change, ScreenEvent::FADE_WHITE); +} + +// The first rise of `event`, if any. +std::optional first_rise(const Changes& changes, ScreenEvent event){ + for (const ScreenEventChange& change : changes){ + if (rose(change, event)){ + return change; + } + } + return std::nullopt; +} + +// The last fade that went down, and when it came back up. +std::optional last_fade(const Changes& changes){ + std::optional out; + for (const ScreenEventChange& change : changes){ + if (fade_rose(change)){ + out = FadeSpan{fade_in({change.event}), change.time, std::nullopt}; + }else if (out && !out->up && change.edge == ScreenEventEdge::FELL && fade_events(out->fade).has(change.event)){ + out->up = change.time; + } + } + return out; +} + +ScreenEvent fade_event(WarpFade fade){ + return fade == WarpFade::WHITE ? ScreenEvent::FADE_WHITE : ScreenEvent::FADE_BLACK; +} +void add_fade_evidence(Changes& evidence, const FadeSpan& fade){ + evidence.push_back({fade.down, fade_event(fade.fade), ScreenEventEdge::ROSE}); + if (fade.up){ + evidence.push_back({*fade.up, fade_event(fade.fade), ScreenEventEdge::FELL}); + } +} + + +// A battle, and whose. The battle's fade is the last one that went down +// no more than battle_after_fade before it; a box that rose before that +// fade (or before the battle, with no fade) is a trainer's lines. A box +// after the fade is the battle's own opening text. +std::optional battle_verdict(const Changes& changes, const InterruptKnobs& knobs){ + const std::optional battle = first_rise(changes, ScreenEvent::BATTLE_STARTED); + if (!battle){ + return std::nullopt; + } + std::optional fade; + for (const ScreenEventChange& change : changes){ + if (change.time > battle->time){ + break; + } + if (fade_rose(change) && battle->time - change.time <= knobs.battle_after_fade){ + fade = change; + } + } + const WallClock cut = fade ? fade->time : battle->time; + std::optional lines; + for (const ScreenEventChange& change : changes){ + if (change.time >= cut){ + break; + } + if (rose(change, ScreenEvent::DIALOG_BOX) && cut - change.time <= knobs.sight_lines_lead){ + lines = change; + break; + } + } + InterruptVerdict out{lines ? InterruptKind::TRAINER_SIGHT : InterruptKind::WILD_ENCOUNTER, {}, false}; + for (const std::optional& change : {lines, fade, battle}){ + if (change){ + out.evidence.push_back(*change); + } + } + return out; +} + +// A warp: a banner, or a fade back up with no battle after it for +// battle_after_fade. A fade still showing, or back up too recently, may +// yet be an encounter's flash. +std::optional warp_verdict( + const ScreenEventLog& log, InterruptWindow window, const Changes& changes, const InterruptKnobs& knobs +){ + const WarpVerdict warp = judge_warp(log, window, knobs, SceneChange::UNKNOWN); + const std::optional banner = first_rise(changes, ScreenEvent::LOCATION_BANNER); + InterruptVerdict out{InterruptKind::WARP, {}, false}; + if (warp.fade && warp.judgement != WarpJudgement::TOO_SHORT){ + add_fade_evidence(out.evidence, *warp.fade); + } + if (banner){ + out.evidence.push_back(*banner); + std::stable_sort(out.evidence.begin(), out.evidence.end(), [](const auto& a, const auto& b){ + return a.time < b.time; + }); + return out; + } + switch (warp.judgement){ + case WarpJudgement::FADE_SHOWING: + out.kind = InterruptKind::UNSETTLED; + return out; + case WarpJudgement::WARPED:{ + const WallClock seen_until = std::min(window.end, log.last_frame().value_or(window.end)); + if (seen_until - *warp.fade->up < knobs.battle_after_fade){ + out.kind = InterruptKind::UNSETTLED; + } + return out; + } + case WarpJudgement::NO_FADE: + case WarpJudgement::TOO_SHORT: + case WarpJudgement::SAME_SCENE: + case WarpJudgement::FLASH: + case WarpJudgement::WHITE_UNSURE: + break; + } + return std::nullopt; +} + +std::optional dialog_verdict(const Changes& changes){ + const std::optional box = first_rise(changes, ScreenEvent::DIALOG_BOX); + if (!box){ + return std::nullopt; + } + return InterruptVerdict{InterruptKind::DIALOG, {*box}, false}; +} + +} // namespace + + +WarpVerdict judge_warp( + const ScreenEventLog& log, InterruptWindow window, + const InterruptKnobs& knobs, SceneChange change +){ + WarpVerdict out{WarpJudgement::NO_FADE, last_fade(changes_in(log, window))}; + if (!out.fade){ + return out; + } + if (!out.fade->up){ + out.judgement = WarpJudgement::FADE_SHOWING; + }else if (*out.fade->up - out.fade->down < knobs.min_fade_hold){ + out.judgement = WarpJudgement::TOO_SHORT; + }else if (change == SceneChange::SAME){ + out.judgement = WarpJudgement::SAME_SCENE; + }else{ + out.judgement = WarpJudgement::WARPED; + } + return out; +} + +WarpVerdict judge_warp(const Transition& transition, const InterruptKnobs& knobs){ + const WarpFade fade = transition.kind == TransitionKind::FADE_BLACK ? WarpFade::BLACK : WarpFade::WHITE; + WarpVerdict out{WarpJudgement::WARPED, FadeSpan{fade, transition.start, transition.end}}; + if (!transition.end){ + out.judgement = WarpJudgement::FADE_SHOWING; + }else if (transition.kind == TransitionKind::FLASH){ + out.judgement = WarpJudgement::FLASH; + }else if (transition.change == SceneChange::SAME){ + out.judgement = WarpJudgement::SAME_SCENE; + }else if (transition.kind == TransitionKind::WHITE_UNSURE){ + out.judgement = WarpJudgement::WHITE_UNSURE; + }else if (transition.peak && transition.change != SceneChange::DIFFERENT && transition.hold() < knobs.min_fade_hold){ + out.judgement = WarpJudgement::TOO_SHORT; + } + return out; +} + + +std::string describe(const InterruptVerdict& verdict, WallClock origin){ + std::string out = interrupt_kind_name(verdict.kind); + const char* separator = ": "; + for (const ScreenEventChange& change : verdict.evidence){ + out += std::format( + "{}{:+} ms {} {}", + separator, + std::chrono::duration_cast(change.time - origin).count(), + screen_event_name(change.event), + change.edge == ScreenEventEdge::ROSE ? "rose" : "fell" + ); + separator = ", "; + } + if (verdict.log_truncated){ + out += " (log truncated)"; + } + return out; +} + + +InterruptVerdict classify_interrupt( + const ScreenEventLog& log, InterruptWindow window, const InterruptKnobs& knobs +){ + const Changes changes = changes_in(log, window); + std::optional verdict = battle_verdict(changes, knobs); + if (!verdict){ + verdict = warp_verdict(log, window, changes, knobs); + } + if (!verdict){ + verdict = dialog_verdict(changes); + } + InterruptVerdict out = verdict.value_or(InterruptVerdict{}); + out.log_truncated = log.dropped() > 0 && + (log.changes().empty() || window.start <= log.changes().front().time); + return out; +} + + +std::optional interrupt_step_time(const InterruptVerdict& verdict, const std::vector& lags){ + for (const ScreenEventChange& change : verdict.evidence){ + if (change.edge != ScreenEventEdge::ROSE){ + continue; + } + for (const EventLag& lag : lags){ + if (lag.event == change.event){ + return change.time - lag.lag; + } + } + } + return std::nullopt; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts.h new file mode 100644 index 0000000000..52d23c811c --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts.h @@ -0,0 +1,157 @@ +/* Interrupts + * + * From: https://github.com/PokemonAutomation/ + * + * What stopped the player, judged from the order of screen events + * (ScreenEventLog), the same way for every game: a wild encounter, a + * trainer's sight, a dialog, a warp. One frame cannot tell them apart; the + * order can. A white flash then a battle is an encounter, a white fade with + * no battle after it a cave exit (BDSP's walk fled one as a battle, #268); + * a box then a flash then a battle is a trainer's lines; a flash then a box + * then a battle is a wild battle's opening text. + * + * Games differ in timing only, so they pass their numbers (InterruptKnobs) + * rather than their own classifier. + * + */ + +#ifndef PokemonAutomation_Pokemon_Interrupts_H +#define PokemonAutomation_Pokemon_Interrupts_H + +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "Pokemon_ScreenEvents.h" +#include "Pokemon_Transitions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// A game's timing, as data. +struct InterruptKnobs{ + // A fade must hold this long to be a warp's. One uniform frame is enough + // by default: a 50 ms fade can show on one frame only. + Milliseconds min_fade_hold = Milliseconds(0); + // A battle that shows this soon after a fade ends is that fade's + // encounter, and a fade is no warp before this has passed with no + // battle. BDSP's encounter flash shows its battle within 8 s + // (WHITE_FLASH_BATTLE_WAIT in its walk). + Milliseconds battle_after_fade = Milliseconds(8000); + // A dialog box that rose this long or less before a battle's fade is a + // trainer's lines. A trainer's lines can pause more than 4 s before the + // battle (BDSP r210d), and a trainer may say several boxes. + Milliseconds sight_lines_lead = Milliseconds(20000); +}; + +// The span of the log to judge: changes at `start` or later, and no later +// than `end`. +struct InterruptWindow{ + WallClock start = {}; + WallClock end = {}; +}; + + +// How a fade looked, for the warp judge. +struct FadeSpan{ + WarpFade fade = WarpFade::NONE; + WallClock down = {}; + // When the screen came back; none while it still shows the fade. + std::optional up = {}; +}; + +// What the warp judge made of a fade. +enum class WarpJudgement{ + NO_FADE, // No fade went down in the window. + FADE_SHOWING, // It went down and has not come back up. + TOO_SHORT, // It came back up before min_fade_hold. + SAME_SCENE, // It came back up on the scene it left. + WARPED, // Down, held, back up; not on the same scene. + FLASH, // An encounter's flash (a tracked transition only). + WHITE_UNSURE, // A white that may be a flash or a cave exit (a + // tracked transition only): wait for a battle. +}; +// "fade too short", for the log. +const char* warp_judgement_name(WarpJudgement judgement); + +// The warp judge's verdict and the fade it judged. +struct WarpVerdict{ + WarpJudgement judgement = WarpJudgement::NO_FADE; + // The last fade that went down in the window, if any. + std::optional fade = {}; +}; + +// A warp is a fade going down, holding uniform, and coming back up on +// another scene. The last fade that went down in `window` is judged; a +// LOCATION_BANNER does not count here (WarpSeen does). `change` is what +// the caller saw of the scene before and after: UNKNOWN does not rule the +// warp out. This judges the fade only: a battle after it is +// classify_interrupt()'s question. +WarpVerdict judge_warp( + const ScreenEventLog& log, InterruptWindow window, + const InterruptKnobs& knobs, SceneChange change +); +// The same judge over a TransitionTracker's transition, which has its own +// scene comparison. A transition whose hold went unseen onto another scene +// is a warp whatever min_fade_hold says: the scene change is the evidence. +WarpVerdict judge_warp(const Transition& transition, const InterruptKnobs& knobs); + + +// What stopped the player. +enum class InterruptKind{ + NONE, + WILD_ENCOUNTER, // A battle with no trainer's lines before it. + TRAINER_SIGHT, // A dialog box, then a battle. + DIALOG, // A dialog box and no battle. + WARP, // A fade back up with no battle after it, or a banner. + UNSETTLED, // A fade, too recent to know if a battle follows. +}; +// "trainer sight", for the log. +const char* interrupt_kind_name(InterruptKind kind); + +// What stopped the player, and the evidence for it. +struct InterruptVerdict{ + InterruptKind kind = InterruptKind::NONE; + // The changes it was judged on, in order. + std::vector evidence = {}; + // The log dropped changes the window reaches back to: the verdict may + // miss what came first. + bool log_truncated = false; +}; +// "wild encounter: +0 ms white fade rose, +3400 ms battle started rose", +// times from `origin`. +std::string describe(const InterruptVerdict& verdict, WallClock origin); + +// What interrupted the player in `window` of `log`. Order of precedence: a +// battle, then a warp, then a dialog. Only events that rose in the window +// count: a box already up when it starts is not this interrupt's. +InterruptVerdict classify_interrupt( + const ScreenEventLog& log, InterruptWindow window, const InterruptKnobs& knobs +); + + +// How long after the step that set an interrupt off one of its events +// shows, as a game measured it on its console. +struct EventLag{ + ScreenEvent event = ScreenEvent::BATTLE_STARTED; + Milliseconds lag = Milliseconds(0); +}; + +// When the step that set `verdict`'s interrupt off was taken: the first +// rise in its evidence that `lags` times, less that lag. The first, as it +// shows nearest the step. Nothing if `lags` times none of them, as for a +// game that hasn't measured its events, or for no verdict. Lags are one +// kind of interrupt's (a wild battle's box, not a trainer's): the caller +// checks the verdict's kind first. +// +// This places an interrupt seen only after a walk's hold has ended, by +// what the log saw of it during the hold, instead of by when the watcher +// that stopped for it fired. +std::optional interrupt_step_time(const InterruptVerdict& verdict, const std::vector& lags); + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts_Tests.cpp new file mode 100644 index 0000000000..aecc5cbbd8 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Interrupts_Tests.cpp @@ -0,0 +1,435 @@ +/* Interrupts Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The timed screen-event log, the warp judge and the interrupt classifier, + * driven by synthetic scenes: a frame's events at a time. + * + */ + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Pokemon_AutoStory_Tests.h" +#include "Pokemon_Interrupts.h" +#include "Pokemon_ScreenEvents.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +namespace{ + +using namespace std::chrono_literals; + +const WallClock T0 = WallClock() + std::chrono::hours(1); + +UnitTestResult expect_equal(const std::string& what, const std::string& got, const std::string& expected){ + if (got == expected){ + return true; + } + return std::format("{}: expected \"{}\", got \"{}\"", what, expected, got); +} + +// One frame of a synthetic scene: its events, at `ms` after T0. +struct Frame{ + int ms; + ScreenEvents events; +}; + +// A log of `frames`, then the same as the last one every 500 ms until +// `until_ms` (a walk's screen, looked at while nothing changes). +ScreenEventLog log_of(const std::vector& frames, int until_ms, size_t capacity = SCREEN_EVENT_LOG_CAPACITY){ + ScreenEventLog log(capacity); + for (const Frame& frame : frames){ + log.record(frame.events, T0 + Milliseconds(frame.ms)); + } + const ScreenEvents last = frames.empty() ? ScreenEvents() : frames.back().events; + for (int ms = frames.empty() ? 0 : frames.back().ms + 500; ms <= until_ms; ms += 500){ + log.record(last, T0 + Milliseconds(ms)); + } + return log; +} + +const ScreenEvents WALK{ScreenEvent::PLAYER_CAN_MOVE}; +const ScreenEvents BLACK{ScreenEvent::FADE_BLACK}; +const ScreenEvents WHITE{ScreenEvent::FADE_WHITE}; +const ScreenEvents BOX{ScreenEvent::DIALOG_BOX, ScreenEvent::PLAYER_CAN_MOVE}; +const ScreenEvents BATTLE{ScreenEvent::BATTLE_STARTED}; +const ScreenEvents BATTLE_TEXT{ScreenEvent::DIALOG_BOX}; +const ScreenEvents BANNER{ScreenEvent::LOCATION_BANNER, ScreenEvent::PLAYER_CAN_MOVE}; + +InterruptWindow whole(int until_ms){ + return {T0, T0 + Milliseconds(until_ms)}; +} + + +UnitTestResult test_log_records_rises_and_falls(){ + const ScreenEventLog log = log_of({ + {0, WALK}, + {100, WALK}, + {200, BLACK}, + {450, ScreenEvents{ScreenEvent::LOCATION_BANNER, ScreenEvent::PLAYER_CAN_MOVE}}, + }, 450); + if (log.showing() != BANNER || log.last_frame() != T0 + 450ms || log.dropped() != 0){ + return "the log's last frame is not the banner frame at +450 ms"; + } + return expect_equal("describe(log)", describe(log, T0), + "+0 ms player can move rose\n" + "+200 ms black fade rose\n" + "+200 ms player can move fell\n" + "+450 ms black fade fell\n" + "+450 ms location banner rose\n" + "+450 ms player can move rose\n" + ); +} + +UnitTestResult test_log_overflow_and_clear(){ + ScreenEventLog log = log_of({{0, WALK}, {100, BOX}, {200, WALK}, {300, BOX}, {400, WALK}}, 400, 3); + if (log.changes().size() != 3 || log.dropped() != 2){ + return std::format("expected 3 kept and 2 dropped, got {} and {}", log.changes().size(), log.dropped()); + } + if (log.changes().front() != ScreenEventChange{T0 + 200ms, ScreenEvent::DIALOG_BOX, ScreenEventEdge::FELL}){ + return "the oldest change kept is not the box falling at +200 ms"; + } + const InterruptVerdict early = classify_interrupt(log, whole(400), {}); + const InterruptVerdict late = classify_interrupt(log, {T0 + 250ms, T0 + 400ms}, {}); + if (!early.log_truncated || late.log_truncated){ + return "only a window reaching back past the kept changes is truncated"; + } + UnitTestResult result = expect_equal("truncated verdict", describe(early, T0), "dialog: +300 ms dialog box rose (log truncated)"); + if (result.result != UnitTestResult::PASSED){ + return result; + } + log.clear(); + if (!log.changes().empty() || log.dropped() != 0 || log.last_frame() || log.showing() != ScreenEvents()){ + return "clear() did not empty the log"; + } + return true; +} + +UnitTestResult test_empty_log(){ + const ScreenEventLog log; + const InterruptVerdict verdict = classify_interrupt(log, whole(1000), {}); + if (judge_warp(log, whole(1000), {}, SceneChange::DIFFERENT).judgement != WarpJudgement::NO_FADE){ + return "an empty log has a fade"; + } + return expect_equal("empty log", describe(verdict, T0), "none"); +} + + +UnitTestResult test_judge_warp(){ + struct Row{ + const char* name; + std::vector frames; + SceneChange change; + WarpJudgement expected; + }; + const std::vector rows = { + {"down, held, back up on another scene", {{0, WALK}, {1000, BLACK}, {1400, WALK}}, SceneChange::DIFFERENT, WarpJudgement::WARPED}, + {"no frame compare does not rule it out", {{0, WALK}, {1000, BLACK}, {1400, WALK}}, SceneChange::UNKNOWN, WarpJudgement::WARPED}, + {"back on the same scene", {{0, WALK}, {1000, BLACK}, {1400, WALK}}, SceneChange::SAME, WarpJudgement::SAME_SCENE}, + {"a flicker", {{0, WALK}, {1000, BLACK}, {1050, WALK}}, SceneChange::DIFFERENT, WarpJudgement::TOO_SHORT}, + {"still dark", {{0, WALK}, {1000, BLACK}}, SceneChange::UNKNOWN, WarpJudgement::FADE_SHOWING}, + {"no fade", {{0, WALK}, {1000, BOX}}, SceneChange::DIFFERENT, WarpJudgement::NO_FADE}, + }; + const InterruptKnobs knobs{.min_fade_hold = 200ms}; + for (const Row& row : rows){ + const ScreenEventLog log = log_of(row.frames, 3000); + const WarpJudgement got = judge_warp(log, whole(3000), knobs, row.change).judgement; + if (got != row.expected){ + return std::format("{}: expected {}, got {}", row.name, warp_judgement_name(row.expected), warp_judgement_name(got)); + } + } + return true; +} + + +// Replays a fixed list of event sets, one per frame. +class ScriptedObserver : public ScreenEventObserver{ +public: + explicit ScriptedObserver(std::vector frames) + : m_frames(std::move(frames)) + {} + virtual ScreenEvents observe(const VideoSnapshot&, WallClock) override{ + return m_next < m_frames.size() ? m_frames[m_next++] : ScreenEvents(); + } + virtual void make_overlays(VideoOverlaySet&) const override{} + +private: + std::vector m_frames; + size_t m_next = 0; +}; + +UnitTestResult test_watcher_log(){ + ScriptedObserver observer({WALK, WHITE, WALK}); + ScreenEventWatcher watcher(observer); + for (int c = 0; c < 3; c++){ + watcher.process_frame(VideoSnapshot(ImageRGB32(4, 4), T0 + Milliseconds(100 * c))); + } + UnitTestResult result = expect_equal("watcher log", describe(watcher.log(), T0), + "+0 ms player can move rose\n" + "+100 ms white fade rose\n" + "+100 ms player can move fell\n" + "+200 ms white fade fell\n" + "+200 ms player can move rose\n" + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + watcher.reset(); + return expect_equal("watcher log after reset()", describe(watcher.log(), T0), "no changes"); +} + + +struct InterruptsInput{ + std::string name; + std::function body; +}; + +struct InterruptsExpectation{ +}; + +struct InterruptsCase{ + InterruptsInput input; + InterruptsExpectation expectation; +}; + +class Test_Interrupts : public UnitTest{ +public: + explicit Test_Interrupts(InterruptsCase test_case) + : UnitTest("Pokemon::Interrupts - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_case.input.body(); + } + +private: + InterruptsCase m_case; +}; + + +// A synthetic scene, and the window of it to judge. +struct ClassifyInput{ + std::string name; + std::vector frames; + int until_ms = 0; + InterruptKnobs knobs = {}; + int window_from_ms = 0; +}; + +struct ClassifyExpectation{ + // describe(verdict, T0). + std::string verdict; +}; + +struct ClassifyCase{ + ClassifyInput input; + ClassifyExpectation expectation; +}; + +class Test_InterruptClassify : public UnitTest{ +public: + explicit Test_InterruptClassify(ClassifyCase test_case) + : UnitTest("Pokemon::Interrupts - classify: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const ClassifyInput& input = m_case.input; + const ScreenEventLog log = log_of(input.frames, input.until_ms); + const InterruptWindow window{T0 + Milliseconds(input.window_from_ms), T0 + Milliseconds(input.until_ms)}; + const InterruptVerdict verdict = classify_interrupt(log, window, input.knobs); + return expect_equal("verdict", describe(verdict, T0), m_case.expectation.verdict); + } + +private: + ClassifyCase m_case; +}; + + +// A synthetic scene, classified over all of it, and the lags to time it. +struct StepTimeInput{ + std::string name; + std::vector frames; + int until_ms = 0; + std::vector lags; +}; + +struct StepTimeExpectation{ + // "+700 ms" from T0, or "none". + std::string step; +}; + +struct StepTimeCase{ + StepTimeInput input; + StepTimeExpectation expectation; +}; + +class Test_InterruptStepTime : public UnitTest{ +public: + explicit Test_InterruptStepTime(StepTimeCase test_case) + : UnitTest("Pokemon::Interrupts - step time: " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const StepTimeInput& input = m_case.input; + const InterruptVerdict verdict = classify_interrupt(log_of(input.frames, input.until_ms), whole(input.until_ms), {}); + const std::optional step = interrupt_step_time(verdict, input.lags); + const std::string got = step + ? std::format("{:+} ms", std::chrono::duration_cast(*step - T0).count()) + : "none"; + return expect_equal("step time of " + describe(verdict, T0), got, m_case.expectation.step); + } + +private: + StepTimeCase m_case; +}; + +} // namespace + + +const InterruptsCase INTERRUPTS_CASES[] = { + { + .input = {"the log keeps rises and falls in order, timed", test_log_records_rises_and_falls}, + .expectation = {}, + }, + { + .input = {"a full log drops its oldest changes and says so", test_log_overflow_and_clear}, + .expectation = {}, + }, + { + .input = {"an empty log is no interrupt and no fade", test_empty_log}, + .expectation = {}, + }, + { + .input = {"a warp is down, held, back up, not the same scene", test_judge_warp}, + .expectation = {}, + }, + { + .input = {"a watcher keeps a timed log", test_watcher_log}, + .expectation = {}, + }, +}; + +const ClassifyCase CLASSIFY_CASES[] = { + { + .input = {"an encounter's white flash, then the battle", + {{0, WALK}, {1000, WHITE}, {1300, ScreenEvents()}, {3000, BATTLE}}, 3000}, + .expectation = {"wild encounter: +1000 ms white fade rose, +3000 ms battle started rose"}, + }, + { + .input = {"a wild battle's opening text comes after its flash", + {{0, WALK}, {1000, WHITE}, {1300, BATTLE_TEXT}, {2500, BATTLE}}, 2500}, + .expectation = {"wild encounter: +1000 ms white fade rose, +2500 ms battle started rose"}, + }, + { + .input = {"a trainer's lines, a pause, the battle's fade", + {{0, WALK}, {1000, BOX}, {2000, WALK}, {6000, BLACK}, {6400, BATTLE}}, 6400}, + .expectation = {"trainer sight: +1000 ms dialog box rose, +6000 ms black fade rose, +6400 ms battle started rose"}, + }, + { + .input = {"a trainer's lines, then the battle with no fade seen", + {{0, WALK}, {1000, BOX}, {2000, BATTLE}}, 2000}, + .expectation = {"trainer sight: +1000 ms dialog box rose, +2000 ms battle started rose"}, + }, + { + .input = {"a box and no battle", {{0, WALK}, {1000, BOX}}, 20000}, + .expectation = {"dialog: +1000 ms dialog box rose"}, + }, + { + .input = {"a cave exit's white fade, and no battle for 8 s", + {{0, WALK}, {1000, WHITE}, {1600, WALK}}, 12000}, + .expectation = {"warp: +1000 ms white fade rose, +1600 ms white fade fell"}, + }, + { + .input = {"a white fade too recent to rule out a battle", + {{0, WALK}, {1000, WHITE}, {1600, WALK}}, 4000}, + .expectation = {"unsettled: +1000 ms white fade rose, +1600 ms white fade fell"}, + }, + { + .input = {"a fade still showing", {{0, WALK}, {1000, BLACK}}, 3000}, + .expectation = {"unsettled: +1000 ms black fade rose"}, + }, + { + .input = {"a banner after the white settles it at once", + {{0, WALK}, {1000, WHITE}, {1600, WALK}, {2000, BANNER}}, 2500}, + .expectation = {"warp: +1000 ms white fade rose, +1600 ms white fade fell, +2000 ms location banner rose"}, + }, + { + .input = {"a box during a fade is the scene cut's", + {{0, WALK}, {1000, BLACK}, {1300, ScreenEvents{ScreenEvent::FADE_BLACK, ScreenEvent::DIALOG_BOX}}, + {1800, BOX}, {3000, WALK}}, 12000}, + .expectation = {"warp: +1000 ms black fade rose, +1800 ms black fade fell"}, + }, + { + .input = {"a door's fade long before a battle is not the battle's", + {{0, WALK}, {500, BLACK}, {900, WALK}, {20000, BATTLE}}, 20000}, + .expectation = {"wild encounter: +20000 ms battle started rose"}, + }, + { + .input = {"a box up before the window is not this interrupt's", + {{0, BOX}, {1000, WALK}, {3000, WHITE}, {3300, WALK}, {5000, BATTLE}}, 5000, {}, 500}, + .expectation = {"wild encounter: +3000 ms white fade rose, +5000 ms battle started rose"}, + }, + { + .input = {"a one-frame flicker is no fade with a hold to meet", + {{0, WALK}, {1000, BLACK}, {1050, WALK}}, 12000, {.min_fade_hold = 200ms}}, + .expectation = {"none"}, + }, + { + .input = {"a one-frame fade is a warp by default", + {{0, WALK}, {1000, BLACK}, {1050, WALK}}, 12000}, + .expectation = {"warp: +1000 ms black fade rose, +1050 ms black fade fell"}, + }, + { + .input = {"a walk with nothing in it", {{0, WALK}}, 5000}, + .expectation = {"none"}, + }, +}; + +const StepTimeCase STEP_TIME_CASES[] = { + { + .input = {"a wild battle's box, its fade untimed", + {{0, WALK}, {1500, BLACK}, {2000, ScreenEvents()}, {4400, BATTLE}}, 6000, + {{ScreenEvent::BATTLE_STARTED, 3700ms}}}, + .expectation = {"+700 ms"}, + }, + { + .input = {"the first timed rise wins", + {{0, WALK}, {1500, BLACK}, {2000, ScreenEvents()}, {4400, BATTLE}}, 6000, + {{ScreenEvent::BATTLE_STARTED, 3700ms}, {ScreenEvent::FADE_BLACK, 1000ms}}}, + .expectation = {"+500 ms"}, + }, + { + .input = {"no lags, no time", + {{0, WALK}, {4400, BATTLE}}, 6000, {}}, + .expectation = {"none"}, + }, + { + .input = {"no interrupt, no time", + {{0, WALK}}, 6000, {{ScreenEvent::BATTLE_STARTED, 3700ms}}}, + .expectation = {"none"}, + }, +}; + +void add_tests_Interrupts(UnitTestDatabase& database){ + for (const auto& item : INTERRUPTS_CASES){ + database.add(item); + } + for (const auto& item : CLASSIFY_CASES){ + database.add(item); + } + for (const auto& item : STEP_TIME_CASES){ + database.add(item); + } +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_SceneJudges.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_SceneJudges.cpp new file mode 100644 index 0000000000..b92a63e2a9 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_SceneJudges.cpp @@ -0,0 +1,93 @@ +/* Scene Judges + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_SceneJudges.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +WarpSeen note_warp(WarpSeen seen, const ScreenEvents& events, const std::string& banner_read){ + if (seen.fade == WarpFade::NONE){ + seen.fade = fade_in(events); + } + if (seen.banner.empty() && events.has(ScreenEvent::LOCATION_BANNER)){ + seen.banner = banner_read; + } + return seen; +} + +std::string describe(const WarpSeen& seen){ + if (!seen.any()){ + return "nothing"; + } + std::string out = seen.fade == WarpFade::NONE ? "" : warp_fade_name(seen.fade); + if (!seen.banner.empty()){ + out += (out.empty() ? "banner \"" : ", banner \"") + seen.banner + "\""; + } + return out; +} + + +SceneMove TalkOver::look(const ScreenEvents& look, WallClock now){ + if (look.has(ScreenEvent::BATTLE_STARTED)){ + m_quiet_since = WallClock::max(); + m_calm_since = WallClock::max(); + return SceneMove::BATTLE; + } + m_fade_showing = fade_in(look); + if (m_fade_showing != WarpFade::NONE){ + m_fade = m_fade_showing; + } + m_banner |= look.has(ScreenEvent::LOCATION_BANNER); + const bool box = look.has(ScreenEvent::DIALOG_BOX); + // A fade is never calm: BDSP's overworld reads a black frame as no + // box, and black stays still, so a closing fade counted as quiet and + // ended Mom's scene as it went down (lab-B 2026-11-09 14:33). + const bool calm = !box && m_fade_showing == WarpFade::NONE && + look.has(ScreenEvent::PLAYER_CAN_MOVE) && warped(); + const bool quiet = calm && look.has(ScreenEvent::SCREEN_STILL); + if (!calm){ + m_calm_since = WallClock::max(); + }else if (m_calm_since == WallClock::max()){ + m_calm_since = now; + } + if (!quiet){ + m_quiet_since = WallClock::max(); + }else if (m_quiet_since == WallClock::max()){ + m_quiet_since = now; + } + const bool over = + (quiet && now - m_quiet_since >= TALK_OVER_QUIET) || + (calm && now - m_calm_since >= TALK_OVER_CALM); + if (over && m_rule.end_on_quiet){ + return SceneMove::DONE; + } + return box ? SceneMove::NEXT : SceneMove::B; +} + +bool TalkOver::warped() const{ + return !m_rule.warp || m_fade != WarpFade::NONE || m_banner; +} + +std::string TalkOver::describe() const{ + if (m_fade == WarpFade::NONE && !m_banner){ + return "no fade or banner"; + } + std::string out = m_fade == WarpFade::NONE ? "" : warp_fade_name(m_fade); + if (m_fade != WarpFade::NONE){ + out += m_fade_showing == WarpFade::NONE ? " back up" : " still down"; + } + if (m_banner){ + out += out.empty() ? "banner read" : ", banner read"; + } + return out; +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_SceneJudges.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_SceneJudges.h new file mode 100644 index 0000000000..908dcaa39c --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_SceneJudges.h @@ -0,0 +1,104 @@ +/* Scene Judges + * + * From: https://github.com/PokemonAutomation/ + * + * Judgements a story step makes from screen events (Pokemon_ScreenEvents.h) + * over time, not from one look (#268's design note): a warp is a fade or + * the banner of the map arrived on, and a talk or a scene is over when the + * overworld has been quiet for a while. Pure: tests drive them with + * scripted scenes, and every game shares them. + * + */ + +#ifndef PokemonAutomation_Pokemon_SceneJudges_H +#define PokemonAutomation_Pokemon_SceneJudges_H + +#include +#include "Common/Cpp/Time.h" +#include "Pokemon_ScreenEvents.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// What a walk or a step saw of a warp (#268): a fade or the location +// banner of the map arrived on. Either one alone is the warp. +struct WarpSeen{ + WarpFade fade = WarpFade::NONE; + // The banner read, "" if none. + std::string banner = {}; + + bool any() const{ + return fade != WarpFade::NONE || !banner.empty(); + } +}; +// `seen` after a frame's events: their fade, and `banner_read` when they +// show LOCATION_BANNER. The first of each counts. +WarpSeen note_warp(WarpSeen seen, const ScreenEvents& events, const std::string& banner_read); +// How it was seen, for the log: a black fade, banner "Route 203". +std::string describe(const WarpSeen& seen); + + +// What to do after a look. +enum class SceneMove{ + NEXT, // A box shows: press the talk's button. + B, // No box: press B. It never starts a talk on the overworld + // (A would, and answers Yes on a prompt). + BATTLE, // A battle started. + DONE, // The talk or scene is over. +}; + +// What ends a talk-over, besides the quiet. +struct TalkOverRule{ + // A map change must come first: a fade or the banner. Only the quiet + // after it counts: r30's scene went quiet for 5 s before the fade home. + bool warp = false; + // False when a battle is to follow (a trainer's lines pause for more + // than 4 s before it, r210d): no quiet ends the talk then. + bool end_on_quiet = true; +}; + +// The overworld with no box and a still screen this long ends it: a scene +// pauses while someone walks off (Dawn and Barry after Rowan leaves, r23), +// a scripted walk-in ran 5 s with no text before Mom spoke (r31), and +// Roark's badge speech pauses for the fanfare (lab-B 2026-10-31 09:45 and +// 2026-11-08 15:06). The clock is the last look that was not quiet. +constexpr Milliseconds TALK_OVER_QUIET = Milliseconds(5000); +// The overworld with no box this long ends it, still or not: wanderers or +// grass can keep the screen from ever being still. +constexpr Milliseconds TALK_OVER_CALM = Milliseconds(15000); + +// When a talk or a scene is over, from looks alone. A look is a frame's +// events: DIALOG_BOX, PLAYER_CAN_MOVE, SCREEN_STILL, a fade, +// LOCATION_BANNER (the expected map's) and BATTLE_STARTED are read. +// It is over only once every fade has come back up, no box shows, and +// the overworld has been quiet (or calm) since: a look that shows a fade +// starts the quiet over, however still it is. +class TalkOver{ +public: + explicit TalkOver(TalkOverRule rule = {}) + : m_rule(rule) + {} + + SceneMove look(const ScreenEvents& look, WallClock now); + + // Whether the warp the rule asks for has shown (true without one). + bool warped() const; + // What it has seen, for the log: "black fade, banner read". + std::string describe() const; + +private: + TalkOverRule m_rule; + // The first fade seen, and the one the last look showed. + WarpFade m_fade = WarpFade::NONE; + WarpFade m_fade_showing = WarpFade::NONE; + bool m_banner = false; + WallClock m_quiet_since = WallClock::max(); + WallClock m_calm_since = WallClock::max(); +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents.cpp new file mode 100644 index 0000000000..b58a85cd9a --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents.cpp @@ -0,0 +1,203 @@ +/* Screen Events + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Pokemon_ScreenEvents.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +const char* screen_event_name(ScreenEvent event){ + switch (event){ + case ScreenEvent::FADE_BLACK: return "black fade"; + case ScreenEvent::FADE_WHITE: return "white fade"; + case ScreenEvent::LOCATION_BANNER: return "location banner"; + case ScreenEvent::DIALOG_BOX: return "dialog box"; + case ScreenEvent::CHOICE_BOX: return "choice box"; + case ScreenEvent::INTERACT_PROMPT: return "interact prompt"; + case ScreenEvent::BATTLE_STARTED: return "battle started"; + case ScreenEvent::BATTLE_ENDED: return "battle ended"; + case ScreenEvent::PLAYER_CAN_MOVE: return "player can move"; + case ScreenEvent::SCREEN_STILL: return "screen still"; + case ScreenEvent::SWITCH_HOME_MENU: return "Switch HOME menu"; + } + return "?"; +} + +const char* warp_fade_name(WarpFade fade){ + switch (fade){ + case WarpFade::NONE: return "no fade"; + case WarpFade::BLACK: return "black fade"; + case WarpFade::WHITE: return "white fade"; + } + return "?"; +} + + +ScreenEvents::ScreenEvents(std::initializer_list events){ + for (ScreenEvent event : events){ + set(event, true); + } +} +void ScreenEvents::set(ScreenEvent event, bool on){ + if (on){ + m_bits |= bit(event); + }else{ + m_bits &= ~bit(event); + } +} +ScreenEvents ScreenEvents::from_bits(uint32_t bits){ + ScreenEvents out; + out.m_bits = bits & ((uint32_t(1) << SCREEN_EVENT_COUNT) - 1); + return out; +} + +ScreenEvents fade_events(WarpFade fade){ + switch (fade){ + case WarpFade::NONE: + return {}; + case WarpFade::BLACK: + return {ScreenEvent::FADE_BLACK}; + case WarpFade::WHITE: + return {ScreenEvent::FADE_WHITE}; + } + return {}; +} +WarpFade fade_in(const ScreenEvents& events){ + if (events.has(ScreenEvent::FADE_BLACK)){ + return WarpFade::BLACK; + } + if (events.has(ScreenEvent::FADE_WHITE)){ + return WarpFade::WHITE; + } + return WarpFade::NONE; +} +std::string describe(const ScreenEvents& events){ + std::string out; + for (size_t c = 0; c < SCREEN_EVENT_COUNT; c++){ + const ScreenEvent event = (ScreenEvent)c; + if (!events.has(event)){ + continue; + } + if (!out.empty()){ + out += ", "; + } + out += screen_event_name(event); + } + return out.empty() ? "nothing" : out; +} + + +WarpFade fade_of(const ImageStats& middle){ + if (middle.stddev.sum() > FADE_MAX_STDDEV_SUM){ + return WarpFade::NONE; + } + const double sum = middle.average.sum(); + return sum <= FADE_MAX_DARK_SUM ? WarpFade::BLACK : sum >= FADE_MIN_WHITE_SUM ? WarpFade::WHITE : WarpFade::NONE; +} +WarpFade fade_of_screen(const ImageViewRGB32& screen){ + return fade_of(image_stats(extract_box_reference(screen, ImageFloatBox(0.1, 0.1, 0.8, 0.8)))); +} + + +ScreenEventLog::ScreenEventLog(size_t capacity) + : m_capacity(capacity) +{} +void ScreenEventLog::record(const ScreenEvents& frame, WallClock now){ + const uint32_t changed = frame.bits() ^ m_showing.bits(); + for (size_t c = 0; c < SCREEN_EVENT_COUNT; c++){ + if ((changed & (uint32_t(1) << c)) == 0){ + continue; + } + const ScreenEvent event = (ScreenEvent)c; + m_changes.push_back({now, event, frame.has(event) ? ScreenEventEdge::ROSE : ScreenEventEdge::FELL}); + } + while (m_changes.size() > m_capacity){ + m_changes.pop_front(); + m_dropped++; + } + m_showing = frame; + m_last_frame = now; +} +void ScreenEventLog::clear(){ + m_changes.clear(); + m_dropped = 0; + m_showing = ScreenEvents(); + m_last_frame.reset(); +} +std::string describe(const ScreenEventLog& log, WallClock origin){ + std::string out; + if (log.dropped() > 0){ + out += std::format("({} older changes dropped)\n", log.dropped()); + } + for (const ScreenEventChange& change : log.changes()){ + out += std::format( + "{:+} ms {} {}\n", + std::chrono::duration_cast(change.time - origin).count(), + screen_event_name(change.event), + change.edge == ScreenEventEdge::ROSE ? "rose" : "fell" + ); + } + return out.empty() ? "no changes" : out; +} + + +SwitchHomeObserver::SwitchHomeObserver(NintendoSwitch::ConsoleHandle& console, ScreenEventObserver& game) + : m_game(game) + , m_home(console, COLOR_RED) +{} + +ScreenEvents SwitchHomeObserver::observe(const VideoSnapshot& frame, WallClock now){ + if (frame && m_home.detect_only(*frame.frame)){ + return {ScreenEvent::SWITCH_HOME_MENU}; + } + return m_game.observe(frame, now); +} + +void SwitchHomeObserver::make_overlays(VideoOverlaySet& items) const{ + m_game.make_overlays(items); +} + + +ScreenEventWatcher::ScreenEventWatcher(ScreenEventObserver& observer, ScreenEvents stop_on) + : VisualInferenceCallback("ScreenEventWatcher") + , m_observer(observer) + , m_stop_on(stop_on) +{} +void ScreenEventWatcher::make_overlays(VideoOverlaySet& items) const{ + m_observer.make_overlays(items); +} +bool ScreenEventWatcher::process_frame(const VideoSnapshot& frame){ + const ScreenEvents events = m_observer.observe(frame, frame.timestamp); + m_seen.fetch_or(events.bits(), std::memory_order_acq_rel); + { + LockGuard lg(m_log_lock); + m_log.record(events, frame.timestamp); + } + if (!events.any_of(m_stop_on)){ + return false; + } + m_stopped_on.store(events.bits(), std::memory_order_release); + return true; +} +ScreenEventLog ScreenEventWatcher::log() const{ + LockGuard lg(m_log_lock); + return m_log; +} +void ScreenEventWatcher::reset(){ + m_seen.store(0, std::memory_order_release); + m_stopped_on.store(0, std::memory_order_release); + LockGuard lg(m_log_lock); + m_log.clear(); +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents.h new file mode 100644 index 0000000000..800351a306 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents.h @@ -0,0 +1,262 @@ +/* Screen Events + * + * From: https://github.com/PokemonAutomation/ + * + * What a story step can see on the screen, in words every game shares: a + * fade, a location banner, a dialogue box, a battle starting. Each game + * implements ScreenEventObserver with its own detectors; the judgements + * built on these events (Pokemon_SceneJudges.h) are then the same for all + * of them. + * + * An observer runs during a controller action as a ScreenEventWatcher + * (a VisualInferenceCallback for run_until), or is asked frame by frame. + * + */ + +#ifndef PokemonAutomation_Pokemon_ScreenEvents_H +#define PokemonAutomation_Pokemon_ScreenEvents_H + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Concurrency/Mutex.h" +#include "Common/Cpp/Time.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "NintendoSwitch/Inference/NintendoSwitch_HomeMenuDetector.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// One thing a frame shows. A game reports the ones its detectors can see. +enum class ScreenEvent : uint8_t{ + FADE_BLACK, // A uniform dark screen: a door, a cave mouth, a scene cut. + FADE_WHITE, // A uniform white screen: a cave exit. + LOCATION_BANNER, // A location banner. An observer given an expected + // map reports only that map's. + DIALOG_BOX, // A dialogue box. + CHOICE_BOX, // A choice (Yes/No, a menu of answers). + INTERACT_PROMPT, // A button prompt to interact ("A Talk"). + BATTLE_STARTED, // A battle shows (its menu, its HUD or its opening). + BATTLE_ENDED, // A battle's closing screen. + PLAYER_CAN_MOVE, // The overworld with the player in control. + SCREEN_STILL, // The screen barely changed since the frame before. + SWITCH_HOME_MENU, // The Switch's HOME menu: the game is not on the screen. +}; +// How many ScreenEvent values there are. +constexpr size_t SCREEN_EVENT_COUNT = 11; +// "dialog box", for the log. +const char* screen_event_name(ScreenEvent event); + + +// How a screen's fade looks: the kind of FADE_BLACK / FADE_WHITE. +enum class WarpFade{ + NONE, + BLACK, // a door's black, or a cave mouth's dim fade + WHITE, // a cave exit's white, with no battle after it +}; +// "black fade", for the log. +const char* warp_fade_name(WarpFade fade); + + +// The events of one frame, or of all the frames a watcher saw. +class ScreenEvents{ +public: + ScreenEvents() = default; + ScreenEvents(std::initializer_list events); + + bool has(ScreenEvent event) const{ + return (m_bits & bit(event)) != 0; + } + // Whether any of `events` is in this set. + bool any_of(const ScreenEvents& events) const{ + return (m_bits & events.m_bits) != 0; + } + // Adds `event`, or removes it when `on` is false (as std::bitset::set). + void set(ScreenEvent event, bool on); + void merge(const ScreenEvents& events){ + m_bits |= events.m_bits; + } + + uint32_t bits() const{ + return m_bits; + } + static ScreenEvents from_bits(uint32_t bits); + + bool operator==(const ScreenEvents& other) const = default; + +private: + static uint32_t bit(ScreenEvent event){ + return uint32_t(1) << (uint32_t)event; + } + + uint32_t m_bits = 0; +}; +// The event of a fade: FADE_BLACK, FADE_WHITE, or none for WarpFade::NONE. +ScreenEvents fade_events(WarpFade fade); +// The fade `events` show: BLACK before WHITE if they have both. +WarpFade fade_in(const ScreenEvents& events); +// "dialog box, player can move"; "nothing" when empty. +std::string describe(const ScreenEvents& events); + + +// A fade is uniform over the middle 80% of the screen, the shared +// Black/WhiteScreenWatcher's stddev test (upstream #1717): a dim cave floor +// is textured, so it is no fade (BDSP's Oreburgh Gate, mean 46, lab-B +// 2026-11-08). Check against the Gate's frames on the console before +// tightening. +constexpr double FADE_MAX_STDDEV_SUM = 10; +// Dark goes to an rgb sum of 225, as a cave mouth's fade is dim, 45 to 64 +// a channel, not black. +constexpr double FADE_MAX_DARK_SUM = 225; +// White from an rgb sum of 500, as the shared WhiteScreenWatcher's. +constexpr double FADE_MIN_WHITE_SUM = 500; +// The fade `middle` (the stats of the screen's middle 80%) shows, if any. +WarpFade fade_of(const ImageStats& middle); +// fade_of() for a screen. +WarpFade fade_of_screen(const ImageViewRGB32& screen); + + +// Whether an event started or stopped showing. +enum class ScreenEventEdge : uint8_t{ + ROSE, + FELL, +}; + +// One event starting or stopping to show, at the frame that showed it. +struct ScreenEventChange{ + WallClock time; + ScreenEvent event; + ScreenEventEdge edge; + + bool operator==(const ScreenEventChange& other) const = default; +}; + +// How many changes a log keeps. A walk's events change a few times a +// second at most (a fade's black, a box, the screen going still), so this +// is a minute or more of a busy scene. +constexpr size_t SCREEN_EVENT_LOG_CAPACITY = 256; + +// The events of a stream of frames, in order and timed: what rose and fell +// when, not only what was seen (ScreenEventWatcher::seen()). The judges that +// care about order read it (Pokemon_Interrupts.h): a flash then a battle is +// an encounter, a box then a battle a trainer's sight. +// +// It keeps the newest `capacity` changes and counts the ones it dropped. +// Not thread-safe; ScreenEventWatcher guards its own. +class ScreenEventLog{ +public: + explicit ScreenEventLog(size_t capacity = SCREEN_EVENT_LOG_CAPACITY); + + // Adds a frame: a change for each event that rose or fell since the + // frame before (the first frame's events all rise). `now` must not go + // back in time. + void record(const ScreenEvents& frame, WallClock now); + + // The changes kept, oldest first. + const std::deque& changes() const{ + return m_changes; + } + // How many changes were dropped, oldest first, to keep the capacity. + size_t dropped() const{ + return m_dropped; + } + // The events of the last frame recorded. + ScreenEvents showing() const{ + return m_showing; + } + // When the last frame was recorded; none before the first. + std::optional last_frame() const{ + return m_last_frame; + } + void clear(); + +private: + size_t m_capacity; + std::deque m_changes; + size_t m_dropped = 0; + ScreenEvents m_showing; + std::optional m_last_frame; +}; +// "+1200 ms dialog box rose", times from `origin`, one change per line. +std::string describe(const ScreenEventLog& log, WallClock origin); + + +// A game's detectors, as events. Observers may keep state between frames +// (the frame before, for SCREEN_STILL; when a banner was last read, to +// gate OCR), so one observer watches one stream at a time. +class ScreenEventObserver{ +public: + virtual ~ScreenEventObserver() = default; + + // What `frame` shows. `now` is the caller's clock, for observers that + // rate-limit expensive reads. A null frame shows nothing. + virtual ScreenEvents observe(const VideoSnapshot& frame, WallClock now) = 0; + + // The boxes the detectors read, for the video overlay. + virtual void make_overlays(VideoOverlaySet& items) const = 0; +}; + + +// A game's observer behind a look at the Switch itself: a frame showing +// the HOME menu shows SWITCH_HOME_MENU and nothing else, as a game's +// detectors may read HOME's plain screen as a quiet overworld (BDSP, +// lab-B 2026-11-09). Every other frame is the game's to read. +class SwitchHomeObserver : public ScreenEventObserver{ +public: + SwitchHomeObserver(NintendoSwitch::ConsoleHandle& console, ScreenEventObserver& game); + + virtual ScreenEvents observe(const VideoSnapshot& frame, WallClock now) override; + virtual void make_overlays(VideoOverlaySet& items) const override; + +private: + ScreenEventObserver& m_game; + NintendoSwitch::HomeMenuDetector m_home; +}; + + +// An observer as a run_until callback: it records every event it sees, in +// order in a log, and stops the action on any of `stop_on` (none: it only +// records). +class ScreenEventWatcher : public VisualInferenceCallback{ +public: + ScreenEventWatcher(ScreenEventObserver& observer, ScreenEvents stop_on = {}); + + virtual void make_overlays(VideoOverlaySet& items) const override; + using VisualInferenceCallback::process_frame; + virtual bool process_frame(const VideoSnapshot& frame) override; + + // Every event seen since construction (or the last reset()). + ScreenEvents seen() const{ + return ScreenEvents::from_bits(m_seen.load(std::memory_order_acquire)); + } + // The events of the frame that stopped the action ("nothing" if none did). + ScreenEvents stopped_on() const{ + return ScreenEvents::from_bits(m_stopped_on.load(std::memory_order_acquire)); + } + // A copy of the timed log of every frame since construction (or the + // last reset()). + ScreenEventLog log() const; + void reset(); + +private: + ScreenEventObserver& m_observer; + const ScreenEvents m_stop_on; + std::atomic m_seen{0}; + std::atomic m_stopped_on{0}; + mutable Mutex m_log_lock; + ScreenEventLog m_log; +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents_Tests.cpp new file mode 100644 index 0000000000..6620fd0204 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_ScreenEvents_Tests.cpp @@ -0,0 +1,280 @@ +/* Screen Events Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The shared event vocabulary, the watcher form of an observer, and the + * judgements over events (warp seen, talk-over). + * + */ + +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Pokemon_AutoStory_Tests.h" +#include "Pokemon_SceneJudges.h" +#include "Pokemon_ScreenEvents.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +namespace{ + +using namespace std::chrono_literals; + +UnitTestResult expect_equal(const std::string& what, const std::string& got, const std::string& expected){ + if (got == expected){ + return true; + } + return std::format("{}: expected \"{}\", got \"{}\"", what, expected, got); +} + + +UnitTestResult test_set_and_describe(){ + ScreenEvents events{ScreenEvent::DIALOG_BOX, ScreenEvent::PLAYER_CAN_MOVE}; + if (!events.has(ScreenEvent::DIALOG_BOX) || events.has(ScreenEvent::CHOICE_BOX)){ + return "has() disagrees with the constructor's events"; + } + events.set(ScreenEvent::DIALOG_BOX, false); + events.merge(fade_events(WarpFade::WHITE)); + events.merge(fade_events(WarpFade::NONE)); + if (fade_in(events) != WarpFade::WHITE){ + return "fade_events(WHITE) did not give a white fade"; + } + if (ScreenEvents::from_bits(events.bits()) != events){ + return "from_bits(bits()) is not the same set"; + } + if (describe(ScreenEvents()) != "nothing"){ + return "an empty set does not describe as nothing"; + } + return expect_equal("describe()", describe(events), "white fade, player can move"); +} + +// A screen filled with one colour, or a checkerboard of two. +ImageRGB32 screen_of(uint32_t a, uint32_t b){ + ImageRGB32 out(64, 36); + for (size_t y = 0; y < out.height(); y++){ + for (size_t x = 0; x < out.width(); x++){ + out.pixel(x, y) = (x + y) % 2 == 0 ? a : b; + } + } + return out; +} + +UnitTestResult test_fade_of_screen(){ + struct Row{ + const char* name; + uint32_t a; + uint32_t b; + WarpFade fade; + }; + const std::vector rows = { + {"black", 0xff000000, 0xff000000, WarpFade::BLACK}, + {"a cave mouth's dim fade (60 a channel)", 0xff3c3c3c, 0xff3c3c3c, WarpFade::BLACK}, + {"white", 0xffffffff, 0xffffffff, WarpFade::WHITE}, + {"grey, neither", 0xff787878, 0xff787878, WarpFade::NONE}, + {"a dark textured floor", 0xff101010, 0xff505050, WarpFade::NONE}, + }; + for (const Row& row : rows){ + const WarpFade got = fade_of_screen(screen_of(row.a, row.b)); + if (got != row.fade){ + return std::format("{}: expected {}, got {}", row.name, warp_fade_name(row.fade), warp_fade_name(got)); + } + } + return true; +} + +UnitTestResult test_warp_seen(){ + WarpSeen seen = note_warp({}, {ScreenEvent::DIALOG_BOX}, "Route 203"); + if (seen.any()){ + return "a dialog box with no banner event counted as a warp"; + } + seen = note_warp(seen, {ScreenEvent::FADE_BLACK}, ""); + seen = note_warp(seen, {ScreenEvent::FADE_WHITE, ScreenEvent::LOCATION_BANNER}, "Route 203"); + seen = note_warp(seen, {ScreenEvent::LOCATION_BANNER}, "Oreburgh Gate"); + return expect_equal("describe(WarpSeen)", describe(seen), "black fade, banner \"Route 203\""); +} + + +// A scripted scene: a look per beat, each `every` apart. Returns the beat +// TalkOver first says `want` at, or -1. +int first_beat(TalkOver& judge, const std::vector& beats, SceneMove want, Milliseconds every = 1000ms){ + WallClock now = WallClock(); + for (size_t c = 0; c < beats.size(); c++){ + if (judge.look(beats[c], now) == want){ + return (int)c; + } + now += every; + } + return -1; +} + +const ScreenEvents QUIET{ScreenEvent::PLAYER_CAN_MOVE, ScreenEvent::SCREEN_STILL}; +const ScreenEvents CALM{ScreenEvent::PLAYER_CAN_MOVE}; +const ScreenEvents BOX{ScreenEvent::DIALOG_BOX, ScreenEvent::PLAYER_CAN_MOVE, ScreenEvent::SCREEN_STILL}; +// A black frame as BDSP's eyes see it: no box, so "overworld", and still. +const ScreenEvents FADED{ScreenEvent::FADE_BLACK, ScreenEvent::PLAYER_CAN_MOVE, ScreenEvent::SCREEN_STILL}; +const ScreenEvents CALM_FADED{ScreenEvent::FADE_BLACK, ScreenEvent::PLAYER_CAN_MOVE}; + +std::vector repeat(const ScreenEvents& look, size_t count){ + return std::vector(count, look); +} +std::vector concat(std::vector a, const std::vector& b){ + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +UnitTestResult test_talk_over(){ + struct Row{ + const char* name; + TalkOverRule rule; + std::vector beats; + SceneMove want; + int beat; + }; + const std::vector rows = { + {"5 s of quiet ends it", {}, repeat(QUIET, 8), SceneMove::DONE, 5}, + {"a box restarts the quiet", {}, concat(concat(repeat(QUIET, 3), {BOX}), repeat(QUIET, 8)), SceneMove::DONE, 9}, + {"a box is answered", {}, {BOX}, SceneMove::NEXT, 0}, + {"no box is a B press", {}, {QUIET}, SceneMove::B, 0}, + {"15 s calm but moving ends it", {}, repeat(CALM, 20), SceneMove::DONE, 15}, + {"a warp rule waits for the fade", {.warp = true}, + concat(concat(repeat(QUIET, 8), {ScreenEvents{ScreenEvent::FADE_BLACK}}), repeat(QUIET, 8)), + SceneMove::DONE, 14}, + {"a banner is the warp too", {.warp = true}, + concat({ScreenEvents{ScreenEvent::LOCATION_BANNER, ScreenEvent::PLAYER_CAN_MOVE}}, repeat(QUIET, 8)), + SceneMove::DONE, 6}, + {"no quiet ends a talk before a battle", {.end_on_quiet = false}, repeat(QUIET, 30), SceneMove::DONE, -1}, + {"a battle", {.end_on_quiet = false}, concat(repeat(QUIET, 6), {ScreenEvents{ScreenEvent::BATTLE_STARTED}}), SceneMove::BATTLE, 6}, + // Mom's Running Shoes scene (lab-B 2026-11-09 14:33): quiet, then + // the scene's closing fade. It ended at the fade's start and the + // next step's input landed in the fade. + {"a closing fade starts the quiet over", {}, + concat(concat(repeat(QUIET, 4), repeat(FADED, 3)), repeat(QUIET, 8)), + SceneMove::DONE, 12}, + {"a fade still down never ends it", {}, concat(repeat(QUIET, 3), repeat(FADED, 20)), SceneMove::DONE, -1}, + {"a fade is never calm", {}, concat(repeat(CALM, 10), repeat(CALM_FADED, 10)), SceneMove::DONE, -1}, + }; + for (const Row& row : rows){ + TalkOver judge(row.rule); + const int got = first_beat(judge, row.beats, row.want); + if (got != row.beat){ + return std::format("{}: expected beat {}, got {} ({})", row.name, row.beat, got, judge.describe()); + } + } + return true; +} + + +// Replays a fixed list of event sets, one per frame. +class ScriptedObserver : public ScreenEventObserver{ +public: + explicit ScriptedObserver(std::vector frames) + : m_frames(std::move(frames)) + {} + virtual ScreenEvents observe(const VideoSnapshot&, WallClock) override{ + return m_next < m_frames.size() ? m_frames[m_next++] : ScreenEvents(); + } + virtual void make_overlays(VideoOverlaySet&) const override{} + +private: + std::vector m_frames; + size_t m_next = 0; +}; + +UnitTestResult test_watcher(){ + ScriptedObserver observer({ + {ScreenEvent::PLAYER_CAN_MOVE}, + {ScreenEvent::FADE_BLACK}, + {ScreenEvent::LOCATION_BANNER, ScreenEvent::PLAYER_CAN_MOVE}, + {ScreenEvent::DIALOG_BOX}, + }); + ScreenEventWatcher watcher(observer, {ScreenEvent::LOCATION_BANNER}); + const VideoSnapshot frame; + std::vector stops; + for (size_t c = 0; c < 4; c++){ + stops.push_back(watcher.process_frame(frame)); + } + if (stops != std::vector{false, false, true, false}){ + return "the watcher did not stop exactly on the banner frame"; + } + UnitTestResult result = expect_equal("seen()", describe(watcher.seen()), + "black fade, location banner, dialog box, player can move"); + if (result.result != UnitTestResult::PASSED){ + return result; + } + result = expect_equal("stopped_on()", describe(watcher.stopped_on()), "location banner, player can move"); + if (result.result != UnitTestResult::PASSED){ + return result; + } + watcher.reset(); + return expect_equal("seen() after reset()", describe(watcher.seen()), "nothing"); +} + + +struct ScreenEventsInput{ + std::string name; + std::function body; +}; + +struct ScreenEventsExpectation{ +}; + +struct ScreenEventsCase{ + ScreenEventsInput input; + ScreenEventsExpectation expectation; +}; + +class Test_ScreenEvents : public UnitTest{ +public: + explicit Test_ScreenEvents(ScreenEventsCase test_case) + : UnitTest("Pokemon::ScreenEvents - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_case.input.body(); + } + +private: + ScreenEventsCase m_case; +}; + +} // namespace + + +const ScreenEventsCase SCREEN_EVENTS_CASES[] = { + { + .input = {"a set of events, its fade and its log text", test_set_and_describe}, + .expectation = {}, + }, + { + .input = {"a fade is uniform, dark or white; a textured floor is none", test_fade_of_screen}, + .expectation = {}, + }, + { + .input = {"a warp is the first fade and the first banner seen", test_warp_seen}, + .expectation = {}, + }, + { + .input = {"talk-over from scripted events", test_talk_over}, + .expectation = {}, + }, + { + .input = {"an observer as a watcher records and stops", test_watcher}, + .expectation = {}, + }, +}; + +void add_tests_ScreenEvents(UnitTestDatabase& database){ + for (const auto& item : SCREEN_EVENTS_CASES){ + database.add(item); + } +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions.cpp new file mode 100644 index 0000000000..715869aa27 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions.cpp @@ -0,0 +1,272 @@ +/* Transitions + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "Pokemon_Transitions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +const FadeProfile BDSP_FADE_PROFILE{ + .uniform_max_stddev_sum = 10, + .partial_max_stddev_sum = 60, + .dark_max_sum = 225, + .partial_dark_max_sum = 300, + .white_min_sum = 500, + .partial_white_min_sum = 450, + .max_fall = Milliseconds(1000), + .max_hold = Milliseconds(8000), + .has_white_fade = true, + .flash_max_hold = Milliseconds(150), + .white_fade_min_hold = Milliseconds(400), +}; +const FadeProfile FRLG_FADE_PROFILE{ + .uniform_max_stddev_sum = 10, + .partial_max_stddev_sum = 60, + .dark_max_sum = 100, + .partial_dark_max_sum = 200, + .white_min_sum = 500, + .partial_white_min_sum = 450, + .max_fall = Milliseconds(1000), + .max_hold = Milliseconds(8000), + .has_white_fade = false, + .flash_max_hold = Milliseconds(150), + .white_fade_min_hold = Milliseconds(400), +}; + + +const char* transition_kind_name(TransitionKind kind){ + switch (kind){ + case TransitionKind::FADE_BLACK: return "black fade"; + case TransitionKind::FADE_WHITE: return "white fade"; + case TransitionKind::FLASH: return "flash"; + case TransitionKind::WHITE_UNSURE: return "white, flash or fade"; + } + return "?"; +} + + +TransitionSample transition_sample(const VideoSnapshot& frame){ + TransitionSample out{frame.timestamp, {}, frame}; + if (frame){ + out.middle = image_stats(extract_box_reference(*frame.frame, ImageFloatBox(0.1, 0.1, 0.8, 0.8))); + } + return out; +} + + +Milliseconds Transition::hold() const{ + if (!peak || !rise){ + return Milliseconds(0); + } + return std::chrono::duration_cast(*rise - *peak); +} + +namespace{ + +std::string offset(std::optional time, WallClock origin){ + if (!time){ + return "unseen"; + } + return std::format("{:+} ms", std::chrono::duration_cast(*time - origin).count()); +} +const char* scene_change_name(SceneChange change){ + switch (change){ + case SceneChange::UNKNOWN: return "scene unknown"; + case SceneChange::SAME: return "same scene"; + case SceneChange::DIFFERENT: return "other scene"; + } + return "?"; +} + +} // namespace + +std::string describe(const Transition& transition, WallClock origin){ + std::string out = std::format( + "{} {}, peak {}, rise {}", + transition_kind_name(transition.kind), + offset(transition.start, origin), + offset(transition.peak, origin), + offset(transition.rise, origin) + ); + if (!transition.end){ + return out + ", in progress"; + } + return out + std::format(", end {}, {}", offset(transition.end, origin), scene_change_name(transition.change)); +} + + +TransitionTracker::TransitionTracker(FadeProfile profile, CompareScenes compare) + : m_profile(profile) + , m_compare(std::move(compare)) +{} + +TransitionTracker::Level TransitionTracker::level_of(const ImageStats& middle) const{ + const double stddev = middle.stddev.sum(); + const double sum = middle.average.sum(); + if (stddev <= m_profile.uniform_max_stddev_sum){ + if (sum <= m_profile.dark_max_sum){ + return Level::DARK; + } + if (sum >= m_profile.white_min_sum){ + return Level::WHITE; + } + } + if (stddev <= m_profile.partial_max_stddev_sum){ + if (sum <= m_profile.partial_dark_max_sum){ + return Level::PARTIAL_DARK; + } + if (sum >= m_profile.partial_white_min_sum){ + return Level::PARTIAL_WHITE; + } + } + return Level::SCENE; +} + +std::optional TransitionTracker::push(const TransitionSample& sample){ + switch (level_of(sample.middle)){ + case Level::SCENE: + return on_scene(sample); + case Level::PARTIAL_DARK: + return on_partial(sample, false); + case Level::PARTIAL_WHITE: + return on_partial(sample, true); + case Level::DARK: + on_uniform(sample, false); + return std::nullopt; + case Level::WHITE: + on_uniform(sample, true); + return std::nullopt; + } + return std::nullopt; +} + +std::optional TransitionTracker::on_scene(const TransitionSample& sample){ + m_scene_is_partial = false; + if (m_phase == TransitionPhase::SCENE){ + m_before = sample; + return std::nullopt; + } + if (m_phase == TransitionPhase::HOLD){ + m_current.rise = sample.time; + } + return finish(sample); +} + +// A partial frame starts a fall from a scene, unless the scene itself +// looks partial (a dim floor). Partial frames for longer than max_fall, +// falling or rising, are a scene again. +std::optional TransitionTracker::on_partial(const TransitionSample& sample, bool white){ + switch (m_phase){ + case TransitionPhase::SCENE: + if (m_scene_is_partial){ + m_before = sample; + return std::nullopt; + } + m_current = Transition{.start = sample.time}; + m_white = white; + m_phase = TransitionPhase::FALLING; + return std::nullopt; + case TransitionPhase::FALLING: + m_white = white; + if (sample.time - m_current.start > m_profile.max_fall){ + m_phase = TransitionPhase::SCENE; + m_scene_is_partial = true; + m_before = sample; + } + return std::nullopt; + case TransitionPhase::HOLD: + m_current.rise = sample.time; + m_phase = TransitionPhase::RISING; + return std::nullopt; + case TransitionPhase::RISING: + if (sample.time - *m_current.rise <= m_profile.max_fall){ + return std::nullopt; + } + m_scene_is_partial = true; + return finish(sample); + } + return std::nullopt; +} + +void TransitionTracker::on_uniform(const TransitionSample& sample, bool white){ + switch (m_phase){ + case TransitionPhase::SCENE: + m_current = Transition{.start = sample.time, .peak = sample.time}; + m_white = white; + break; + case TransitionPhase::FALLING: + m_current.peak = sample.time; + m_white = white; + break; + case TransitionPhase::HOLD: + break; + case TransitionPhase::RISING: + // Back down again before a scene showed: the same hold. + m_current.rise.reset(); + break; + } + m_phase = TransitionPhase::HOLD; +} + +// Back on a scene: a transition if it held, or if it fell to another +// scene with its hold unseen. A fall back to the same scene is a dip. +std::optional TransitionTracker::finish(const TransitionSample& sample){ + const SceneChange change = m_compare && m_before ? m_compare(*m_before, sample) : SceneChange::UNKNOWN; + m_phase = TransitionPhase::SCENE; + m_before = sample; + if (!m_current.peak && change != SceneChange::DIFFERENT){ + return std::nullopt; + } + m_current.end = sample.time; + m_current.change = change; + m_current.kind = m_white ? kind_of(m_current.hold(), change) : TransitionKind::FADE_BLACK; + m_last = m_current; + return m_current; +} + +TransitionKind TransitionTracker::kind_of(Milliseconds hold, SceneChange change) const{ + if (!m_profile.has_white_fade){ + return TransitionKind::FLASH; + } + if (hold <= m_profile.flash_max_hold && change != SceneChange::DIFFERENT){ + return TransitionKind::FLASH; + } + if (hold >= m_profile.white_fade_min_hold && change != SceneChange::SAME){ + return TransitionKind::FADE_WHITE; + } + return TransitionKind::WHITE_UNSURE; +} + +std::optional TransitionTracker::current() const{ + if (m_phase == TransitionPhase::SCENE){ + return m_last; + } + Transition out = m_current; + out.kind = m_white ? TransitionKind::WHITE_UNSURE : TransitionKind::FADE_BLACK; + return out; +} + +bool TransitionTracker::timed_out(WallClock now) const{ + return m_phase != TransitionPhase::SCENE && now - m_current.start > m_profile.max_hold; +} + +void TransitionTracker::reset(){ + m_phase = TransitionPhase::SCENE; + m_white = false; + m_scene_is_partial = false; + m_before.reset(); + m_current = Transition{}; + m_last.reset(); +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions.h b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions.h new file mode 100644 index 0000000000..5764247571 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions.h @@ -0,0 +1,197 @@ +/* Transitions + * + * From: https://github.com/PokemonAutomation/ + * + * A screen transition followed over many frames rather than judged on one: + * the scene, the screen falling toward a uniform colour, the uniform hold, + * the rise, and the scene it comes back on. fade_of() alone judges one + * frame, so a 50 ms door fade that falls between two sampled frames is never + * seen, and the step waits out its whole timeout (12 s for a BDSP door). + * The tracker sees the frames around it instead: the screen falling, then a + * different scene. + * + * Each game's numbers are a FadeProfile; the tracker is the same for all. + * + */ + +#ifndef PokemonAutomation_Pokemon_Transitions_H +#define PokemonAutomation_Pokemon_Transitions_H + +#include +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Whether the screen differs after a transition from before it. The +// tracker has only frame statistics; the caller compares the frames, or +// says it did not. +enum class SceneChange{ + UNKNOWN, + SAME, + DIFFERENT, +}; + + +// A game's fades, as data. Two tiers of the same test: a frame is uniform +// (the fade's hold) when its middle's stddev sum is at most +// `uniform_max_stddev_sum`, and partial (on the way down or up) when it is +// at most `partial_max_stddev_sum`. Either way it is dark or white by its +// rgb sum. +struct FadeProfile{ + double uniform_max_stddev_sum = 10; + double partial_max_stddev_sum = 60; + // Dark at or below this rgb sum; a partial frame up to `partial_dark_max_sum`. + double dark_max_sum = 225; + double partial_dark_max_sum = 300; + // White at or above this rgb sum; a partial frame from `partial_white_min_sum`. + double white_min_sum = 500; + double partial_white_min_sum = 450; + + // Partial frames for longer than this, with no hold, are the scene's + // own look (a dim cave floor, a street at night), not a fade. + Milliseconds max_fall = Milliseconds(1000); + // A transition still dark or white after this has timed out: the + // screen did not come back. + Milliseconds max_hold = Milliseconds(8000); + + // Whether the game fades to white between maps (BDSP's cave exits). + // Without, every white is an encounter's flash. + bool has_white_fade = true; + // A white held this long or less, back on the same scene, is a flash. + Milliseconds flash_max_hold = Milliseconds(150); + // A white held this long or more, not back on the same scene, is a + // white fade. Between the two the white is unsure. + Milliseconds white_fade_min_hold = Milliseconds(400); +}; + +// BDSP. The uniform tier is the shared walk's (FADE_MAX_STDDEV_SUM, the +// Oreburgh Gate's textured floor is no fade, lab-B 2026-11-08); the partial +// tier is run_into_door()'s 60, which caught the doorway's dim fade frames. +// A cave mouth's fade is dim (45 to 64 a channel), hence the dark sum of 225. +// The flash and white-fade holds are provisional: no console frames of an +// encounter flash and a cave exit have been timed side by side yet, so the +// band between them is wide and reports WHITE_UNSURE. +extern const FadeProfile BDSP_FADE_PROFILE; +// FRLG. The GBA fades to true black, so the dark tiers are the shared +// BlackScreenDetector's 100 and a little above; it has no white fade between +// maps, so every white is an encounter's flash. +extern const FadeProfile FRLG_FADE_PROFILE; + + +// What a transition was. +enum class TransitionKind : uint8_t{ + FADE_BLACK, // A door, a cave mouth, a scene cut, a battle's own fade. + FADE_WHITE, // A cave exit: a long white onto another scene. + FLASH, // An encounter's flash: a short white, back on the scene. + WHITE_UNSURE, // A white that may be either: wait for a battle. +}; +// "black fade", for the log. +const char* transition_kind_name(TransitionKind kind); + +// Where the tracker is in a transition. +enum class TransitionPhase : uint8_t{ + SCENE, // No transition: the screen shows a scene. + FALLING, // Partial frames, toward dark or white. + HOLD, // Uniform frames. + RISING, // Partial frames after the hold. +}; + +// One frame, as the tracker reads it. +struct TransitionSample{ + WallClock time = {}; + // The stats of the screen's middle 80%, as fade_of_screen() reads. + ImageStats middle = {}; + // The frame itself, for the caller's scene comparison. May be empty. + VideoSnapshot frame = {}; +}; +// A sample of `frame`, at its timestamp. +TransitionSample transition_sample(const VideoSnapshot& frame); + +// One transition, complete or in progress. +struct Transition{ + TransitionKind kind = TransitionKind::FADE_BLACK; + // The first frame off the scene: falling, or the hold if no fall was seen. + WallClock start = {}; + // The first uniform frame. None: the hold fell between two frames, and + // only the falling frames and the new scene were seen. + std::optional peak = {}; + // The first frame off the hold. + std::optional rise = {}; + // The first frame back on a scene. None while in progress. + std::optional end = {}; + // The scene after against the one before; UNKNOWN in progress. + SceneChange change = SceneChange::UNKNOWN; + + // How long the uniform frames showed; zero with no peak. + Milliseconds hold() const; +}; +// "black fade +200 ms, peak +300 ms, rise +700 ms, end +800 ms, other scene", +// times from `origin`. +std::string describe(const Transition& transition, WallClock origin); + + +// The transition state machine, fed one frame at a time: scene, falling, +// hold, rising, scene. Not thread-safe. +class TransitionTracker{ +public: + // Compares the last scene frame before a transition with the first + // after it. + using CompareScenes = std::function; + + // With no `compare`, every scene change is UNKNOWN, and a transition + // whose hold went unseen is not reported. + explicit TransitionTracker(FadeProfile profile, CompareScenes compare = {}); + + // Adds a frame; times must not go back. Returns the transition this + // frame completed, if any. + std::optional push(const TransitionSample& sample); + + TransitionPhase phase() const{ + return m_phase; + } + // The transition in progress, else the last one completed. + std::optional current() const; + // Whether the transition in progress has gone on longer than max_hold + // at `now`. + bool timed_out(WallClock now) const; + void reset(); + +private: + enum class Level : uint8_t{ + SCENE, + PARTIAL_DARK, + PARTIAL_WHITE, + DARK, + WHITE, + }; + Level level_of(const ImageStats& middle) const; + + std::optional on_scene(const TransitionSample& sample); + std::optional on_partial(const TransitionSample& sample, bool white); + void on_uniform(const TransitionSample& sample, bool white); + std::optional finish(const TransitionSample& sample); + TransitionKind kind_of(Milliseconds hold, SceneChange change) const; + + FadeProfile m_profile; + CompareScenes m_compare; + TransitionPhase m_phase = TransitionPhase::SCENE; + bool m_white = false; + // The scene itself looks partial (a dim floor): partial frames are no fall. + bool m_scene_is_partial = false; + std::optional m_before; + Transition m_current; + std::optional m_last; +}; + + + +} // namespace Pokemon +} // namespace PokemonAutomation +#endif diff --git a/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions_Tests.cpp b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions_Tests.cpp new file mode 100644 index 0000000000..2f29f54475 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/AutoStory/Pokemon_Transitions_Tests.cpp @@ -0,0 +1,378 @@ +/* Transitions Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The transition tracker on synthetic frame statistics: a frame is its + * middle's mean and stddev at a time. Then the same tracker on captured + * frames from CommandLineTests, when they are checked out. + * + */ + +#include +#include +#include +#include +#include +#include "Common/Cpp/Filesystem/FileIO.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "Pokemon_AutoStory_Tests.h" +#include "Pokemon_Interrupts.h" +#include "Pokemon_Transitions.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + +namespace{ + +using namespace std::chrono_literals; + +const WallClock T0 = WallClock() + std::chrono::hours(1); + +ImageStats stats(double r, double g, double b, double stddev){ + return ImageStats(FloatPixel(r, g, b), FloatPixel(stddev, stddev, stddev), 1000); +} + +// Scenes are textured; each has its own mean, which the comparison reads. +const ImageStats ROUTE = stats(120, 100, 90, 40); +const ImageStats HOUSE = stats(100, 120, 90, 40); +// A cave: dark, but textured past the partial tier. +const ImageStats CAVE = stats(50, 45, 40, 25); +// The Oreburgh Gate's dim floor: mean 46, textured within the partial tier. +const ImageStats DIM_FLOOR = stats(46, 46, 46, 8); +// A street at night: dark, but lit windows keep it textured. +const ImageStats NIGHT = stats(40, 40, 55, 30); +const ImageStats FALLING_DARK = stats(60, 60, 60, 10); +const ImageStats DARK = stats(5, 5, 5, 1); +const ImageStats FALLING_WHITE = stats(160, 160, 160, 10); +const ImageStats WHITE = stats(250, 250, 250, 1); + +SceneChange compare_means(const TransitionSample& before, const TransitionSample& after){ + const FloatPixel& a = before.middle.average; + const FloatPixel& b = after.middle.average; + return a.r == b.r && a.g == b.g && a.b == b.b ? SceneChange::SAME : SceneChange::DIFFERENT; +} + +// `stats` from `from_ms` to `to_ms`, a frame every 100 ms. +struct Span{ + int from_ms; + int to_ms; + ImageStats stats; +}; + +// The transitions completed, as describe() puts them, joined by "; "; +// "none" if none. +std::string run_tracker(TransitionTracker& tracker, const std::vector& spans){ + std::string out; + for (const Span& span : spans){ + for (int ms = span.from_ms; ms <= span.to_ms; ms += 100){ + const std::optional done = tracker.push({T0 + Milliseconds(ms), span.stats, {}}); + if (done){ + out += (out.empty() ? "" : "; ") + describe(*done, T0); + } + } + } + return out.empty() ? "none" : out; +} + + +// A synthetic stream of frames, through one game's profile. +struct TrackInput{ + std::string name; + std::vector spans; + const FadeProfile* profile = &BDSP_FADE_PROFILE; + bool compare = true; +}; + +struct TrackExpectation{ + // The transitions completed, as run_tracker() puts them. + std::string transitions; + // What judge_warp() makes of the tracker's current() at the end; empty + // for none. + std::string warp = ""; +}; + +struct TrackCase{ + TrackInput input; + TrackExpectation expectation; +}; + +class Test_TransitionTracker : public UnitTest{ +public: + explicit Test_TransitionTracker(TrackCase test_case) + : UnitTest("Pokemon::Transitions - " + test_case.input.name) + , m_case(std::move(test_case)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const TrackInput& input = m_case.input; + TransitionTracker tracker(*input.profile, input.compare ? compare_means : TransitionTracker::CompareScenes()); + const std::string got = run_tracker(tracker, input.spans); + if (got != m_case.expectation.transitions){ + return std::format("transitions: expected \"{}\", got \"{}\"", m_case.expectation.transitions, got); + } + const std::optional current = tracker.current(); + const InterruptKnobs knobs{.min_fade_hold = 200ms}; + const std::string warp = current ? warp_judgement_name(judge_warp(*current, knobs).judgement) : ""; + if (warp != m_case.expectation.warp){ + return std::format("warp: expected \"{}\", got \"{}\"", m_case.expectation.warp, warp); + } + return true; + } + +private: + TrackCase m_case; +}; + + +UnitTestResult test_timeout(){ + TransitionTracker tracker(BDSP_FADE_PROFILE, compare_means); + const std::string got = run_tracker(tracker, {{0, 100, ROUTE}, {200, 8200, DARK}}); + if (got != "none" || tracker.phase() != TransitionPhase::HOLD){ + return "a screen that stays dark completes no transition and stays in its hold"; + } + if (tracker.timed_out(T0 + 8200ms) || !tracker.timed_out(T0 + 8300ms)){ + return "a transition times out max_hold after it started, not before"; + } + const std::string current = describe(*tracker.current(), T0); + if (current != "black fade +200 ms, peak +200 ms, rise unseen, in progress"){ + return "current(): " + current; + } + tracker.reset(); + if (tracker.phase() != TransitionPhase::SCENE || tracker.current() || tracker.timed_out(T0 + 9000ms)){ + return "reset() did not clear the tracker"; + } + return true; +} + +UnitTestResult test_sample_of_frame(){ + ImageRGB32 image(40, 40); + image.fill(0xff000000); + const TransitionSample sample = transition_sample(VideoSnapshot(std::move(image), T0)); + TransitionTracker tracker(BDSP_FADE_PROFILE); + tracker.push(sample); + if (sample.time != T0 || tracker.phase() != TransitionPhase::HOLD){ + return "a black frame's sample is not a hold at its timestamp"; + } + return transition_sample(VideoSnapshot()).middle.count == 0 + ? UnitTestResult(true) + : UnitTestResult("an empty frame has stats"); +} + + +// Captured frames: CommandLineTests//Transitions// holds +// frames named by their time in ms ("0.png", "250.png"), and expected.txt +// with the transitions run_tracker() should report (no scene comparison). +struct CapturedInput{ + std::string game; + const FadeProfile* profile; +}; + +// Each case folder's expected.txt holds its expectation. +struct CapturedExpectation{ +}; + +struct CapturedCase{ + CapturedInput input; + CapturedExpectation expectation; +}; + +std::string track_captured_case(const Filesystem::Path& folder, const FadeProfile& profile){ + std::vector> frames; + for (const Filesystem::Path& file : Filesystem::list_directory(folder)){ + if (file.extension().string() == ".png"){ + frames.emplace_back(std::stoi(file.stem().string()), file); + } + } + std::sort(frames.begin(), frames.end(), [](const auto& a, const auto& b){ + return a.first < b.first; + }); + TransitionTracker tracker(profile); + std::string out; + for (const auto& frame : frames){ + VideoSnapshot snapshot(ImageRGB32(frame.second.string()), T0 + Milliseconds(frame.first)); + const std::optional done = tracker.push(transition_sample(snapshot)); + if (done){ + out += (out.empty() ? "" : "; ") + describe(*done, T0); + } + } + return out.empty() ? "none" : out; +} + +class Test_CapturedTransitions : public UnitTest{ +public: + explicit Test_CapturedTransitions(CapturedCase test_case) + : UnitTest("Pokemon::Transitions - captured frames: " + test_case.input.game) + , m_input(std::move(test_case.input)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + const Filesystem::Path root(UNIT_TEST_RESOURCE_PATH() + m_input.game + "/Transitions/"); + if (!Filesystem::is_directory(root)){ + return UnitTestResult(UnitTestResult::SKIPPED, "no captured transitions at " + root.string()); + } + for (const Filesystem::Path& folder : Filesystem::list_directory(root)){ + if (!Filesystem::is_directory(folder)){ + continue; + } + std::string expected = file_to_string((folder / "expected.txt").string()); + expected.erase(expected.find_last_not_of("\r\n") + 1); + const std::string got = track_captured_case(folder, *m_input.profile); + if (got != expected){ + return std::format("{}: expected \"{}\", got \"{}\"", folder.filename().string(), expected, got); + } + } + return true; + } + +private: + CapturedInput m_input; +}; + + +struct TransitionsInput{ + std::string name; + UnitTestResult (*body)(); +}; + +// The body checks its own expectations. +struct TransitionsExpectation{ +}; + +struct TransitionsCase{ + TransitionsInput input; + TransitionsExpectation expectation; +}; + +class Test_Transitions : public UnitTest{ +public: + explicit Test_Transitions(TransitionsCase test_case) + : UnitTest("Pokemon::Transitions - " + test_case.input.name) + , m_input(std::move(test_case.input)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_input.body(); + } + +private: + TransitionsInput m_input; +}; + +} // namespace + + +const TrackCase TRACK_CASES[] = { + { + .input = {"a door: falls, holds, rises onto another scene", + {{0, 100, ROUTE}, {200, 200, FALLING_DARK}, {300, 700, DARK}, {800, 800, FALLING_DARK}, {900, 1000, HOUSE}}}, + .expectation = {"black fade +200 ms, peak +300 ms, rise +800 ms, end +900 ms, other scene", "warped"}, + }, + { + .input = {"a missed short fade: falling, then another scene", + {{0, 100, ROUTE}, {200, 200, FALLING_DARK}, {300, 400, HOUSE}}}, + .expectation = {"black fade +200 ms, peak unseen, rise unseen, end +300 ms, other scene", "warped"}, + }, + { + .input = {"a missed fade is not reported with no scene comparison", + {{0, 100, ROUTE}, {200, 200, FALLING_DARK}, {300, 400, HOUSE}}, &BDSP_FADE_PROFILE, false}, + .expectation = {"none"}, + }, + { + .input = {"a dip back onto the same scene is no transition", + {{0, 100, ROUTE}, {200, 300, FALLING_DARK}, {400, 500, ROUTE}}}, + .expectation = {"none"}, + }, + { + .input = {"a one-frame black onto the same scene is too short", + {{0, 100, ROUTE}, {200, 200, DARK}, {300, 400, ROUTE}}}, + .expectation = {"black fade +200 ms, peak +200 ms, rise +300 ms, end +300 ms, same scene", "same scene after the fade"}, + }, + { + .input = {"a one-frame black, scene unknown, is too short", + {{0, 100, ROUTE}, {200, 200, DARK}, {300, 400, ROUTE}}, &BDSP_FADE_PROFILE, false}, + .expectation = {"black fade +200 ms, peak +200 ms, rise +300 ms, end +300 ms, scene unknown", "fade too short"}, + }, + { + .input = {"an encounter's flash: a short white, back on the scene", + {{0, 100, ROUTE}, {200, 200, WHITE}, {300, 400, ROUTE}}}, + .expectation = {"flash +200 ms, peak +200 ms, rise +300 ms, end +300 ms, same scene", "an encounter's flash"}, + }, + { + .input = {"a cave exit: a long white onto another scene", + {{0, 100, CAVE}, {200, 200, FALLING_WHITE}, {300, 800, WHITE}, {900, 900, FALLING_WHITE}, {1000, 1100, ROUTE}}}, + .expectation = {"white fade +200 ms, peak +300 ms, rise +900 ms, end +1000 ms, other scene", "warped"}, + }, + { + .input = {"a white between a flash and a fade, scene unknown, is unsure", + {{0, 100, ROUTE}, {200, 400, WHITE}, {500, 600, ROUTE}}, &BDSP_FADE_PROFILE, false}, + .expectation = {"white, flash or fade +200 ms, peak +200 ms, rise +500 ms, end +500 ms, scene unknown", "a white, flash or fade"}, + }, + { + .input = {"a short white onto another scene may be the battle: unsure", + {{0, 100, ROUTE}, {200, 200, WHITE}, {300, 400, HOUSE}}}, + .expectation = {"white, flash or fade +200 ms, peak +200 ms, rise +300 ms, end +300 ms, other scene", "a white, flash or fade"}, + }, + { + .input = {"FRLG has no white fade: a long white is a flash", + {{0, 100, CAVE}, {300, 800, WHITE}, {1000, 1100, ROUTE}}, &FRLG_FADE_PROFILE}, + .expectation = {"flash +300 ms, peak +300 ms, rise +1000 ms, end +1000 ms, other scene", "an encounter's flash"}, + }, + { + .input = {"the Oreburgh Gate's dim floor is no fade, a door from it is", + {{0, 100, ROUTE}, {200, 3000, DIM_FLOOR}, {3100, 3400, DARK}, {3500, 3600, HOUSE}}}, + .expectation = {"black fade +3100 ms, peak +3100 ms, rise +3500 ms, end +3500 ms, other scene", "warped"}, + }, + { + .input = {"a street at night is no fade", {{0, 5000, NIGHT}}}, + .expectation = {"none"}, + }, + { + .input = {"a cave mouth's fade rises into a dim cave", + {{0, 100, ROUTE}, {200, 500, DARK}, {600, 2500, DIM_FLOOR}}}, + .expectation = {"black fade +200 ms, peak +200 ms, rise +600 ms, end +1700 ms, other scene", "warped"}, + }, + { + .input = {"a fade in progress is still showing", + {{0, 100, ROUTE}, {200, 200, FALLING_DARK}, {300, 600, DARK}}}, + .expectation = {"none", "fade still showing"}, + }, +}; + +const TransitionsCase TRANSITIONS_CASES[] = { + { + .input = {"a screen that stays dark times out", test_timeout}, + .expectation = {}, + }, + { + .input = {"a video frame's sample", test_sample_of_frame}, + .expectation = {}, + }, +}; + +const CapturedCase CAPTURED_CASES[] = { + { + .input = {"PokemonBDSP", &BDSP_FADE_PROFILE}, + .expectation = {}, + }, + { + .input = {"PokemonFRLG", &FRLG_FADE_PROFILE}, + .expectation = {}, + }, +}; + +void add_tests_Transitions(UnitTestDatabase& database){ + for (const auto& item : TRACK_CASES){ + database.add(item); + } + for (const auto& item : TRANSITIONS_CASES){ + database.add(item); + } + for (const auto& item : CAPTURED_CASES){ + database.add(item); + } +} + + + +} // namespace Pokemon +} // namespace PokemonAutomation diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 1e121e4ab0..5e1f728dfe 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1509,6 +1509,14 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/AutoStory/Pokemon_AutoStorySelect_Tests.h Source/Pokemon/AutoStory/Pokemon_AutoStoryStats.h Source/Pokemon/AutoStory/Pokemon_AutoStory_Tests.h + Source/Pokemon/AutoStory/Pokemon_Interrupts.cpp + Source/Pokemon/AutoStory/Pokemon_Interrupts.h + Source/Pokemon/AutoStory/Pokemon_Interrupts_Tests.cpp + Source/Pokemon/AutoStory/Pokemon_SceneJudges.cpp + Source/Pokemon/AutoStory/Pokemon_SceneJudges.h + Source/Pokemon/AutoStory/Pokemon_ScreenEvents.cpp + Source/Pokemon/AutoStory/Pokemon_ScreenEvents.h + Source/Pokemon/AutoStory/Pokemon_ScreenEvents_Tests.cpp Source/Pokemon/AutoStory/Pokemon_StoryArgs_Tests.cpp Source/Pokemon/AutoStory/Pokemon_StoryEvents.cpp Source/Pokemon/AutoStory/Pokemon_StoryEvents.h @@ -1526,6 +1534,9 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/AutoStory/Pokemon_StoryVariant.h Source/Pokemon/AutoStory/Pokemon_TilePose.cpp Source/Pokemon/AutoStory/Pokemon_TilePose.h + Source/Pokemon/AutoStory/Pokemon_Transitions.cpp + Source/Pokemon/AutoStory/Pokemon_Transitions.h + Source/Pokemon/AutoStory/Pokemon_Transitions_Tests.cpp Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.cpp Source/Pokemon/AutoStory/Types/Pokemon_StoryArgs.h Source/Pokemon/AutoStory/Types/Pokemon_StoryCheckLevel.cpp