src/internal/scan_engine.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_INTERNAL_SCAN_ENGINE_HPP | ||
| 2 | #define DETOURMODKIT_INTERNAL_SCAN_ENGINE_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file internal/scan_engine.hpp | ||
| 6 | * @brief Raw AOB matching engine: the compiled-pattern representation, the rarest-byte anchor selector, the | ||
| 7 | * memchr-prefiltered SIMD match loop, and the runtime SIMD-tier report. | ||
| 8 | * @details Never installed. The public scan::Pattern is converted to an EnginePattern through engine_pattern_from(), so | ||
| 9 | * the matcher sees one representation regardless of whether the pattern came from a DSL string or the | ||
| 10 | * value-semantic Pattern. | ||
| 11 | */ | ||
| 12 | |||
| 13 | #include "DetourModKit/scan.hpp" | ||
| 14 | |||
| 15 | #include <cstddef> | ||
| 16 | #include <cstdint> | ||
| 17 | #include <limits> | ||
| 18 | #include <optional> | ||
| 19 | #include <span> | ||
| 20 | #include <string_view> | ||
| 21 | #include <vector> | ||
| 22 | |||
| 23 | namespace DetourModKit | ||
| 24 | { | ||
| 25 | namespace detail | ||
| 26 | { | ||
| 27 | /** | ||
| 28 | * @brief Region-wide ceiling on total bounded-jump backtracking node visits for one segmented scan. | ||
| 29 | * @details The per-position @ref SEGMENT_MATCH_STEP_BUDGET caps one start position, but a non-anchored pattern | ||
| 30 | * tries every start in a region and can otherwise degrade to O(region_size x per-position budget). | ||
| 31 | * Work accumulates across all starts and suffix continuations of one physical region; exceeding the | ||
| 32 | * ceiling marks the result truncated rather than spending unbounded time. An over-broad bounded-jump | ||
| 33 | * pattern should add a literal to its leading segment instead. | ||
| 34 | */ | ||
| 35 | inline constexpr std::size_t SEGMENT_MATCH_REGION_STEP_BUDGET = 1u << 26; | ||
| 36 | static_assert( | ||
| 37 | SEGMENT_MATCH_REGION_STEP_BUDGET >= SEGMENT_MATCH_STEP_BUDGET, | ||
| 38 | "The per-region budget must let at least one start position run to its per-position ceiling." | ||
| 39 | ); | ||
| 40 | |||
| 41 | /** | ||
| 42 | * @struct EnginePattern | ||
| 43 | * @brief A heap-backed compiled AOB pattern with separate bytes and mask, plus a cached scan anchor. | ||
| 44 | * @details A position matches when (memory_byte ^ @ref bytes) & @ref mask == 0, so 0xFF marks a fully literal | ||
| 45 | * byte, 0x00 a wildcard, and 0xF0 / 0x0F a per-nibble token. Byte values are pre-masked to their known | ||
| 46 | * bits, which is what lets one plain compare be correct at every position without special-casing the | ||
| 47 | * wildcard slots. | ||
| 48 | */ | ||
| 49 | struct EnginePattern | ||
| 50 | { | ||
| 51 | /// Pattern bytes, one per token in the source AOB string, pre-masked to their known bits. | ||
| 52 | std::vector<std::byte> bytes; | ||
| 53 | |||
| 54 | /// Per-byte match mask paralleling @ref bytes; sized identically. | ||
| 55 | std::vector<std::byte> mask; | ||
| 56 | |||
| 57 | /** | ||
| 58 | * @brief Byte offset from pattern start to the point of interest, from the `|` marker (0 if absent). | ||
| 59 | * @details May equal bytes.size() when `|` appears at the end. Signed to match pointer arithmetic. | ||
| 60 | */ | ||
| 61 | std::ptrdiff_t offset = 0; | ||
| 62 | |||
| 63 | /** | ||
| 64 | * @brief Cached anchor index selected by compile_anchor(). | ||
| 65 | * @details find_pattern() drives its memchr sweep on the byte at this position. The anchor is confined to | ||
| 66 | * segment 0 (the fixed run before the first bounded jump) because the matcher finds that run and | ||
| 67 | * then extends across the variable gaps; a byte in a later segment sits at a gap-dependent address | ||
| 68 | * the prefilter cannot sweep for. | ||
| 69 | * | ||
| 70 | * Sentinel values: | ||
| 71 | * - `[0, size())` valid anchor. | ||
| 72 | * - `size()` segment 0 has no fully-known byte to anchor on; the scan degenerates. | ||
| 73 | * - `>= size() + 1` anchor not yet selected; find_pattern() picks one inline (slower path). | ||
| 74 | */ | ||
| 75 | std::size_t anchor = std::numeric_limits<std::size_t>::max(); | ||
| 76 | |||
| 77 | /** | ||
| 78 | * @brief Bounded-jump gaps between fixed segments, in ascending position order. | ||
| 79 | * @details Empty for a plain pattern, which takes the single fixed-width fast path. Copied verbatim from | ||
| 80 | * the shared parser so the runtime matcher and the compile-time Pattern agree on the segmentation. | ||
| 81 | */ | ||
| 82 | std::vector<PatternJump> jumps; | ||
| 83 | |||
| 84 | /// Returns the number of fixed bytes in the pattern (all segments concatenated, gaps excluded). | ||
| 85 | 9269508 | [[nodiscard]] std::size_t size() const noexcept { return bytes.size(); } | |
| 86 | |||
| 87 | /// Checks if the pattern has no bytes. | ||
| 88 | 6331 | [[nodiscard]] bool empty() const noexcept { return bytes.empty(); } | |
| 89 | |||
| 90 | /// Fewest bytes any match can occupy: the fixed byte count plus every gap's minimum skip. | ||
| 91 | 2179 | [[nodiscard]] std::size_t min_match_length() const noexcept | |
| 92 | { | ||
| 93 | 2179 | std::size_t total = bytes.size(); | |
| 94 |
2/2✓ Branch 16 → 5 taken 2251 times.
✓ Branch 16 → 17 taken 2179 times.
|
6609 | for (const PatternJump &gap : jumps) |
| 95 | { | ||
| 96 | 2251 | total += gap.min_skip; | |
| 97 | } | ||
| 98 | 2179 | return total; | |
| 99 | } | ||
| 100 | |||
| 101 | /// Most bytes any match can occupy: the fixed byte count plus every gap's maximum skip. | ||
| 102 | 25934 | [[nodiscard]] std::size_t max_match_length() const noexcept | |
| 103 | { | ||
| 104 | 25934 | std::size_t total = bytes.size(); | |
| 105 |
2/2✓ Branch 16 → 5 taken 2170 times.
✓ Branch 16 → 17 taken 25933 times.
|
54037 | for (const PatternJump &gap : jumps) |
| 106 | { | ||
| 107 | 2170 | total += gap.max_skip; | |
| 108 | } | ||
| 109 | 25933 | return total; | |
| 110 | } | ||
| 111 | |||
| 112 | /** | ||
| 113 | * @brief Selects and stores the rarest fully-known byte's index in segment 0 as the scan anchor. | ||
| 114 | * @details Scores each fully-known byte against a small byte-frequency table so one memchr pass produces | ||
| 115 | * far fewer false candidate hits than anchoring on bytes[0]. Partially-masked nibble positions | ||
| 116 | * cannot anchor: the prefilter needs one exact byte value. Ties break by first occurrence. | ||
| 117 | * Idempotent and O(size()). A caller that mutates @ref bytes, @ref mask, or @ref jumps afterwards | ||
| 118 | * MUST call it again or the cached anchor drifts. Not thread-safe with concurrent find_pattern() | ||
| 119 | * on the same instance. | ||
| 120 | */ | ||
| 121 | void compile_anchor() noexcept; | ||
| 122 | }; | ||
| 123 | |||
| 124 | /** | ||
| 125 | * @brief Parses a space-separated AOB string into a compiled EnginePattern. | ||
| 126 | * @param aob_str The AOB pattern string. | ||
| 127 | * @return The compiled pattern, or std::nullopt on any parse failure. | ||
| 128 | * @details Drives the single shared grammar through a heap-backed sink, so the runtime engine and the | ||
| 129 | * compile-time scan::Pattern can never silently diverge. Unlike the fixed-array Pattern storage this | ||
| 130 | * imposes no MAX_PATTERN_BYTES cap, so a long runtime pattern compiles here even when it would | ||
| 131 | * overflow a literal Pattern; the jump count is still capped at MAX_PATTERN_JUMPS. | ||
| 132 | */ | ||
| 133 | [[nodiscard]] std::optional<EnginePattern> parse_aob(std::string_view aob_str); | ||
| 134 | |||
| 135 | /** | ||
| 136 | * @struct RawMatch | ||
| 137 | * @brief A raw match location: the match start, its one-past-last-byte end, and the offset-applied point. | ||
| 138 | * @details All three are reported because a bounded-jump match has a variable span, and a caller doing | ||
| 139 | * exclusion or cross-boundary counting needs the true end rather than a fixed pattern length. All are | ||
| 140 | * null on no match. | ||
| 141 | */ | ||
| 142 | struct RawMatch | ||
| 143 | { | ||
| 144 | /// Leftmost match start (segment 0 address), or nullptr on no match. | ||
| 145 | const std::byte *start = nullptr; | ||
| 146 | /// One past the last matched byte (start + actual match length), or nullptr on no match. | ||
| 147 | const std::byte *end = nullptr; | ||
| 148 | /// The offset-applied result address the `|` marker resolves to, or nullptr on no match. | ||
| 149 | const std::byte *point = nullptr; | ||
| 150 | /** | ||
| 151 | * @brief True when the segmented matcher spent its backtracking budget before the scan was exhaustive. | ||
| 152 | * @details A truncated scan cannot prove there is no earlier match, nor that a found match is the leftmost, | ||
| 153 | * so a caller counting occurrences MUST fail closed. Independent of start/end/point: it can be | ||
| 154 | * true on both a found match and a no-match return. The flat matcher never sets it. | ||
| 155 | */ | ||
| 156 | bool budget_exhausted = false; | ||
| 157 | }; | ||
| 158 | |||
| 159 | /** | ||
| 160 | * @struct SegmentedScanBudget | ||
| 161 | * @brief Shared bounded-jump work state for one pattern over one physical readable region. | ||
| 162 | * @details The Nth-occurrence helpers continue from the byte after each prior match and pass the same state to | ||
| 163 | * every suffix scan, so the region-wide ceiling cannot reset at a continuation boundary. Meaningful | ||
| 164 | * only for one pattern and one contiguous region; flat patterns ignore it. | ||
| 165 | */ | ||
| 166 | struct SegmentedScanBudget | ||
| 167 | { | ||
| 168 | /// Node visits already spent across all segmented suffix scans of the region. | ||
| 169 | std::size_t node_visits = 0; | ||
| 170 | /// True once a per-position or region-wide cap made the occurrence count incomplete. | ||
| 171 | bool exhausted = false; | ||
| 172 | /// True once the region-wide cap prevents any further segmented node visits. | ||
| 173 | bool region_exhausted = false; | ||
| 174 | }; | ||
| 175 | |||
| 176 | /** | ||
| 177 | * @brief Builds an EnginePattern from a public value-semantic scan::Pattern. | ||
| 178 | * @param pattern The compiled value Pattern. | ||
| 179 | * @param anchor_index The prefilter position, already in the engine sentinel convention (size() means "no | ||
| 180 | * fully-known byte"). | ||
| 181 | * @return The heap-backed engine pattern. Allocates, so a caller on a noexcept path must guard std::bad_alloc. | ||
| 182 | */ | ||
| 183 | [[nodiscard]] EnginePattern engine_pattern_from(const scan::Pattern &pattern, std::size_t anchor_index); | ||
| 184 | |||
| 185 | /** | ||
| 186 | * @brief Scans a readable memory region for the first occurrence of a byte pattern. | ||
| 187 | * @param start_address Pointer to the beginning of the region to scan. | ||
| 188 | * @param region_size The size in bytes of the region to scan. | ||
| 189 | * @param pattern The compiled pattern. | ||
| 190 | * @return Pointer to the match (adjusted by pattern.offset), or nullptr if not found. | ||
| 191 | * @warning READABLE-RANGE PRECONDITION: this raw matcher performs no page filtering. The caller MUST guarantee | ||
| 192 | * the entire span is committed and readable; the search reads it with raw memchr/SIMD loads. | ||
| 193 | */ | ||
| 194 | [[nodiscard]] const std::byte * | ||
| 195 | find_pattern(const std::byte *start_address, std::size_t region_size, const EnginePattern &pattern); | ||
| 196 | |||
| 197 | /** | ||
| 198 | * @brief Scans a readable region for the Nth occurrence of a byte pattern. | ||
| 199 | * @param start_address Pointer to the beginning of the region to scan. | ||
| 200 | * @param region_size The size in bytes of the region to scan. | ||
| 201 | * @param pattern The compiled pattern. | ||
| 202 | * @param occurrence Which occurrence to return (1-based). Passing 0 returns nullptr. | ||
| 203 | * @return Pointer to the Nth occurrence (adjusted by pattern.offset), or nullptr if fewer than N matches exist. | ||
| 204 | * @warning Same READABLE-RANGE PRECONDITION as the single-occurrence overload. | ||
| 205 | */ | ||
| 206 | [[nodiscard]] const std::byte *find_pattern( | ||
| 207 | const std::byte *start_address, | ||
| 208 | std::size_t region_size, | ||
| 209 | const EnginePattern &pattern, | ||
| 210 | std::size_t occurrence | ||
| 211 | ); | ||
| 212 | |||
| 213 | /** | ||
| 214 | * @brief Implements an Nth-occurrence scan with caller-owned bounded-jump work state. | ||
| 215 | * @param start_address Pointer to the beginning of the region to scan. | ||
| 216 | * @param region_size The size in bytes of the region to scan. | ||
| 217 | * @param pattern The compiled pattern. | ||
| 218 | * @param occurrence Which occurrence to return (1-based). Passing 0 returns nullptr. | ||
| 219 | * @param segmented_budget Shared bounded-jump work state for all suffix scans of this region. | ||
| 220 | * @return Pointer to the Nth occurrence (adjusted by pattern.offset), or nullptr on a miss or truncated scan. | ||
| 221 | * @details Keeping the budget outside the suffix loop is what stops bounded-jump work from resetting after each | ||
| 222 | * prior match. The state must belong to this one pattern and contiguous readable region. | ||
| 223 | * @warning Same READABLE-RANGE PRECONDITION as the single-occurrence overload. | ||
| 224 | */ | ||
| 225 | [[nodiscard]] const std::byte *find_pattern_nth( | ||
| 226 | const std::byte *start_address, | ||
| 227 | std::size_t region_size, | ||
| 228 | const EnginePattern &pattern, | ||
| 229 | std::size_t occurrence, | ||
| 230 | SegmentedScanBudget &segmented_budget | ||
| 231 | ); | ||
| 232 | |||
| 233 | /// Span convenience over the pointer+size single-occurrence matcher (same READABLE-RANGE precondition). | ||
| 234 | [[nodiscard]] inline const std::byte * | ||
| 235 | 2 | find_pattern(std::span<const std::byte> region, const EnginePattern &pattern) | |
| 236 | { | ||
| 237 | 2 | return find_pattern(region.data(), region.size(), pattern); | |
| 238 | } | ||
| 239 | |||
| 240 | /// Span convenience over the pointer+size Nth-occurrence matcher (same READABLE-RANGE precondition). | ||
| 241 | [[nodiscard]] inline const std::byte * | ||
| 242 | 1 | find_pattern(std::span<const std::byte> region, const EnginePattern &pattern, std::size_t occurrence) | |
| 243 | { | ||
| 244 | 1 | return find_pattern(region.data(), region.size(), pattern, occurrence); | |
| 245 | } | ||
| 246 | |||
| 247 | /** | ||
| 248 | * @brief Locates the leftmost match and reports its start, end, and resolved point. | ||
| 249 | * @param segmented_budget Optional shared state for bounded-jump suffix continuations of one region. | ||
| 250 | * @details The single dispatch point for both the flat fixed-width fast path and the segmented backtracking | ||
| 251 | * matcher. The offset is baked into RawMatch::point so it is applied exactly once regardless of gap | ||
| 252 | * widths. When @p segmented_budget is supplied, bounded-jump work accumulates across every suffix | ||
| 253 | * continuation over the same physical region. | ||
| 254 | */ | ||
| 255 | [[nodiscard]] RawMatch find_pattern_raw( | ||
| 256 | const std::byte *start_address, | ||
| 257 | std::size_t region_size, | ||
| 258 | const EnginePattern &pattern, | ||
| 259 | SegmentedScanBudget *segmented_budget = nullptr | ||
| 260 | ) noexcept; | ||
| 261 | |||
| 262 | /** | ||
| 263 | * @brief True when @p pattern carries at least one literal (non-wildcard) byte. | ||
| 264 | * @details Shared guard for the "all wildcards matches anywhere" degenerate case the page sweeps screen for. | ||
| 265 | */ | ||
| 266 | [[nodiscard]] bool pattern_has_literal_byte(const EnginePattern &pattern) noexcept; | ||
| 267 | |||
| 268 | /** | ||
| 269 | * @brief Reports the SIMD tier find_pattern matching uses at runtime. | ||
| 270 | * @details Reflects compile-time support and runtime CPU detection. Reports Avx512 only on a DMK_ENABLE_AVX512 | ||
| 271 | * build on an AVX-512F + AVX-512BW host; otherwise the highest available lower tier. | ||
| 272 | */ | ||
| 273 | [[nodiscard]] scan::SimdLevel active_simd_level() noexcept; | ||
| 274 | } // namespace detail | ||
| 275 | } // namespace DetourModKit | ||
| 276 | |||
| 277 | #endif // DETOURMODKIT_INTERNAL_SCAN_ENGINE_HPP | ||
| 278 |