From 823f5318d56532c7e286d9f6097494eaaf204e16 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sat, 31 Oct 2026 21:59:50 -0700 Subject: [PATCH 01/19] Common: add MoveOnlyFunction, a move-only callable wrapper std::function is copyable, so copying one silently forks a stateful lambda's state, and it can't hold move-only captures. std::move_only_function fixes both, but Apple Clang's libc++ doesn't have it yet. MoveOnlyFunction is an alias of std::move_only_function where the library defines it (GCC, MSVC) and a small fallback elsewhere (macOS), with copying deleted. Tested with four unit tests on fork CI (Mac runs the fallback; Ubuntu and Windows clang run the std branch). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- Common/Cpp/MoveOnlyFunction.h | 118 +++++++++++ Common/Cpp/MoveOnlyFunction_Tests.cpp | 190 ++++++++++++++++++ Common/Cpp/MoveOnlyFunction_Tests.h | 21 ++ .../ComputerPrograms_UnitTestRunner.cpp | 2 + SerialPrograms/cmake/SourceFiles.cmake | 3 + 5 files changed, 334 insertions(+) create mode 100644 Common/Cpp/MoveOnlyFunction.h create mode 100644 Common/Cpp/MoveOnlyFunction_Tests.cpp create mode 100644 Common/Cpp/MoveOnlyFunction_Tests.h diff --git a/Common/Cpp/MoveOnlyFunction.h b/Common/Cpp/MoveOnlyFunction.h new file mode 100644 index 0000000000..9cc71f3422 --- /dev/null +++ b/Common/Cpp/MoveOnlyFunction.h @@ -0,0 +1,118 @@ +/* Move-Only Function + * + * From: https://github.com/PokemonAutomation/ + * + * A callable wrapper that can be moved but not copied. + * + * Prefer this over std::function for a callback that has one owner. + * Copying a std::function copies its callable, so a stateful lambda + * silently splits into two independent states. And std::function cannot + * hold a lambda that captures a move-only object (e.g. std::unique_ptr). + * + * This is std::move_only_function (C++23) where the standard library has + * it. Apple's libc++ (Xcode 26 / Apple Clang 21) does not, so this header + * has a minimal fallback for the plain "R(Args...)" signature. Delete the + * fallback once every supported compiler defines + * __cpp_lib_move_only_function. + * + */ + +#ifndef PokemonAutomation_MoveOnlyFunction_H +#define PokemonAutomation_MoveOnlyFunction_H + +#include +#include + +#ifndef __cpp_lib_move_only_function +#include +#include +#include +#include +#endif + +namespace PokemonAutomation{ + + + +#ifdef __cpp_lib_move_only_function + +template +using MoveOnlyFunction = std::move_only_function; + +#else + +template +class MoveOnlyFunction; + +template +class MoveOnlyFunction{ +public: + MoveOnlyFunction() noexcept = default; + MoveOnlyFunction(std::nullptr_t) noexcept {} + + template + requires ( + !std::is_same_v, MoveOnlyFunction> && + std::is_invocable_r_v&, Args...> + ) + MoveOnlyFunction(Function&& function){ + using Stored = std::decay_t; + // Like std::move_only_function, a null function pointer makes an + // empty wrapper. + if constexpr (std::is_pointer_v || std::is_member_pointer_v){ + if (function == nullptr){ + return; + } + } + m_callable = std::make_unique>(std::forward(function)); + } + + MoveOnlyFunction(MoveOnlyFunction&&) noexcept = default; + MoveOnlyFunction& operator=(MoveOnlyFunction&&) noexcept = default; + MoveOnlyFunction(const MoveOnlyFunction&) = delete; + MoveOnlyFunction& operator=(const MoveOnlyFunction&) = delete; + + explicit operator bool() const noexcept{ + return m_callable != nullptr; + } + friend bool operator==(const MoveOnlyFunction& function, std::nullptr_t) noexcept{ + return !function; + } + + // Calling an empty wrapper is undefined, as for std::move_only_function. + R operator()(Args... args){ + return m_callable->call(std::forward(args)...); + } + + +private: + class Callable{ + public: + virtual ~Callable() = default; + virtual R call(Args&&... args) = 0; + }; + + template + class Holder final : public Callable{ + public: + template + explicit Holder(Source&& source) + : m_stored(std::forward(source)) + {} + virtual R call(Args&&... args) override{ + return std::invoke_r(m_stored, std::forward(args)...); + } + + private: + Stored m_stored; + }; + + std::unique_ptr m_callable; +}; + +#endif + + + +} +#endif diff --git a/Common/Cpp/MoveOnlyFunction_Tests.cpp b/Common/Cpp/MoveOnlyFunction_Tests.cpp new file mode 100644 index 0000000000..72ed5998ae --- /dev/null +++ b/Common/Cpp/MoveOnlyFunction_Tests.cpp @@ -0,0 +1,190 @@ +/* Move-Only Function Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include "TestRunners/UnitTestDatabase.h" +#include "MoveOnlyFunction.h" +#include "MoveOnlyFunction_Tests.h" + +namespace PokemonAutomation{ +namespace{ + + + +// Copying would fork a stateful callable, so it must not compile. +static_assert(!std::is_copy_constructible_v>); +static_assert(!std::is_copy_assignable_v>); +static_assert(std::is_nothrow_move_constructible_v>); + + + +std::string expected_int(const char* what, int expected, int actual){ + return std::string(what) + ": expected " + std::to_string(expected) + + ", got " + std::to_string(actual); +} + + + +class Test_MoveOnlyFunction_MoveOnlyCapture : public UnitTest{ +public: + Test_MoveOnlyFunction_MoveOnlyCapture() + : UnitTest("Common::MoveOnlyFunction - Move-only capture") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + auto value = std::make_unique(42); + MoveOnlyFunction function = [value = std::move(value)](int add){ + return *value + add; + }; + int actual = function(1); + if (actual != 43){ + return UnitTestResult(expected_int("call with a unique_ptr capture", 43, actual)); + } + + MoveOnlyFunction moved = std::move(function); + actual = moved(2); + if (actual != 44){ + return UnitTestResult(expected_int("call after a move", 44, actual)); + } + return true; + } +}; + + + +class Test_MoveOnlyFunction_StateNotForked : public UnitTest{ +public: + Test_MoveOnlyFunction_StateNotForked() + : UnitTest("Common::MoveOnlyFunction - Moving keeps one state") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + MoveOnlyFunction counter = [count = 0]() mutable{ + return ++count; + }; + int actual = counter(); + if (actual != 1){ + return UnitTestResult(expected_int("first call", 1, actual)); + } + + // The moved-to wrapper continues the same count. + MoveOnlyFunction constructed = std::move(counter); + actual = constructed(); + if (actual != 2){ + return UnitTestResult(expected_int("call after a move construction", 2, actual)); + } + + MoveOnlyFunction assigned; + assigned = std::move(constructed); + actual = assigned(); + if (actual != 3){ + return UnitTestResult(expected_int("call after a move assignment", 3, actual)); + } + return true; + } +}; + + + +int return_seven(){ + return 7; +} + +class Test_MoveOnlyFunction_Empty : public UnitTest{ +public: + Test_MoveOnlyFunction_Empty() + : UnitTest("Common::MoveOnlyFunction - Empty is false") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + MoveOnlyFunction defaulted; + if (defaulted){ + return UnitTestResult("default constructed: expected empty, got non-empty"); + } + MoveOnlyFunction from_nullptr = nullptr; + if (from_nullptr){ + return UnitTestResult("constructed from nullptr: expected empty, got non-empty"); + } + int (*null_pointer)() = nullptr; + MoveOnlyFunction from_null_pointer = null_pointer; + if (from_null_pointer){ + return UnitTestResult("constructed from a null function pointer: expected empty, got non-empty"); + } + + MoveOnlyFunction from_pointer = &return_seven; + if (!from_pointer){ + return UnitTestResult("constructed from a function pointer: expected non-empty, got empty"); + } + int actual = from_pointer(); + if (actual != 7){ + return UnitTestResult(expected_int("call through a function pointer", 7, actual)); + } + return true; + } +}; + + + +class Test_MoveOnlyFunction_Reassignment : public UnitTest{ +public: + Test_MoveOnlyFunction_Reassignment() + : UnitTest("Common::MoveOnlyFunction - Reassignment") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + // The use count shows whether the wrapper still holds the first + // callable. + auto token = std::make_shared(1); + MoveOnlyFunction function = [token]{ + return *token; + }; + int actual = function(); + if (actual != 1){ + return UnitTestResult(expected_int("first callable", 1, actual)); + } + + function = []{ + return 2; + }; + actual = function(); + if (actual != 2){ + return UnitTestResult(expected_int("call after reassignment", 2, actual)); + } + actual = (int)token.use_count(); + if (actual != 1){ + return UnitTestResult(expected_int("token owners after reassignment", 1, actual)); + } + + function = nullptr; + if (function){ + return UnitTestResult("assigned nullptr: expected empty, got non-empty"); + } + if (function != nullptr){ + return UnitTestResult("assigned nullptr: expected == nullptr, got != nullptr"); + } + return true; + } +}; + + + +} // anonymous namespace + + + +void add_tests_MoveOnlyFunction(UnitTestDatabase& database){ + database.add(); + database.add(); + database.add(); + database.add(); +} + + + +} diff --git a/Common/Cpp/MoveOnlyFunction_Tests.h b/Common/Cpp/MoveOnlyFunction_Tests.h new file mode 100644 index 0000000000..c43f518bdc --- /dev/null +++ b/Common/Cpp/MoveOnlyFunction_Tests.h @@ -0,0 +1,21 @@ +/* Move-Only Function Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_MoveOnlyFunction_Tests_H +#define PokemonAutomation_MoveOnlyFunction_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ + + + +void add_tests_MoveOnlyFunction(UnitTestDatabase& database); + + + +} +#endif diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index 1c19573466..d434036d6e 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -5,6 +5,7 @@ */ #include "Common/Cpp/ListenerSet.h" +#include "Common/Cpp/MoveOnlyFunction_Tests.h" #include "Common/Cpp/ScopeExit.h" #include "Common/Cpp/PrettyPrint.h" #include "Common/Cpp/TestRunners/TestImageDistortionManager.h" @@ -51,6 +52,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ add_tests_QtEventThreadPool(ret, GlobalThreadPools::computation_normal()); add_tests_BlackBorderDetector(ret); add_tests_ListenerSet(ret); + add_tests_MoveOnlyFunction(ret); add_tests_AudioInfo(ret); add_tests_ChannelMixerMatrix(ret); add_tests_FFTStreamer(ret); diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index b0f27c7490..98d84589f1 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -118,6 +118,9 @@ file(GLOB CORE_LIBRARY_SOURCES ../Common/Cpp/Logging/MultiOutputLogger.h ../Common/Cpp/Logging/TaggedLogger.cpp ../Common/Cpp/Logging/TaggedLogger.h + ../Common/Cpp/MoveOnlyFunction.h + ../Common/Cpp/MoveOnlyFunction_Tests.cpp + ../Common/Cpp/MoveOnlyFunction_Tests.h ../Common/Cpp/Options/BatchOption.cpp ../Common/Cpp/Options/BatchOption.h ../Common/Cpp/Options/BooleanCheckBoxOption.cpp From 3209523a93783ccebe5c2ab5231e9bf4e23f2ab4 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sat, 31 Oct 2026 22:00:28 -0700 Subject: [PATCH 02/19] Pokemon navigation: grid types and leg planner over a game's step model FRLG and BDSP each built a grid planner for AutoStory, and the two copies share the same ideas but can't serve each other, because each planner is tied to its own map type. This adds the part both games plan on: the grid types (tile positions and directions) and a leg planner that asks the game's step model what one press does and prefers legs that end at a wall, tested on a small made-up game. The leg walker, ScreenMachine and TileGrid/MapSet live with their users in other PRs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../ComputerPrograms_UnitTestRunner.cpp | 2 + .../Pokemon/Navigation/Pokemon_Grid.cpp | 50 +++ .../Source/Pokemon/Navigation/Pokemon_Grid.h | 57 +++ .../Pokemon/Navigation/Pokemon_LegPlanner.h | 339 ++++++++++++++++++ .../Navigation/Pokemon_LegPlanner_Tests.cpp | 260 ++++++++++++++ .../Navigation/Pokemon_Navigation_Tests.h | 22 ++ SerialPrograms/cmake/SourceFiles.cmake | 5 + 7 files changed, 735 insertions(+) create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.cpp create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.h create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp create mode 100644 SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index d434036d6e..79b8520da0 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -28,6 +28,7 @@ #include "NintendoSwitch/Inference/NintendoSwitch_UpdatePopupDetector.h" #include "NintendoSwitch/Programs/Typewriter/NintendoSwitch_Typewriter_Tests.h" +#include "Pokemon/Navigation/Pokemon_Navigation_Tests.h" #include "VideoGames/PokemonHome/PokemonHome_Tests.h" #include "VideoGames/PokemonSwSh/PokemonSwSh_Tests.h" #include "VideoGames/PokemonLA/PokemonLA_Tests.h" @@ -60,6 +61,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ add_tests_AudioFileLoader(ret); OCR::add_tests(ret); Kernels::add_tests(ret); + Pokemon::add_tests_LegPlanner(ret); NintendoSwitch::add_tests_CheckOnlineDetector(ret); NintendoSwitch::add_tests_FailedToConnectDetector(ret); NintendoSwitch::add_tests_UpdatePopupDetector(ret); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.cpp new file mode 100644 index 0000000000..4aaaf62536 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.cpp @@ -0,0 +1,50 @@ +/* Grid + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +const char* direction_name(Direction direction){ + switch (direction){ + case Direction::NORTH: + return "north"; + case Direction::EAST: + return "east"; + case Direction::SOUTH: + return "south"; + case Direction::WEST: + return "west"; + } + return "?"; +} + + +TilePos TilePos::next(Direction direction) const{ + switch (direction){ + case Direction::NORTH: + return {x, y - 1}; + case Direction::EAST: + return {x + 1, y}; + case Direction::SOUTH: + return {x, y + 1}; + case Direction::WEST: + return {x - 1, y}; + } + return *this; +} + +std::string to_string(const TilePos& pos){ + return std::format("({}, {})", pos.x, pos.y); +} + + + +} +} diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.h new file mode 100644 index 0000000000..409e6fe06e --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Grid.h @@ -0,0 +1,57 @@ +/* Grid + * + * From: https://github.com/PokemonAutomation/ + * + * Tiles and directions on an overworld grid, shared by the games that + * move the player a tile at a time. + * + */ + +#ifndef PokemonAutomation_Pokemon_Grid_H +#define PokemonAutomation_Pokemon_Grid_H + +#include +#include +#include + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Row 0 is the top of a map, so SOUTH increases y. +enum class Direction{ + NORTH, + EAST, + SOUTH, + WEST, +}; +constexpr Direction ALL_DIRECTIONS[] = { + Direction::NORTH, Direction::EAST, Direction::SOUTH, Direction::WEST, +}; +const char* direction_name(Direction direction); + + +struct TilePos{ + int x = 0; + int y = 0; + + TilePos next(Direction direction) const; + bool operator==(const TilePos& other) const = default; +}; +std::string to_string(const TilePos& pos); + + + +} +} + + +template <> +struct std::hash{ + size_t operator()(const PokemonAutomation::Pokemon::TilePos& pos) const{ + return std::hash()(((long long)pos.x << 32) ^ (unsigned)pos.y); + } +}; + + +#endif diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h new file mode 100644 index 0000000000..c9d49735d9 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner.h @@ -0,0 +1,339 @@ +/* Leg Planner + * + * From: https://github.com/PokemonAutomation/ + * + * Plans a walk as straight legs: hold one direction, then the next. The + * game decides what a single press does through its step model; the + * planner searches, joins steps into legs, and prefers legs that end + * against a wall, because the player stops there however long the button + * is held, while a leg that ends in the open depends on timing. + * + * A step model is a class with: + * + * using State = ...; // where the player is and what moves them: + * // comparable with == + * using StateHash = ...; // a hash of State, e.g. std::hash + * std::optional> press(const State& state, Direction direction) const; + * bool held_at(const State& state, Direction direction) const; + * + * press() is what pressing `direction` once does, or nothing if the player + * can't go that way (a wall, or something the plan must not do). held_at() + * is whether holding `direction` keeps the player on this tile: a wall, but + * not a person who may walk off or an edge to a map that isn't loaded. + * + */ + +#ifndef PokemonAutomation_Pokemon_LegPlanner_H +#define PokemonAutomation_Pokemon_LegPlanner_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include "Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// What one press does. +template +struct Step{ + State next; + // 0 for a plain step. Anything else is a step of the game's own kind + // (e.g. starting to Surf), planned as a leg on its own. + int action = 0; + // The step can't continue the leg before it, e.g. leaving the water, + // after which the player walks at another speed. + bool starts_leg = false; + // No later step continues this leg: the game stops the player, e.g. + // at a warp. + bool ends_leg = false; + // The step takes a warp: a door, a ladder, a cave entrance. + bool warped = false; + // Which way the player faces afterwards. Usually the pressed direction; + // some warps turn the player. + Direction facing = Direction::NORTH; + // Cost on top of LegCosts::per_tile: tall grass, a warp's fade, a dialog. + int extra_cost = 0; +}; + + +enum class LegKind{ + WALK, // hold `direction` for `tiles` steps + ACTION, // one step of the game's own kind, `action` + FACE, // turn to `direction` without moving +}; + +template +struct Leg{ + LegKind kind = LegKind::WALK; + int action = 0; + Direction direction = Direction::NORTH; + int tiles = 0; + // WALK: the last step takes a warp. + bool warps = false; + // WALK: holding on keeps the player on the last tile, so the leg can be + // held for longer than it takes and walked again after an interruption. + bool to_wall = false; + Direction facing_after = Direction::NORTH; + State start; + State end; +}; + + +struct LegCosts{ + int per_tile = 1; + // Extra for a step in a new direction. + int per_turn = 2; + // Extra for ending a leg where the player could keep going: it has to + // be timed, and where an interruption leaves the player is a guess. + int per_open_stop = 8; +}; + + +// The cheapest legs from `start` to a state where `at_goal` holds, facing +// `goal_facing` if given, or nothing if there is none. `start_facing` is +// the way the player faces at the start, if known. +template +std::optional>> plan_legs( + const Model& model, + const typename Model::State& start, std::optional start_facing, + const std::function& at_goal, + std::optional goal_facing, + const LegCosts& costs = LegCosts() +); + + + +namespace LegPlannerInternal{ + +// A search node: the player, the way they face (4: not known), and whether +// they are in the middle of a leg that could go on. +template +struct Node{ + State state; + int facing = 4; + bool moving = false; + + bool operator==(const Node& other) const = default; +}; + +template +struct NodeHash{ + size_t operator()(const Node& node) const{ + return StateHash()(node.state) * 10 + node.facing * 2 + (node.moving ? 1 : 0); + } +}; + +template +struct Move{ + Node from; + Direction direction; + Step step; +}; + +template +int stop_cost(const Model& model, const Node& node, const LegCosts& costs){ + if (!node.moving || model.held_at(node.state, (Direction)node.facing)){ + return 0; + } + return costs.per_open_stop; +} + +template +std::vector> join_legs(const Model& model, const State& start, const std::vector>& moves){ + std::vector> legs; + bool can_extend = false; + State before = start; + for (const Move& move : moves){ + const Step& step = move.step; + const bool extends = can_extend && step.action == 0 && !step.starts_leg + && legs.back().kind == LegKind::WALK && legs.back().direction == move.direction; + if (extends){ + legs.back().tiles++; + }else{ + Leg leg; + leg.kind = step.action == 0 ? LegKind::WALK : LegKind::ACTION; + leg.action = step.action; + leg.direction = move.direction; + leg.tiles = 1; + leg.start = before; + legs.push_back(leg); + } + Leg& leg = legs.back(); + leg.warps = step.warped; + leg.to_wall = leg.kind == LegKind::WALK && !step.warped && model.held_at(step.next, move.direction); + leg.facing_after = step.facing; + leg.end = step.next; + can_extend = step.action == 0 && !step.ends_leg; + before = step.next; + } + return legs; +} + +// The search's working set. Nodes are kept in a list and queued by index, +// so State needs no ordering. +template +struct Search{ + using State = typename Model::State; + using Hash = NodeHash; + using Entry = std::pair; + + std::unordered_map, int, Hash> distance; + std::unordered_map, Move, Hash> came_from; + std::vector> nodes; + std::priority_queue, std::greater> queue; +}; + +template +int distance_of(const Search& search, const Node& node){ + auto iter = search.distance.find(node); + return iter == search.distance.end() ? std::numeric_limits::max() : iter->second; +} + +// Records `cost` as the way to `node` if it is cheaper than any so far. +template +void relax(Search& search, const Node& node, int cost, const Move& move){ + if (cost >= distance_of(search, node)){ + return; + } + search.distance[node] = cost; + search.came_from.insert_or_assign(node, move); + search.nodes.push_back(node); + search.queue.emplace(cost, search.nodes.size() - 1); +} + +// Queues every press from `current`, reached at `cost`. +template +void expand( + const Model& model, Search& search, + const Node& current, int cost, const LegCosts& costs +){ + for (Direction direction : ALL_DIRECTIONS){ + std::optional> step = model.press(current.state, direction); + if (!step){ + continue; + } + const bool continues = step->action == 0 && !step->ends_leg; + const Node next{step->next, (int)step->facing, continues}; + int step_cost = costs.per_tile + step->extra_cost; + if (current.facing != (int)direction){ + step_cost += costs.per_turn + stop_cost(model, current, costs); + } + relax(search, next, cost + step_cost, Move{current, direction, *step}); + } +} + +// What it costs to end at `node`: stopping there, then turning to +// `goal_facing`. +template +int arrival_cost( + const Model& model, const Node& node, + std::optional goal_facing, const LegCosts& costs +){ + int cost = stop_cost(model, node, costs); + if (goal_facing && node.facing != (int)*goal_facing){ + cost += costs.per_turn; + } + return cost; +} + +// Runs the search from `first` and returns the goal node cheapest to end +// at, or nothing if no goal can be reached. +template +std::optional> search_goal( + const Model& model, Search& search, const Node& first, + const std::function& at_goal, + std::optional goal_facing, const LegCosts& costs +){ + search.distance[first] = 0; + search.nodes.push_back(first); + search.queue.emplace(0, 0); + + std::optional> best_end; + int best_total = std::numeric_limits::max(); + while (!search.queue.empty()){ + const auto [cost, index] = search.queue.top(); + search.queue.pop(); + const Node current = search.nodes[index]; + if (cost != distance_of(search, current)){ + continue; + } + if (cost >= best_total){ + break; + } + if (!at_goal(current.state)){ + expand(model, search, current, cost, costs); + continue; + } + const int total = cost + arrival_cost(model, current, goal_facing, costs); + if (total < best_total){ + best_total = total; + best_end = current; + } + } + return best_end; +} + +// The moves from `first` to `end`, in order. +template +std::vector> trace_moves(const Search& search, const Node& first, const Node& end){ + std::vector> moves; + for (Node node = end; !(node == first); node = search.came_from.at(node).from){ + moves.push_back(search.came_from.at(node)); + } + std::reverse(moves.begin(), moves.end()); + return moves; +} + +template +Leg face_leg(const State& state, Direction direction){ + Leg face; + face.kind = LegKind::FACE; + face.direction = direction; + face.facing_after = direction; + face.start = state; + face.end = state; + return face; +} + +} + + + +template +std::optional>> plan_legs( + const Model& model, + const typename Model::State& start, std::optional start_facing, + const std::function& at_goal, + std::optional goal_facing, + const LegCosts& costs +){ + using State = typename Model::State; + using Node = LegPlannerInternal::Node; + + const Node first{start, start_facing ? (int)*start_facing : 4, false}; + LegPlannerInternal::Search search; + const std::optional end = LegPlannerInternal::search_goal(model, search, first, at_goal, goal_facing, costs); + if (!end){ + return std::nullopt; + } + std::vector> legs = LegPlannerInternal::join_legs( + model, start, LegPlannerInternal::trace_moves(search, first, *end) + ); + if (goal_facing && end->facing != (int)*goal_facing){ + legs.push_back(LegPlannerInternal::face_leg(end->state, *goal_facing)); + } + return legs; +} + + + +} +} +#endif diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp new file mode 100644 index 0000000000..de69a79762 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp @@ -0,0 +1,260 @@ +/* Leg Planner Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The planner on a small made-up game, with the kinds of tiles the real + * games have: walls, people, tall grass, a one-way tile and a warp. + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon_LegPlanner.h" +#include "Pokemon_Navigation_Tests.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + +// '#' wall, '.' floor, '"' tall grass, 'p' a person (blocks, but may walk +// off, so not a wall to stop against), 'v' entered only going south, 'W' +// a warp to the tile marked 'X'. +class ToyGame{ +public: + using State = TilePos; + using StateHash = std::hash; + + explicit ToyGame(std::vector rows) + : m_rows(std::move(rows)) + { + for (int y = 0; y < (int)m_rows.size(); y++){ + for (int x = 0; x < (int)m_rows[y].size(); x++){ + if (m_rows[y][x] == 'X'){ + m_warp_to = {x, y}; + } + } + } + } + + std::optional> press(const State& state, Direction direction) const{ + const TilePos next = state.next(direction); + const char tile = at(next); + if (tile == '#' || tile == 'p' || (tile == 'v' && direction != Direction::SOUTH)){ + return std::nullopt; + } + Step step; + step.next = next; + step.facing = direction; + if (tile == '"'){ + step.extra_cost = 5; + } + if (tile == 'W'){ + step.next = m_warp_to; + step.warped = true; + step.ends_leg = true; + step.extra_cost = 10; + } + return step; + } + bool held_at(const State& state, Direction direction) const{ + const char tile = at(state.next(direction)); + return tile == '#' || (tile == 'v' && direction != Direction::SOUTH); + } + +private: + char at(const TilePos& pos) const{ + if (pos.y < 0 || pos.y >= (int)m_rows.size() || pos.x < 0 || pos.x >= (int)m_rows[pos.y].size()){ + return '#'; + } + return m_rows[pos.y][pos.x]; + } + + std::vector m_rows; + TilePos m_warp_to; +}; + + +std::string describe(const std::vector>& legs){ + std::string text; + for (const Leg& leg : legs){ + text += "\n "; + text += leg.kind == LegKind::FACE ? "face " : "walk "; + text += direction_name(leg.direction); + if (leg.kind == LegKind::WALK){ + text += std::format(" {}", leg.tiles); + } + if (leg.to_wall){ + text += " (to wall)"; + } + if (leg.warps){ + text += " (warp)"; + } + text += " -> " + to_string(leg.end); + } + return text; +} + +std::optional>> plan( + const ToyGame& game, TilePos from, TilePos to, + std::optional start_facing = std::nullopt, + std::optional goal_facing = std::nullopt +){ + return plan_legs(game, from, start_facing, std::function([to](const TilePos& pos){ return pos == to; }), goal_facing); +} + + +UnitTestResult test_prefers_wall_legs(){ + // Two ways of equal length: east then south ends both legs in the open; + // south then east ends the last one against the pillar. + const ToyGame game({ + "......", + "...#..", + "......", + }); + const auto legs = plan(game, {0, 0}, {2, 1}); + if (!legs || legs->size() != 2){ + return "Expected 2 legs:" + (legs ? describe(*legs) : std::string(" none")); + } + if ((*legs)[1].direction != Direction::EAST || !(*legs)[1].to_wall){ + return "Expected to end going east into the pillar:" + describe(*legs); + } + return true; +} + +UnitTestResult test_person_is_not_a_wall(){ + // The only way ends next to a person, which isn't a wall to stop against. + const ToyGame game({ + "#####", + "#..p#", + "#####", + }); + const auto legs = plan(game, {1, 1}, {2, 1}); + if (!legs || legs->size() != 1 || (*legs)[0].to_wall){ + return "Expected one open leg:" + (legs ? describe(*legs) : std::string(" none")); + } + return true; +} + +UnitTestResult test_avoids_grass(){ + // Straight through the grass is 4 tiles; around it is 8, cheaper with + // 5 extra per grass tile. + const ToyGame game({ + ".....", + ".\"\"\".", + ".....", + }); + const auto legs = plan(game, {0, 1}, {4, 1}); + if (!legs){ + return "No route."; + } + for (const Leg& leg : *legs){ + if (leg.end.y == 1 && leg.end.x > 0 && leg.end.x < 4){ + return "Walked into the grass:" + describe(*legs); + } + } + return true; +} + +UnitTestResult test_one_way_tile(){ + // The 'v' can only be entered going south, so going back up must go around. + const ToyGame game({ + "#.#.#", + "#v#.#", + "#...#", + "#####", + }); + if (!plan(game, {1, 0}, {1, 2})){ + return "No route down through the one-way tile."; + } + const auto up = plan(game, {1, 2}, {1, 0}); + if (up){ + return "Went up through the one-way tile:" + describe(*up); + } + return true; +} + +UnitTestResult test_warp_ends_leg(){ + // The warp on the left takes the player to X on the right. + const ToyGame game({ + "#######", + "#W.#X.#", + "#..#..#", + "#######", + }); + const auto legs = plan(game, {2, 2}, {5, 2}); + if (!legs){ + return "No route."; + } + bool warped = false; + for (const Leg& leg : *legs){ + if (leg.warps){ + warped = true; + if (!(leg.end == TilePos{4, 1})){ + return std::format("The warp leg ends at {}:{}", to_string(leg.end), describe(*legs)); + } + } + } + if (!warped){ + return "Expected a warp leg:" + describe(*legs); + } + return true; +} + +UnitTestResult test_goal_facing(){ + const ToyGame game({ + "....", + }); + const auto legs = plan(game, {0, 0}, {2, 0}, std::nullopt, Direction::NORTH); + if (!legs || legs->back().kind != LegKind::FACE || legs->back().direction != Direction::NORTH){ + return "Expected to end facing north:" + (legs ? describe(*legs) : std::string(" none")); + } + return true; +} + +UnitTestResult test_unreachable(){ + const ToyGame game({ + ".#.", + }); + if (plan(game, {0, 0}, {2, 0})){ + return "Planned through a wall."; + } + return true; +} + + +class Test_LegPlanner : public UnitTest{ +public: + Test_LegPlanner(std::string name, std::function body) + : UnitTest("Pokemon::LegPlanner - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_body(); + } + +private: + std::function m_body; +}; + +} + + +void add_tests_LegPlanner(UnitTestDatabase& database){ + database.add("prefers legs that end at a wall", test_prefers_wall_legs); + database.add("a person is not a wall", test_person_is_not_a_wall); + database.add("avoids tall grass", test_avoids_grass); + database.add("one-way tile", test_one_way_tile); + database.add("a warp ends the leg", test_warp_ends_leg); + database.add("turns to face the goal", test_goal_facing); + database.add("nothing through a wall", test_unreachable); +} + + + +} +} diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h new file mode 100644 index 0000000000..5919dd56b2 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h @@ -0,0 +1,22 @@ +/* Navigation Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#ifndef PokemonAutomation_Pokemon_Navigation_Tests_H +#define PokemonAutomation_Pokemon_Navigation_Tests_H + +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +void add_tests_LegPlanner(UnitTestDatabase& database); + + + +} +} +#endif diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index 98d84589f1..5766bd2ebd 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1438,6 +1438,11 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Inference/Pokemon_TrainPokemonOCR.h Source/Pokemon/Inference/Pokemon_TypeReader.cpp Source/Pokemon/Inference/Pokemon_TypeReader.h + Source/Pokemon/Navigation/Pokemon_Grid.cpp + Source/Pokemon/Navigation/Pokemon_Grid.h + Source/Pokemon/Navigation/Pokemon_LegPlanner.h + Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp + Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h Source/Pokemon/Options/Pokemon_BoxSortingTable.cpp Source/Pokemon/Options/Pokemon_BoxSortingTable.h Source/Pokemon/Options/Pokemon_EncounterBotOptions.h From c246d3d56988c3de68dd7713e39903b120b82b55 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sat, 31 Oct 2026 22:00:49 -0700 Subject: [PATCH 03/19] ScreenMachine: a closed loop over known screens BDSP's new-game intro runs as a closed loop over the screens it knows: read the screen, take the one step that screen calls for, read again, and treat an unexpected screen or a state that keeps coming back as the sign of a dropped or wrong input. This adds that loop as its own module: ScreenMachine runs it and StepTrace logs every recognized screen change with a numbered screenshot and every input, so a failure reports the screens that led up to it. Nothing here depends on navigation; there are no tests yet, CI checks that it builds on its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AKYzhrMnPK61XHWEch4a6E --- .../ScreenMachine/ScreenMachine.cpp | 97 ++++++++++++++++ .../CommonTools/ScreenMachine/ScreenMachine.h | 105 ++++++++++++++++++ .../CommonTools/ScreenMachine/StepTrace.cpp | 74 ++++++++++++ .../CommonTools/ScreenMachine/StepTrace.h | 53 +++++++++ SerialPrograms/cmake/SourceFiles.cmake | 4 + 5 files changed, 333 insertions(+) create mode 100644 SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.cpp create mode 100644 SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.h create mode 100644 SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.cpp create mode 100644 SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.h diff --git a/SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.cpp b/SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.cpp new file mode 100644 index 0000000000..8d528c4eaa --- /dev/null +++ b/SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.cpp @@ -0,0 +1,97 @@ +/* Screen Machine + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/ImageTools/ImageDiff.h" +#include "ScreenMachine.h" + +namespace PokemonAutomation{ + +using namespace std::chrono_literals; + + +namespace{ + +const ScreenState* classify(const std::vector& states, const ImageViewRGB32& screen){ + for (const ScreenState& state : states){ + if (state.matches(screen)){ + return &state; + } + } + return nullptr; +} + +} + + +const ScreenState& read_screen_state( + VideoStream& stream, CancellableScope& scope, StepTrace& trace, + const std::vector& states, VideoSnapshot& frame +){ + const ScreenState* last = nullptr; + WallClock video_lost = WallClock::max(); + for (size_t c = 0; c < 20; c++){ + VideoSnapshot screen = stream.video().snapshot(); + if (!screen){ + // The app resets a frozen capture by itself, which takes a second or two. + const WallClock now = current_time(); + video_lost = std::min(video_lost, now); + if (now - video_lost > std::chrono::seconds(10)){ + trace.fail("No video from the capture card for 10s."); + } + c--; + scope.wait_for(500ms); + continue; + } + video_lost = WallClock::max(); + const ScreenState* now = classify(states, screen); + frame = std::move(screen); + if (now != nullptr && now == last){ + return *now; + } + last = now; + scope.wait_for(150ms); + } + if (last == nullptr){ + trace.fail("The screen matches none of the known states."); + } + return *last; // Still changing after 3s: go with the latest read. +} + + +void ScreenMachineChecks::check(const ScreenState& state, VideoSnapshot frame){ + const bool same_size = m_last_frame && frame && + m_last_frame->width() == frame->width() && m_last_frame->height() == frame->height(); + if (same_size && ImageMatch::pixel_RMSD(m_last_frame, frame) < 2.0){ + if (++m_unchanged >= MAX_UNCHANGED){ + m_trace.fail("The screen stopped changing: " + std::to_string(MAX_UNCHANGED) + + " steps on \"" + state.name + "\" did nothing."); + } + }else{ + m_unchanged = 0; + } + m_last_frame = std::move(frame); + + if (m_previous != nullptr && m_previous != &state && !m_previous->next.empty() && + std::find(m_previous->next.begin(), m_previous->next.end(), state.name) == m_previous->next.end() + ){ + m_trace.fail("Unexpected screen after \"" + m_previous->name + "\": \"" + state.name + "\"."); + } + m_in_a_row = m_previous == &state ? m_in_a_row + 1 : 1; + if (m_in_a_row > state.max_in_a_row){ + m_trace.fail("Screen \"" + state.name + "\" came up " + std::to_string(m_in_a_row) + + " times in a row: the step for it is not getting anywhere."); + } + if (++m_visits[state.name] > state.max_visits){ + m_trace.fail("Screen \"" + state.name + "\" came up more than " + + std::to_string(state.max_visits) + " times: the step for it is not working."); + } + m_previous = &state; +} + + + +} diff --git a/SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.h b/SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.h new file mode 100644 index 0000000000..f878ff709c --- /dev/null +++ b/SerialPrograms/Source/CommonTools/ScreenMachine/ScreenMachine.h @@ -0,0 +1,105 @@ +/* Screen Machine + * + * From: https://github.com/PokemonAutomation/ + * + * A closed loop over known screens: read the screen, take the one step that screen + * calls for, read again. Every step follows a read, a screen counts only once two + * frames agree, and each state lists the screens it may lead to, so a dropped or + * wrong input shows up as an unexpected screen or a state that keeps coming back. + * + */ + +#ifndef PokemonAutomation_CommonTools_ScreenMachine_H +#define PokemonAutomation_CommonTools_ScreenMachine_H + +#include +#include +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "Common/Cpp/CancellableScope.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/Tools/VideoStream.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "StepTrace.h" + +namespace PokemonAutomation{ + + +struct ScreenState{ + std::string name; + // Whether this is the screen. States are tried in order; the first match wins, so + // the last state should match anything. + std::function matches; + // The one step to take on this screen. + std::function act; + // The screens the step may lead to besides staying here. Empty: any. + std::vector next; + // Fail once the screen has been seen this many times. + size_t max_visits; + // End the run after this state's step. + bool terminal = false; + // Fail once the screen has been seen this many times in a row. + size_t max_in_a_row = SIZE_MAX; +}; + + +// The state on screen, once two frames 150ms apart agree. Waits out a video +// gap of up to 10s. Fails through `trace` if no state matches. +const ScreenState& read_screen_state( + VideoStream& stream, CancellableScope& scope, StepTrace& trace, + const std::vector& states, VideoSnapshot& frame +); + +// The checks between reads: the screen must be one the previous state can lead +// to, no state may come up too often, and steps must keep changing the screen. +class ScreenMachineChecks{ +public: + ScreenMachineChecks(StepTrace& trace) + : m_trace(trace) + {} + void check(const ScreenState& state, VideoSnapshot frame); + +private: + // A step that changes nothing on screen is a dead end (e.g. a menu the states do + // not know): fail after a few instead of running out the visit limit. + static constexpr size_t MAX_UNCHANGED = 8; + + StepTrace& m_trace; + std::map m_visits; + const ScreenState* m_previous = nullptr; + VideoSnapshot m_last_frame; + size_t m_unchanged = 0; + size_t m_in_a_row = 0; +}; + + +// Runs the states until a terminal one has acted. Returns its name. Fails through +// `trace` on an unexpected screen, a state seen too often, a stall or the timeout. +template +std::string run_screen_machine( + VideoStream& stream, ControllerContext& context, StepTrace& trace, + const std::vector& states, Milliseconds timeout +){ + ScreenMachineChecks checks(trace); + const WallClock deadline = current_time() + timeout; + while (current_time() < deadline){ + context.wait_for_all_requests(); + VideoSnapshot frame; + const ScreenState& state = read_screen_state(stream, context, trace, states, frame); + trace.screen(state.name); + checks.check(state, std::move(frame)); + state.act(); + if (state.terminal){ + context.wait_for_all_requests(); + return state.name; + } + } + trace.fail("Did not finish within " + std::to_string(timeout.count() / 1000) + "s."); +} + + + +} +#endif diff --git a/SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.cpp b/SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.cpp new file mode 100644 index 0000000000..246bac04a0 --- /dev/null +++ b/SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.cpp @@ -0,0 +1,74 @@ +/* Step Trace + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/PrettyPrint.h" +#include "Common/Cpp/Filesystem/Filesystem.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "StepTrace.h" + +namespace PokemonAutomation{ + + +StepTrace::StepTrace(VideoStream& stream, std::string label) + : m_stream(stream) + , m_label(std::move(label)) + , m_folder(SCREENSHOTS_PATH() + "StepTrace/" + now_to_filestring() + "-" + m_label + "/") + , m_start(current_time()) +{ + Filesystem::create_directories(Filesystem::Path(m_folder)); + m_stream.log("[" + m_label + "] Trace screenshots: " + m_folder); +} + +void StepTrace::screen(const std::string& name){ + if (name == m_last){ + return; + } + m_last = name; + m_recent.push_back(name); + if (m_recent.size() > 12){ + m_recent.pop_front(); + } + m_count++; + const double seconds = std::chrono::duration(current_time() - m_start).count(); + char prefix[16]; + std::snprintf(prefix, sizeof(prefix), "%03zu-", m_count); + std::string path = m_folder + prefix + name + ".png"; + VideoSnapshot screen = m_stream.video().snapshot(); + if (screen){ + screen.frame->save(path); + }else{ + path = "(no video frame)"; + } + m_stream.log("[" + m_label + "] screen " + std::to_string(m_count) + ": " + name + + " at " + tostr_fixed(seconds, 1) + "s, " + path); +} + +void StepTrace::action(const std::string& what){ + m_stream.log("[" + m_label + "] " + what); +} + +std::string StepTrace::recent_screens() const{ + std::string text; + for (const std::string& name : m_recent){ + text += (text.empty() ? "" : " -> ") + name; + } + return text.empty() ? "(none)" : text; +} + +void StepTrace::fail(const std::string& message) const{ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + m_label + ": " + message + " Recent screens: " + recent_screens() + ". Trace: " + m_folder, + m_stream + ); +} + + + +} diff --git a/SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.h b/SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.h new file mode 100644 index 0000000000..e0c4db09d0 --- /dev/null +++ b/SerialPrograms/Source/CommonTools/ScreenMachine/StepTrace.h @@ -0,0 +1,53 @@ +/* Step Trace + * + * From: https://github.com/PokemonAutomation/ + * + * A step log for closed-loop routines: every change of recognized screen is + * logged and saved as a numbered screenshot, every input is logged, and a + * failure reports the screens that led up to it. + * + */ + +#ifndef PokemonAutomation_CommonTools_StepTrace_H +#define PokemonAutomation_CommonTools_StepTrace_H + +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/Tools/VideoStream.h" + +namespace PokemonAutomation{ + + +class StepTrace{ +public: + // Screenshots go to /StepTrace/