// SPDX-License-Identifier: GPL-3.0-or-later #include "menu_pointer.h" #include "input.h" #include "pad.h" #include "../bbport_settings.h" #include "../engine/engine_hooks.h" #include "../engine/engine_state.h" #include "../engine/option_menu.h" #include #include #include #include #include #include #include namespace MenuPointer { namespace { using BbEngine::Call; constexpr std::size_t kListUpdate = 0x1acff30; constexpr std::uint8_t kListUpdateEntry[] = {0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x53, 0x48, 0x81, 0xec, 0x98, 0x00, 0x00, 0x00}; constexpr std::size_t kSetCursor = 0x1acf6b0, kScrollBy = 0x1ad0dd0, kHideCursors = 0x1acf0e0; constexpr std::size_t kChildClip = 0x1adb220, kCursorClipName = 0x4934044; constexpr std::size_t kStageMatrix = 0x2040; constexpr std::size_t kCodeEnd = 0x50d96dc; constexpr std::size_t kListener = 0x30, kItems = 0x70, kCursor = 0x74, kCursorOverride = 0x78; constexpr std::size_t kCells = 0x7c, kLines = 0x80, kAlong = 0x84, kScrollLines = 0x24c; constexpr std::size_t kRootType = 0xc8, kRootData = 0xd0, kProvider = 0x258, kWraps = 0x2b0; constexpr std::size_t kValue = 0x30, kValueInterface = 0x40, kValueType = 0x48, kValueData = 0x50; constexpr std::size_t kDisplayObject = 0x88, kParent = 0x38, kFlagsHigh = 0x6b, kBoundsSlot = 0x1f0; constexpr std::uint8_t kVisible = 0x40; constexpr std::size_t kMenuFlow = 0x5580e38, kFlowBlocked = 0xa78, kFlowMenu = 0xa88, kFlowStarted = 0xb11; constexpr std::size_t kInputBlocker = 0x5562878, kBlockerBusy = 0x2e4; constexpr std::size_t kRegion = 0x5128948; constexpr int kRegionCircleDecides = 5; constexpr std::size_t kMenuSound = 0x557fc60, kMenuSoundLabel = 0x557fc68, kCursorMoveLabel = 0x49344e4; std::uint8_t* g_image = nullptr; bool g_log = false; std::atomic g_focus_ms{-100000}; std::atomic g_choice_ms{-100000}; std::atomic g_holding_row{false}; std::atomic g_click{false}; std::atomic g_click_ms{0}; std::atomic g_press{0}; constexpr std::int64_t kClickMs = 250, kInMenuMs = 400, kNavigatingMs = 70; std::int64_t NowMs() { return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) .count(); } std::uint64_t Image(std::size_t offset) { return reinterpret_cast(g_image) + offset; } template bool Read(std::uint64_t at, T& out) { return at >= 0x10000 && BbEngine::host_read_safe(reinterpret_cast(at), &out, sizeof out); } template T Field(const std::uint8_t* object, std::size_t offset) { T v; std::memcpy(&v, object + offset, sizeof v); return v; } bool GameFunction(std::uint64_t at) { return g_image && at >= Image(0) && at < Image(kCodeEnd); } std::uint64_t VirtualFunction(std::uint64_t object, std::size_t slot) { std::uint64_t vtable = 0, fn = 0; return object && Read(object, vtable) && Read(vtable + slot, fn) && GameFunction(fn) ? fn : 0; } class Wrapped { public: Wrapped() = default; Wrapped(const Wrapped&) = delete; Wrapped& operator=(const Wrapped&) = delete; ~Wrapped() { if (!(Field(bytes_, kValueType) & 0x40)) return; const auto interface = Field(bytes_, kValueInterface); if (const std::uint64_t release = VirtualFunction(interface, 0x18)) Call(release, interface, bytes_ + kValue, Field(bytes_, kValueData)); } std::uint8_t* Bytes() { return bytes_; } std::uint64_t Object() const { std::uint64_t object = 0; const auto data = Field(bytes_, kValueData); return (Field(bytes_, kValueType) & 0x8f) == 10 && Read(data + kDisplayObject, object) ? object : 0; } private: alignas(16) std::uint8_t bytes_[0x80]{}; }; bool ItemAt(std::uint8_t* list, std::uint64_t cell, Wrapped& out) { const auto provider = Field(list, kProvider); const std::uint64_t make = VirtualFunction(provider, 0x18); if (!make) return false; Call(make, out.Bytes(), provider, &cell); return out.Object() != 0; } void Child(Wrapped& parent, const char* path, Wrapped& out) { Call(Image(kChildClip), out.Bytes(), parent.Bytes(), path); } void CursorClip(Wrapped& item, Wrapped& out) { Call(Image(kChildClip), out.Bytes(), item.Bytes(), Image(kCursorClipName)); } bool Shown(std::uint64_t object) { for (int depth = 0; object && depth < 32; ++depth) { std::uint8_t flags = 0; if (!Read(object + kFlagsHigh, flags) || !(flags & kVisible) || !Read(object + kParent, object)) return false; } return true; } struct Rect { float left = 0, top = 0, right = 0, bottom = 0; float CentreX() const { return (left + right) / 2; } float CentreY() const { return (top + bottom) / 2; } }; bool Contains(std::uint64_t object, float x, float y, float grow, Rect* on_stage) { const std::uint64_t bounds_fn = VirtualFunction(object, kBoundsSlot); if (!bounds_fn) return false; alignas(16) float identity[8] = {1, 0, 0, 0, 0, 1, 0, 0}; alignas(16) float b[4] = {}; Call(bounds_fn, b, object, identity); if (!(b[2] > b[0] && b[3] > b[1])) return false; alignas(16) float m[8] = {1, 0, 0, 0, 0, 1, 0, 0}; Call(Image(kStageMatrix), object, m); if (on_stage) { Rect r{1e9f, 1e9f, -1e9f, -1e9f}; for (const float bx : {b[0], b[2]}) { for (const float by : {b[1], b[3]}) { const float sx = (m[0] * bx + m[1] * by + m[3]) / 20, sy = (m[4] * bx + m[5] * by + m[7]) / 20; r = {std::min(r.left, sx), std::min(r.top, sy), std::max(r.right, sx), std::max(r.bottom, sy)}; } } *on_stage = r; } const float det = m[0] * m[5] - m[1] * m[4]; if (std::fabs(det) < 1e-12f) return false; const float px = x * 20 - m[3], py = y * 20 - m[7]; const float lx = (m[5] * px - m[1] * py) / det, ly = (m[0] * py - m[4] * px) / det; const float g = grow * 20 / std::sqrt(std::fabs(det)); return lx >= b[0] - g && lx <= b[2] + g && ly >= b[1] - g && ly <= b[3] + g; } int CursorOf(const std::uint8_t* list) { const auto over = Field(list, kCursorOverride); return over >= 0 ? over : Field(list, kCursor); } struct Pointed { int index = -1; std::uint64_t cell = 0; }; Pointed ItemUnder(std::uint8_t* list, float x, float y, bool log) { const int items = Field(list, kItems), cells = Field(list, kCells); const int lines = Field(list, kLines); if (items <= 0 || cells <= 0 || lines <= 0 || cells > 64 || lines > 64) return {}; const int along = Field(list, kAlong), scrolled = Field(list, kScrollLines); const bool wraps = Field(list, kWraps) != 0; const int line_length = lines == 1 ? std::max({cells, items, 1}) : cells; Pointed best; float best_distance = 0; for (int line = 0; line < lines; ++line) { for (int cell = 0; cell < cells; ++cell) { int index = (scrolled + line) * line_length + along + cell; if (wraps) index %= items; if (index < 0 || index >= items) continue; const std::uint64_t packed = std::uint32_t(cell) | std::uint64_t(std::uint32_t(line)) << 32; Wrapped item; if (!ItemAt(list, packed, item) || !Shown(item.Object())) continue; Wrapped highlight; CursorClip(item, highlight); const std::uint64_t target = highlight.Object() ? highlight.Object() : item.Object(); Rect where; bool on = Contains(target, x, y, 0, &where); for (const char* widget : {"Widgets/ComboBox", "Widgets/Slider", "Slider"}) { if (on) break; Wrapped part; Child(item, widget, part); Rect widget_where; if (part.Object() && Shown(part.Object()) && Contains(part.Object(), x, y, 0, &widget_where)) { on = true; where = widget_where; } } if (log) host_log("pointer: item %d (cell %d, line %d) %s at %.0f,%.0f-%.0f,%.0f%s", index, cell, line, target == item.Object() ? "itself" : "its Cursor", where.left, where.top, where.right, where.bottom, on ? " <- pointer" : ""); if (!on) continue; const float dx = where.CentreX() - x, dy = where.CentreY() - y, distance = dx * dx + dy * dy; if (best.index < 0 || distance < best_distance) { best = {index, packed}; best_distance = distance; } } } return best; } std::uint32_t ClickMeaning(std::uint8_t* list, const Pointed& p, float x, float y) { const std::uint32_t decide = menu_confirm_button(); if (BbEngine::Options::ClickDecides(list, p.index)) return decide; Wrapped item; if (!ItemAt(list, p.cell, item)) return decide; const auto on = [&](const char* path, float grow, Rect* where) { Wrapped part; Child(item, path, part); const bool shown = part.Object() && Shown(part.Object()); const bool hit = shown && Contains(part.Object(), x, y, grow, where); if (g_log && part.Object()) host_log("pointer: %s %s at %.0f,%.0f-%.0f,%.0f%s", path, shown ? "shown" : "hidden", where->left, where->top, where->right, where->bottom, hit ? " <- click" : ""); return hit; }; Rect where; if (on("Widgets/Slider/LeftArrow", 14, &where)) return PadBits::kLeft; if (on("Widgets/Slider/RightArrow", 14, &where)) return PadBits::kRight; if (on("Widgets/ComboBox", 0, &where)) return x < where.CentreX() ? PadBits::kLeft : PadBits::kRight; if (on("Slider", 0, &where)) return x < where.CentreX() ? PadBits::kLeft : PadBits::kRight; return decide; } void ShowCursorOf(std::uint8_t* list, std::uint64_t cell) { Wrapped item; if (!ItemAt(list, cell, item)) return; Wrapped highlight; CursorClip(item, highlight); if (!highlight.Object()) return; if (const std::uint64_t set_visible = VirtualFunction(reinterpret_cast(highlight.Bytes()), 8)) Call(set_visible, highlight.Bytes(), 1); } void CursorMoved(std::uint8_t* list, int index) { const auto listener = Field(list, kListener); if (const std::uint64_t told = VirtualFunction(listener, 0x10)) { std::int32_t at = index; Call(told, listener, &at); } auto* sound = reinterpret_cast(Image(kMenuSound)); if (sound[1] <= 0) { sound[0] = 0; sound[1] = 0; const std::uint64_t label = Image(kCursorMoveLabel); std::memcpy(reinterpret_cast(Image(kMenuSoundLabel)), &label, sizeof label); } } std::uint64_t Flow() { std::uint64_t flow = 0; return g_image && Read(Image(kMenuFlow), flow) ? flow : 0; } bool MenuOpen() { std::uint64_t menu = 0; const std::uint64_t flow = Flow(); return flow && Read(flow + kFlowMenu, menu) && menu; } bool InputBlocked() { std::uint64_t blocked = 0, blocker = 0; std::uint32_t busy = 0; const std::uint64_t flow = Flow(); if (flow && Read(flow + kFlowBlocked, blocked) && blocked) return true; return g_image && Read(Image(kInputBlocker), blocker) && blocker && Read(blocker + kBlockerBusy, busy) && busy; } bool RegionDecidesWithCircle() { std::int32_t region = -1; return g_image && Read(Image(kRegion), region) && region == kRegionCircleDecides; } bool InMenu() { return FrontEnd() || MenuOpen() || NowMs() - g_focus_ms.load(std::memory_order_relaxed) < kInMenuMs; } bool DirectionHeld() { const PadState pad = BbInput::LastPad(); if (pad.buttons & PadBits::kDpad) return true; if (BbSettings::Get().menu_move && !FrontEnd()) return false; const auto pushed = [](std::uint8_t v) { return v < 64 || v > 192; }; return pushed(pad.lx) || pushed(pad.ly); } struct Seen { const std::uint8_t* list = nullptr; float x = 0, y = 0; std::int64_t ms = 0; bool described = false; }; Seen g_seen[32]; unsigned g_seen_next = 0; constexpr std::int64_t kSeenMs = 250; Seen& SeenBy(const std::uint8_t* list, bool& fresh) { const std::int64_t now = NowMs(); for (Seen& s : g_seen) { if (s.list != list) continue; fresh = now - s.ms > kSeenMs; if (fresh) s.described = false; s.ms = now; return s; } Seen& s = g_seen[g_seen_next++ % std::size(g_seen)]; s = {list, 0, 0, now, false}; fresh = true; return s; } bool ClickWaiting() { if (!g_click.load(std::memory_order_relaxed)) return false; if (NowMs() - g_click_ms.load(std::memory_order_relaxed) <= kClickMs) return true; g_click.store(false, std::memory_order_relaxed); return false; } void SpinnerClick(std::uint8_t* list, float x, float y) { std::uint64_t root = 0; const auto data = Field(list, kRootData); if ((Field(list, kRootType) & 0x8f) != 10 || !Read(data + kDisplayObject, root) || !root) return; Rect where; const bool on = Shown(root) && Contains(root, x, y, 0, &where); if (g_log) host_log("pointer: spinner %p at %.0f,%.0f-%.0f,%.0f%s", static_cast(list), where.left, where.top, where.right, where.bottom, on ? " <- click" : ""); if (on && g_click.exchange(false)) g_press.store(x < where.CentreX() ? PadBits::kLeft : PadBits::kRight, std::memory_order_relaxed); } bool OnScreen(const std::uint8_t* list) { std::uint64_t root = 0; const auto data = Field(list, kRootData); if ((Field(list, kRootType) & 0x8f) != 10 || !Read(data + kDisplayObject, root) || !root) return true; return Shown(root); } std::atomic g_choice_list{nullptr}; void PointerStep(std::uint8_t* list, const std::uint8_t* focus) { if (!focus || !*focus || InputBlocked()) return; const std::int64_t focused_at = NowMs(); g_focus_ms.store(focused_at, std::memory_order_relaxed); if (Field(list, kItems) > 1 && OnScreen(list)) { g_choice_ms.store(focused_at, std::memory_order_relaxed); g_choice_list.store(list, std::memory_order_relaxed); } if (g_holding_row.load(std::memory_order_relaxed)) return; float x, y; if (!BbInput::MouseIsMenuPointer() || !BbInput::PointerOnStage(x, y)) return; bool fresh; Seen& seen = SeenBy(list, fresh); const bool moved = !fresh && (x != seen.x || y != seen.y); seen.x = x; seen.y = y; if (BbInput::TextEntryOpen()) return; if (g_log && !seen.described) { seen.described = true; host_log("pointer: list %p: %d items, %d cells x %d lines, cursor %d, scrolled %d/%d", static_cast(list), Field(list, kItems), Field(list, kCells), Field(list, kLines), CursorOf(list), Field(list, kScrollLines), Field(list, kAlong)); ItemUnder(list, x, y, true); } const bool click = ClickWaiting(); if (Field(list, kLines) == 1 && Field(list, kCells) == 1 && Field(list, kItems) > 1) { if (click) SpinnerClick(list, x, y); return; } if (!moved && !click) return; if (DirectionHeld()) { static int told = 0; if (g_log && told++ < 60) host_log("pointer: list %p: not hovered, a direction is held", static_cast(list)); return; } const Pointed p = ItemUnder(list, x, y, false); if (p.index < 0) return; if (click) g_click.store(false, std::memory_order_relaxed); const int was = CursorOf(list); int now = was; if (p.index != was) { const int along = Field(list, kAlong), lines = Field(list, kScrollLines); const auto answer = std::int32_t(Call(Image(kSetCursor), list, p.index)); const int back_along = along - Field(list, kAlong); const int back_lines = lines - Field(list, kScrollLines); if (answer == 1 && (back_along || back_lines)) { Call(Image(kScrollBy), list, back_along, back_lines); Call(Image(kHideCursors), list); ShowCursorOf(list, p.cell); } now = CursorOf(list); if (g_log) host_log("pointer: list %p hover %d -> %d (SetCursor %d)", static_cast(list), was, now, answer); } if (click && now == p.index) { const std::uint32_t press = ClickMeaning(list, p, x, y); g_press.store(press, std::memory_order_relaxed); if (g_log) host_log("pointer: list %p click on item %d -> pad %04x", static_cast(list), p.index, press); } if (now != was) CursorMoved(list, now); } std::int64_t __attribute__((sysv_abi)) OnListUpdate(std::uint64_t, const std::uint64_t* saved) { auto* list = reinterpret_cast(saved[5]); const auto* focus = reinterpret_cast(saved[4]); PointerStep(list, focus); BbEngine::Options::ListUpdate(list, focus && *focus); return 0; } } bool Install(std::uint8_t* image, std::size_t size) { g_image = image; const char* log = std::getenv("BB_POINTER_LOG"); g_log = log && log[0] == '1'; BbEngine::Hook hook; if (!BbEngine::Prepare(hook, image, size, kListUpdate, kListUpdateEntry, OnListUpdate)) { host_log("pointer: off, the menus' list update is not 1.09's"); return false; } BbEngine::Commit(std::span(&hook, 1)); host_log("pointer: the menus' lists follow the mouse"); return true; } bool FrontEnd() { std::uint8_t started = 1; const std::uint64_t flow = Flow(); return flow && Read(flow + kFlowStarted, started) && !started; } bool Navigating() { const bool front = FrontEnd(), open = !front && MenuOpen(); const bool choice = !front && !open && NowMs() - g_choice_ms.load(std::memory_order_relaxed) < kNavigatingMs; const int why = front ? 1 : open ? 2 : choice ? 3 : 0; static int last = -1, told = 0; if (why != last && told < 40) { ++told; static const char* const kWhy[] = {"off", "the front end", "a menu the game opened", "a list with a choice"}; if (why == 3) host_log("menu keys: %s (%p)", kWhy[why], static_cast(g_choice_list.load(std::memory_order_relaxed))); else host_log("menu keys: %s", kWhy[why]); } last = why; return why != 0; } void HoldRow(bool hold) { g_holding_row.store(hold, std::memory_order_relaxed); } void Click() { g_click_ms.store(NowMs(), std::memory_order_relaxed); g_click.store(true, std::memory_order_relaxed); } std::uint32_t TakePress() { return g_press.exchange(0); } } std::uint32_t menu_confirm_button() { return MenuPointer::RegionDecidesWithCircle() ? PadBits::kCircle : PadBits::kCross; } std::uint32_t menu_back_button() { return menu_confirm_button() == PadBits::kCircle ? PadBits::kCross : PadBits::kCircle; } bool menu_pointer_in_menu() { return MenuPointer::InMenu(); }