GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 96.0% 264 / 0 / 275
Functions: 100.0% 19 / 0 / 19
Branches: 89.0% 275 / 0 / 309

include/DetourModKit/detail/pattern_core.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_DETAIL_PATTERN_CORE_HPP
2 #define DETOURMODKIT_DETAIL_PATTERN_CORE_HPP
3
4 /**
5 * @file pattern_core.hpp
6 * @brief Logger-free, heap-free constexpr core that parses the AOB mini-DSL and selects a rarest-byte anchor.
7 * @details The public scan::Pattern type needs to parse the same "48 8B ?? E8 ? ? ? ?" DSL in two very different
8 * contexts: scan::Pattern::compile() at run time (from a configuration string, returning a Result) and
9 * scan::Pattern::literal() at COMPILE time (from an in-source string literal, where a typo must be a build
10 * error). A single parser cannot serve both if it touches the heap (a consteval result may not own
11 * non-transient heap storage) or the logging singleton (not constexpr-callable). This core is therefore a
12 * pure constexpr function that writes into fixed-size arrays and reports failure through a status enum, with
13 * no std::vector and no Logger anywhere in its body. Both Pattern entry points call it and only differ in how
14 * they react to a non-Ok status (compile() maps it to an Error, literal() turns it into a hard compile error).
15 *
16 * The byte/mask encoding, the match semantics, and the rarest-byte anchor heuristic are shared with the
17 * heap-backed runtime scan engine so every parser entry point resolves the same DSL identically; the engine's
18 * runtime haystack-frequency selection can override this compile-time anchor, which serves as the fallback
19 * when no haystack histogram is available.
20 * @note Unlike the other detail/ headers, both axes line up here: scan.hpp needs this implementation support at
21 * compile time, so it sits in the detail/ directory and declares its types in the ::detail namespace. Directory
22 * placement and namespace placement are independent.
23 */
24
25 #include <array>
26 #include <cstddef>
27 #include <cstdint>
28 #include <span>
29 #include <string_view>
30
31 namespace DetourModKit::detail
32 {
33 /**
34 * @brief Inline-storage cap for a compiled pattern, in bytes.
35 * @details The cap is baked into the std::array member type so that a compiled Pattern is a literal type a
36 * consteval result can return by value (a value owning heap storage cannot be returned from consteval).
37 * Game AOB signatures are short; 128 covers every shipped consumer literal with wide headroom and also
38 * admits the longest exercised runtime patterns, since literal() and compile() share this one storage.
39 */
40 inline constexpr std::size_t MAX_PATTERN_BYTES = 128;
41
42 /**
43 * @brief Anchor sentinel meaning "no fully-known byte exists to anchor on".
44 * @details Set to the cap so it is always one past any valid index. An all-wildcard or nibble-only pattern has no
45 * full byte the prefilter can memchr for and resolves through a masked compare at every position instead.
46 */
47 inline constexpr std::size_t NO_ANCHOR = MAX_PATTERN_BYTES;
48
49 /**
50 * @brief Maximum number of bounded-jump gaps a single pattern may carry.
51 * @details A bounded jump (`[X-Y]`) splits the fixed byte stream into segments; each jump records one gap between
52 * two fixed runs. `PatternBuffer::jumps` is a fixed `std::array` sized to this cap, and every value
53 * Pattern (and every Candidate that holds one) carries it inline, so the cap is kept to a small handful of
54 * gaps: a real signature anchors on a few stable points and rarely needs more than one or two gaps. A
55 * pattern that names more gaps fails closed at parse with TooManyJumps rather than silently truncating.
56 */
57 inline constexpr std::size_t MAX_PATTERN_JUMPS = 8;
58
59 /**
60 * @brief Upper bound on a single jump gap's maximum skip, in bytes.
61 * @details A jump range is deliberately bounded (this is a bounded-jump dialect, not YARA's unbounded `[X-]`): the
62 * gap consumes match-window bytes and each extra byte of span multiplies the backtracking matcher's work,
63 * so an unbounded gap could turn a scan into a region-wide sweep. Capping the span bounds each gap's
64 * contribution to that work (see try_segments_at for the full cost profile). A gap whose upper bound
65 * exceeds this is rejected at parse.
66 */
67 inline constexpr std::size_t MAX_JUMP_SPAN = 256;
68
69 /**
70 * @brief Per-start-position ceiling on bounded-jump backtracking node visits.
71 * @details The segmented matcher is deliberately simple and unmemoized: on a miss it may try every skip value of
72 * every gap, so a pathological all-wildcard, wide-gap pattern can approach the product of the gap spans at
73 * one start position. This budget converts that per-position product into a fixed ceiling while preserving
74 * the region-level linear sweep. Exhausting the budget fails the current placement closed (no match)
75 * rather than hanging. The assertion keeps the budget above the linear per-position work of a well-formed
76 * pattern, so ordinary literal-anchored signatures finish before the cap.
77 */
78 inline constexpr std::size_t SEGMENT_MATCH_STEP_BUDGET = 1u << 16;
79 static_assert(
80 SEGMENT_MATCH_STEP_BUDGET >= MAX_PATTERN_JUMPS * MAX_JUMP_SPAN,
81 "The per-position work budget must exceed the linear per-position cost of a well-formed pattern."
82 );
83
84 /**
85 * @struct PatternJump
86 * @brief One bounded gap between two fixed byte runs (segments) of a compiled pattern.
87 * @details A jump lets a pattern tolerate a variable-length span between two stable anchors (an instruction whose
88 * encoding size shifts when the compiler's output moves), which a fixed run of wildcards cannot:
89 * `?? ?? ??` matches exactly three bytes, while `[2-5]` matches any two-to-five-byte gap. @ref position is
90 * the index in the concatenated fixed byte stream that the gap precedes (strictly inside `(0, length)`,
91 * since a jump can neither lead nor trail the pattern nor sit adjacent to another jump). @ref min_skip /
92 * @ref max_skip bound the gap; `min_skip == max_skip` is an exact `[N]` jump.
93 */
94 struct PatternJump
95 {
96 /// Index in the fixed byte stream the gap precedes; the boundary between segment i and segment i+1.
97 std::size_t position{0};
98 /// Fewest bytes the gap may skip before the following segment.
99 std::size_t min_skip{0};
100 /// Most bytes the gap may skip before the following segment; >= min_skip and <= MAX_JUMP_SPAN.
101 std::size_t max_skip{0};
102 };
103
104 /**
105 * @enum PatternStatus
106 * @brief Outcome of parsing an AOB DSL string in the constexpr core.
107 */
108 enum class PatternStatus : std::uint8_t
109 {
110 /// Parsed successfully into at least one byte.
111 Ok,
112 /// The input held no byte tokens (empty, whitespace-only, or only an offset marker).
113 Empty,
114 /// A token was not a recognized DSL form.
115 InvalidToken,
116 /// The pattern exceeded MAX_PATTERN_BYTES byte tokens.
117 TooLong,
118 /// More than one offset marker was present.
119 DuplicateOffset,
120 /// A `[...]` jump token was malformed, out of range, or illegally placed (leading, trailing, or adjacent).
121 InvalidJump,
122 /// The pattern named more bounded jumps than MAX_PATTERN_JUMPS.
123 TooManyJumps
124 };
125
126 /**
127 * @struct PatternBuffer
128 * @brief The compiled byte/mask representation plus the offset marker, bounded-jump gaps, and the selected anchor.
129 * @details A literal-type aggregate (no heap): @ref bytes and @ref mask are fixed arrays sized to the cap, and
130 * only the first @ref length entries are meaningful. @ref offset is the "point of interest" the optional
131 * `|` marker records (0 when absent, which coincides with the match start). @ref anchor is the index of
132 * the rarest fully-known byte, or @ref NO_ANCHOR when none exists.
133 *
134 * A pattern with bounded jumps splits its fixed byte stream into segments. @ref bytes / @ref mask hold the
135 * segments concatenated with no gap bytes; @ref jumps records where a gap sits and how wide it may be. A
136 * jump-free pattern has @ref jump_count == 0 and takes the same single fixed-width match path. The anchor
137 * is deliberately confined to segment 0 (the bytes before the first jump), because the matcher locates
138 * that first fixed run and then extends across the variable gaps: a byte in a later segment sits at an
139 * address that shifts with the gap, so it cannot drive the memchr prefilter.
140 */
141 struct PatternBuffer
142 {
143 /// Pattern byte values; only entries [0, length) are valid.
144 std::array<std::byte, MAX_PATTERN_BYTES> bytes{};
145 /// Per-byte match mask (0xFF literal, 0x00 wildcard, 0xF0 high nibble, 0x0F low nibble).
146 std::array<std::byte, MAX_PATTERN_BYTES> mask{};
147 /// Number of valid byte entries (all segments concatenated, gaps excluded).
148 std::size_t length{0};
149 /// Result offset recorded by the `|` marker; 0 when no marker is present.
150 std::size_t offset{0};
151 /// Index of the rarest fully-known byte in segment 0, or NO_ANCHOR when segment 0 has no full byte.
152 std::size_t anchor{NO_ANCHOR};
153 /// Bounded-jump gaps in ascending position order; only entries [0, jump_count) are valid.
154 std::array<PatternJump, MAX_PATTERN_JUMPS> jumps{};
155 /// Number of valid jump gaps; 0 for a plain (single-segment) pattern.
156 std::size_t jump_count{0};
157 };
158
159 /**
160 * @struct PatternParse
161 * @brief A parse status paired with the buffer it produced (valid only when status == Ok).
162 */
163 struct PatternParse
164 {
165 /// Parse outcome.
166 PatternStatus status{PatternStatus::Empty};
167 /// The compiled representation; meaningful only when @ref status is Ok.
168 PatternBuffer buffer{};
169 };
170
171 /// Maps a hex digit to its value 0-15, or -1 if @p ch is not a hex digit.
172 39784 [[nodiscard]] constexpr int hex_digit(char ch) noexcept
173 {
174
3/4
✓ Branch 2 → 3 taken 39784 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 34047 times.
✓ Branch 3 → 5 taken 5737 times.
39784 if (ch >= '0' && ch <= '9')
175 {
176 34047 return ch - '0';
177 }
178
3/4
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 8 taken 5734 times.
✓ Branch 6 → 7 taken 3 times.
✗ Branch 6 → 8 not taken.
5737 if (ch >= 'a' && ch <= 'f')
179 {
180 3 return ch - 'a' + 10;
181 }
182
4/4
✓ Branch 8 → 9 taken 5715 times.
✓ Branch 8 → 11 taken 19 times.
✓ Branch 9 → 10 taken 5699 times.
✓ Branch 9 → 11 taken 16 times.
5734 if (ch >= 'A' && ch <= 'F')
183 {
184 5699 return ch - 'A' + 10;
185 }
186 35 return -1;
187 }
188
189 /// True for the token separators the DSL splits on (space, tab, CR, LF, form feed, vertical tab).
190 102331 [[nodiscard]] constexpr bool is_token_space(char ch) noexcept
191 {
192
9/12
✓ Branch 2 → 3 taken 62235 times.
✓ Branch 2 → 8 taken 40096 times.
✓ Branch 3 → 4 taken 62227 times.
✓ Branch 3 → 8 taken 8 times.
✓ Branch 4 → 5 taken 62227 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 62226 times.
✓ Branch 5 → 8 taken 1 time.
✓ Branch 6 → 7 taken 62226 times.
✗ Branch 6 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 62227 times.
102331 return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\f' || ch == '\v';
193 }
194
195 /**
196 * @brief Scores how common a byte is in typical x64 .text; lower means rarer and a better scan anchor.
197 * @param value The fully-known byte to score.
198 * @return A frequency class where 0 is the rarest (any byte outside the common-opcode table).
199 * @details Reproduces the runtime engine's table verbatim so the compile-time anchor matches the byte the engine
200 * would have chosen. The listed bytes are the usual high-frequency suspects (padding, INT3/NOP fill, REX
201 * prefixes, common MOV/two-byte-opcode/branch leads); anything else is treated as rare and preferred.
202 */
203 2561 [[nodiscard]] constexpr std::uint8_t byte_frequency_class(std::uint8_t value) noexcept
204 {
205
11/13
✓ Branch 2 → 3 taken 133 times.
✓ Branch 2 → 4 taken 11 times.
✓ Branch 2 → 5 taken 94 times.
✓ Branch 2 → 6 taken 208 times.
✓ Branch 2 → 7 taken 228 times.
✓ Branch 2 → 8 taken 127 times.
✓ Branch 2 → 9 taken 4 times.
✓ Branch 2 → 10 taken 4 times.
✗ Branch 2 → 11 not taken.
✓ Branch 2 → 12 taken 40 times.
✗ Branch 2 → 13 not taken.
✓ Branch 2 → 14 taken 2 times.
✓ Branch 2 → 15 taken 1710 times.
2561 switch (value)
206 {
207 133 case 0x00:
208 133 return 10;
209 11 case 0xCC:
210 11 return 9;
211 94 case 0x90:
212 94 return 9;
213 208 case 0xFF:
214 208 return 8;
215 228 case 0x48:
216 228 return 8;
217 127 case 0x8B:
218 127 return 7;
219 4 case 0x89:
220 4 return 7;
221 4 case 0x0F:
222 4 return 7;
223 case 0xE8:
224 return 6;
225 40 case 0xE9:
226 40 return 6;
227 case 0x83:
228 return 6;
229 2 case 0xC3:
230 2 return 5;
231 1710 default:
232 1710 return 0;
233 }
234 }
235
236 /**
237 * @brief Picks the index of the rarest fully-known byte in segment 0 to drive the prefilter.
238 * @param buffer A parsed buffer (only segment 0, i.e. [0, segment-0 end), is inspected).
239 * @return The index of the lowest-frequency 0xFF-masked byte in segment 0, or NO_ANCHOR if it has no full byte.
240 * @details Only a fully-known byte can anchor, because the prefilter sweeps with a single-byte memchr that cannot
241 * search for a partial nibble. The search is confined to segment 0 (the fixed run before the first bounded
242 * jump): the matcher finds that run first and extends across the variable gaps, so a byte in a later
243 * segment lands at a gap-dependent address the memchr prefilter cannot target. The scan keeps the
244 * lowest-scoring candidate seen so far and stops early once it finds a rarest-class byte (score 0), since
245 * no later byte can beat it.
246 */
247 413 [[nodiscard]] constexpr std::size_t select_anchor(const PatternBuffer &buffer) noexcept
248 {
249 // Segment 0 spans [0, first-jump position); with no jumps it is the whole pattern, so a plain pattern anchors
250 // over its entire length.
251
2/2
✓ Branch 2 → 3 taken 35 times.
✓ Branch 2 → 5 taken 378 times.
413 const std::size_t segment0_end = (buffer.jump_count > 0) ? buffer.jumps[0].position : buffer.length;
252 413 std::size_t best = NO_ANCHOR;
253 413 std::uint8_t best_score = 0xFF;
254
2/2
✓ Branch 18 → 7 taken 919 times.
✓ Branch 18 → 19 taken 42 times.
961 for (std::size_t index = 0; index < segment0_end; ++index)
255 {
256
2/2
✓ Branch 8 → 9 taken 101 times.
✓ Branch 8 → 10 taken 818 times.
919 if (buffer.mask[index] != std::byte{0xFF})
257 {
258 101 continue;
259 }
260 1636 const std::uint8_t score = byte_frequency_class(std::to_integer<std::uint8_t>(buffer.bytes[index]));
261
2/2
✓ Branch 14 → 15 taken 634 times.
✓ Branch 14 → 17 taken 184 times.
818 if (score < best_score)
262 {
263 634 best = index;
264 634 best_score = score;
265
2/2
✓ Branch 15 → 16 taken 371 times.
✓ Branch 15 → 17 taken 263 times.
634 if (score == 0)
266 {
267 371 break;
268 }
269 }
270 }
271 413 return best;
272 }
273
274 /**
275 * @struct JumpParse
276 * @brief Outcome of parsing one `[...]` bounded-jump token: a validity flag plus the resolved skip bounds.
277 */
278 struct JumpParse
279 {
280 /// True when the token was a well-formed, in-range bounded jump.
281 bool ok{false};
282 /// Fewest bytes the gap skips.
283 std::size_t min_skip{0};
284 /// Most bytes the gap skips.
285 std::size_t max_skip{0};
286 };
287
288 /**
289 * @brief Parses a whitespace-stripped bounded-jump token: `[X]` (exact) or `[X-Y]` (range).
290 * @param token The token including its brackets, e.g. "[2-5]".
291 * @return A JumpParse with ok == true and the resolved bounds, or ok == false on any malformed / out-of-range form.
292 * @details Rejects (returns ok == false) anything that is not a faithful bounded jump: missing brackets, empty or
293 * non-decimal content, trailing junk after the number(s), an inverted range (max < min), a bound above
294 * MAX_JUMP_SPAN, and YARA's unbounded `[X-]` form (this dialect is deliberately bounded so every match
295 * attempt stays a predictable cost). `[N]` is the exact-skip shorthand for `[N-N]`, including the harmless
296 * no-op `[0]`.
297 */
298 153 [[nodiscard]] constexpr JumpParse parse_jump_token(std::string_view token) noexcept
299 {
300 153 JumpParse result{};
301
6/8
✓ Branch 3 → 4 taken 149 times.
✓ Branch 3 → 8 taken 4 times.
✓ Branch 5 → 6 taken 149 times.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 149 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 12 taken 149 times.
153 if (token.size() < 3 || token.front() != '[' || token.back() != ']')
302 {
303 4 return result;
304 }
305 149 const std::string_view inner = token.substr(1, token.size() - 2);
306
307 149 std::size_t cursor = 0;
308 // The minimum bound is mandatory: a jump always names at least one number.
309
6/8
✓ Branch 15 → 16 taken 149 times.
✗ Branch 15 → 20 not taken.
✓ Branch 17 → 18 taken 149 times.
✗ Branch 17 → 20 not taken.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 148 times.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 148 times.
149 if (cursor >= inner.size() || inner[cursor] < '0' || inner[cursor] > '9')
310 {
311 1 return result;
312 }
313 148 std::size_t min_value = 0;
314
7/8
✓ Branch 30 → 31 taken 275 times.
✓ Branch 30 → 36 taken 34 times.
✓ Branch 32 → 33 taken 161 times.
✓ Branch 32 → 36 taken 114 times.
✓ Branch 34 → 35 taken 161 times.
✗ Branch 34 → 36 not taken.
✓ Branch 37 → 25 taken 161 times.
✓ Branch 37 → 38 taken 148 times.
309 while (cursor < inner.size() && inner[cursor] >= '0' && inner[cursor] <= '9')
315 {
316 161 min_value = min_value * 10 + static_cast<std::size_t>(inner[cursor] - '0');
317
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 161 times.
161 if (min_value > MAX_JUMP_SPAN)
318 {
319 // Also guards against integer overflow: any in-range bound is <= MAX_JUMP_SPAN, so bailing here keeps
320 // the accumulator far from wrapping.
321 return result;
322 }
323 161 ++cursor;
324 }
325
326 148 std::size_t max_value = min_value;
327
2/2
✓ Branch 39 → 40 taken 114 times.
✓ Branch 39 → 67 taken 34 times.
148 if (cursor < inner.size())
328 {
329
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 114 times.
114 if (inner[cursor] != '-')
330 {
331 return result;
332 }
333 114 ++cursor;
334 // A dash with no following digits is YARA's unbounded `[X-]`, which this bounded dialect does not admit.
335
6/8
✓ Branch 44 → 45 taken 112 times.
✓ Branch 44 → 49 taken 2 times.
✓ Branch 46 → 47 taken 112 times.
✗ Branch 46 → 49 not taken.
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 112 times.
✓ Branch 51 → 52 taken 2 times.
✓ Branch 51 → 53 taken 112 times.
114 if (cursor >= inner.size() || inner[cursor] < '0' || inner[cursor] > '9')
336 {
337 2 return result;
338 }
339 112 max_value = 0;
340
6/8
✓ Branch 59 → 60 taken 253 times.
✓ Branch 59 → 65 taken 111 times.
✓ Branch 61 → 62 taken 253 times.
✗ Branch 61 → 65 not taken.
✓ Branch 63 → 64 taken 253 times.
✗ Branch 63 → 65 not taken.
✓ Branch 66 → 54 taken 253 times.
✓ Branch 66 → 67 taken 111 times.
364 while (cursor < inner.size() && inner[cursor] >= '0' && inner[cursor] <= '9')
341 {
342 253 max_value = max_value * 10 + static_cast<std::size_t>(inner[cursor] - '0');
343
2/2
✓ Branch 55 → 56 taken 1 time.
✓ Branch 55 → 57 taken 252 times.
253 if (max_value > MAX_JUMP_SPAN)
344 {
345 1 return result;
346 }
347 252 ++cursor;
348 }
349 }
350
351
5/6
✓ Branch 68 → 69 taken 145 times.
✗ Branch 68 → 70 not taken.
✓ Branch 69 → 70 taken 3 times.
✓ Branch 69 → 71 taken 142 times.
✓ Branch 72 → 73 taken 3 times.
✓ Branch 72 → 74 taken 142 times.
145 if (cursor != inner.size() || max_value < min_value)
352 {
353 // Trailing junk after the bounds, or an inverted range.
354 3 return result;
355 }
356 142 result.ok = true;
357 142 result.min_skip = min_value;
358 142 result.max_skip = max_value;
359 142 return result;
360 }
361
362 /**
363 * @struct PatternBufferSink
364 * @brief The fixed-array storage sink for the compile-time parse: caps at MAX_PATTERN_BYTES / MAX_PATTERN_JUMPS.
365 * @details The compile-time Pattern must be a literal type a consteval result can return, so its byte / mask / jump
366 * storage is a fixed array and appending past the cap fails (a TooLong / TooManyJumps parse). The shared
367 * parser (@ref parse_pattern_into) writes every token through a sink so the grammar has one
368 * implementation; the runtime engine supplies its own heap-backed sink with no byte cap, which is why the
369 * same grammar serves both without imposing the literal-storage cap on runtime patterns.
370 */
371 struct PatternBufferSink
372 {
373 /// The compiled buffer being filled; only meaningful once the parse returns Ok.
374 PatternBuffer buffer{};
375
376 /// Fixed bytes appended so far.
377 825 [[nodiscard]] constexpr std::size_t length() const noexcept { return buffer.length; }
378 /// Jump gaps appended so far.
379 414 [[nodiscard]] constexpr std::size_t jump_count() const noexcept { return buffer.jump_count; }
380
381 /// Appends one fixed byte; returns false when the fixed-array cap is reached (parser maps this to TooLong).
382 4281 [[nodiscard]] constexpr bool add_byte(std::byte value, std::byte mask) noexcept
383 {
384
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 4279 times.
4281 if (buffer.length >= MAX_PATTERN_BYTES)
385 {
386 2 return false;
387 }
388 4279 buffer.bytes[buffer.length] = value;
389 4279 buffer.mask[buffer.length] = mask;
390 4279 ++buffer.length;
391 4279 return true;
392 }
393
394 /// Records a gap; returns false when the jump cap is reached (parser maps this to TooManyJumps).
395 91 [[nodiscard]] constexpr bool add_jump(std::size_t position, std::size_t min_skip, std::size_t max_skip) noexcept
396 {
397
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 90 times.
91 if (buffer.jump_count >= MAX_PATTERN_JUMPS)
398 {
399 1 return false;
400 }
401 90 buffer.jumps[buffer.jump_count] = PatternJump{position, min_skip, max_skip};
402 90 ++buffer.jump_count;
403 90 return true;
404 }
405
406 /// Records the `|` marker position in the fixed byte stream.
407 7 constexpr void set_offset(std::size_t position) noexcept { buffer.offset = position; }
408 };
409
410 /**
411 * @brief The single AOB DSL grammar, parsing into any storage sink (fixed-array or heap-backed).
412 * @param dsl The whitespace-separated token string, e.g. "48 8B 05 ?? ?? ?? ??".
413 * @param sink The storage sink; its add_byte / add_jump / set_offset / length / jump_count drive where tokens land.
414 * @return The parse status. On Ok the sink holds the compiled bytes / mask / jumps / offset (the anchor, which is
415 * storage-specific, is computed by the caller afterwards).
416 * @details One implementation of the grammar serves both the compile-time Pattern and the runtime EnginePattern.
417 * Whitespace splits the tokens, and leading or trailing whitespace is ignored. Recognized tokens:
418 * - two hex digits (`48`) -> that byte, mask 0xFF (fully known)
419 * - `??` or `?` -> any byte, mask 0x00 (full wildcard)
420 * - hex digit then `?` (`4?`) -> high nibble fixed, mask 0xF0
421 * - `?` then hex digit (`?5`) -> low nibble fixed, mask 0x0F
422 * - `[X]` / `[X-Y]` -> bounded jump: skip exactly X, or between X and Y, bytes before the next
423 * segment; splits the pattern into segments recorded as jumps
424 * - `|` -> offset marker: records the position of the NEXT byte (or the length
425 * when trailing) as the result offset; permitted at most once
426 * Any other token shape fails with InvalidToken. An input with no byte tokens fails with Empty. A sink
427 * that rejects an append fails with TooLong (byte cap) or TooManyJumps (jump cap).
428 *
429 * Every segment must be a non-empty fixed run, so a jump must not lead or trail the pattern and two
430 * jumps must not be adjacent. A violation is InvalidJump. The `|` marker records a position in the fixed
431 * byte stream. When a pattern also carries jumps, the resolver adds the actual gap bytes at match time,
432 * so the marker still points at the right run.
433 * @note Not noexcept. The compile-time fixed-array sink never allocates, so the literal path cannot throw. A
434 * heap-backed runtime sink can throw bad_alloc on an unbounded pattern, and a noexcept mark turns that OOM
435 * into a std::terminate. `parse_aob` catches the throw and fails closed to nullopt instead.
436 */
437 1292 template <class Sink> [[nodiscard]] constexpr PatternStatus parse_pattern_into(std::string_view dsl, Sink &sink)
438 {
439 1292 bool offset_marked = false;
440
441 // Index of the most recent segment boundary: 0 at the start, then the fixed length at each jump. A jump is only
442 // legal once at least one fixed byte has been added since this boundary, which enforces "no leading jump" and
443 // "no two adjacent jumps" in one check, and the end-of-parse comparison against it catches a trailing jump.
444 1292 std::size_t last_boundary = 0;
445
446 1292 std::size_t cursor = 0;
447 1292 const std::size_t end = dsl.size();
448
4/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 104 → 4 taken 8384 times.
✓ Branch 104 → 105 taken 416 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 104 → 4 taken 32240 times.
✓ Branch 104 → 105 taken 842 times.
41882 while (cursor < end)
449 {
450
4/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 6 → 7 taken 3989 times.
✓ Branch 6 → 8 taken 4395 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 6 → 7 taken 16061 times.
✓ Branch 6 → 8 taken 16179 times.
40624 if (is_token_space(dsl[cursor]))
451 {
452 20050 ++cursor;
453 20208 continue;
454 }
455
456 20574 const std::size_t token_start = cursor;
457
12/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 10 → 11 taken 13118 times.
✓ Branch 10 → 15 taken 393 times.
✓ Branch 13 → 14 taken 9116 times.
✓ Branch 13 → 15 taken 4002 times.
✓ Branch 16 → 9 taken 9116 times.
✓ Branch 16 → 17 taken 4395 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 10 → 11 taken 48594 times.
✓ Branch 10 → 15 taken 124 times.
✓ Branch 13 → 14 taken 32540 times.
✓ Branch 13 → 15 taken 16056 times.
✓ Branch 16 → 9 taken 32539 times.
✓ Branch 16 → 17 taken 16181 times.
62229 while (cursor < end && !is_token_space(dsl[cursor]))
458 {
459 41655 ++cursor;
460 }
461
2/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 17 → 18 taken 4395 times.
✗ Branch 17 → 118 not taken.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 17 → 18 taken 16180 times.
✗ Branch 17 → 119 not taken.
20576 const std::string_view token = dsl.substr(token_start, cursor - token_start);
462
463 // Offset marker: the position of interest is wherever the next byte lands. Placed at the very end, that is
464 // one past the final byte (offset == length), which is exactly the value the sink already holds.
465
12/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 19 → 20 taken 10 times.
✓ Branch 19 → 23 taken 4385 times.
✓ Branch 21 → 22 taken 8 times.
✓ Branch 21 → 23 taken 2 times.
✓ Branch 24 → 25 taken 8 times.
✓ Branch 24 → 30 taken 4387 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 19 → 20 taken 18 times.
✓ Branch 19 → 23 taken 16162 times.
✓ Branch 21 → 22 taken 15 times.
✓ Branch 21 → 23 taken 3 times.
✓ Branch 24 → 25 taken 15 times.
✓ Branch 24 → 30 taken 16165 times.
20575 if (token.size() == 1 && token[0] == '|')
466 {
467
4/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 7 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 14 times.
23 if (offset_marked)
468 {
469 33 return PatternStatus::DuplicateOffset;
470 }
471 21 offset_marked = true;
472 21 sink.set_offset(sink.length());
473 21 continue;
474 }
475
476 // Bounded jump: `[X]` or `[X-Y]`. A token that opens with `[` is always intended as a jump, so a malformed
477 // bracket form is a hard InvalidJump rather than falling through to the byte-token parser (which would
478 // otherwise misreport it as a generic InvalidToken).
479
10/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 31 → 32 taken 4387 times.
✗ Branch 31 → 35 not taken.
✓ Branch 33 → 34 taken 100 times.
✓ Branch 33 → 35 taken 4287 times.
✓ Branch 36 → 37 taken 100 times.
✓ Branch 36 → 55 taken 4287 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 31 → 32 taken 16165 times.
✗ Branch 31 → 35 not taken.
✓ Branch 33 → 34 taken 53 times.
✓ Branch 33 → 35 taken 16112 times.
✓ Branch 36 → 37 taken 53 times.
✓ Branch 36 → 55 taken 16112 times.
20552 if (!token.empty() && token.front() == '[')
480 {
481 153 const JumpParse jump = parse_jump_token(token);
482
4/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 38 → 39 taken 7 times.
✓ Branch 38 → 40 taken 93 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 38 → 39 taken 4 times.
✓ Branch 38 → 40 taken 49 times.
153 if (!jump.ok)
483 {
484 16 return PatternStatus::InvalidJump;
485 }
486 // A jump must sit between two non-empty fixed runs: reject a leading jump (no byte yet) and a jump
487 // adjacent to the previous one (no byte added since the last boundary). Both collapse to this one test.
488
12/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 41 → 42 taken 92 times.
✓ Branch 41 → 44 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 45 taken 91 times.
✓ Branch 46 → 47 taken 2 times.
✓ Branch 46 → 48 taken 91 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 41 → 42 taken 48 times.
✓ Branch 41 → 44 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 45 taken 47 times.
✓ Branch 46 → 47 taken 2 times.
✓ Branch 46 → 48 taken 47 times.
142 if (sink.length() == 0 || sink.length() == last_boundary)
489 {
490 4 return PatternStatus::InvalidJump;
491 }
492
4/6
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 50 → 51 taken 1 time.
✓ Branch 50 → 52 taken 90 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 49 → 50 taken 47 times.
✗ Branch 49 → 118 not taken.
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 52 taken 47 times.
138 if (!sink.add_jump(sink.length(), jump.min_skip, jump.max_skip))
493 {
494 1 return PatternStatus::TooManyJumps;
495 }
496 137 last_boundary = sink.length();
497 137 continue;
498 137 }
499
500 20399 std::byte byte_value{0x00};
501 20399 std::byte mask_value{0x00};
502
12/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 56 → 57 taken 4283 times.
✓ Branch 56 → 62 taken 4 times.
✓ Branch 58 → 59 taken 424 times.
✓ Branch 58 → 62 taken 3859 times.
✓ Branch 60 → 61 taken 421 times.
✓ Branch 60 → 62 taken 3 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 56 → 57 taken 16108 times.
✓ Branch 56 → 62 taken 4 times.
✓ Branch 58 → 59 taken 82 times.
✓ Branch 58 → 62 taken 16026 times.
✓ Branch 60 → 61 taken 77 times.
✓ Branch 60 → 62 taken 5 times.
20399 const bool double_wildcard = token.size() == 2 && token[0] == '?' && token[1] == '?';
503
8/8
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 64 → 65 taken 2 times.
✓ Branch 64 → 68 taken 4285 times.
✓ Branch 66 → 67 taken 1 time.
✓ Branch 66 → 68 taken 1 time.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 64 → 65 taken 3 times.
✓ Branch 64 → 68 taken 16109 times.
✓ Branch 66 → 67 taken 2 times.
✓ Branch 66 → 68 taken 1 time.
20399 const bool single_wildcard = token.size() == 1 && token[0] == '?';
504
8/8
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 69 → 70 taken 3866 times.
✓ Branch 69 → 97 taken 421 times.
✓ Branch 70 → 71 taken 3865 times.
✓ Branch 70 → 97 taken 1 time.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 69 → 70 taken 16035 times.
✓ Branch 69 → 97 taken 77 times.
✓ Branch 70 → 71 taken 16033 times.
✓ Branch 70 → 97 taken 2 times.
20399 if (double_wildcard || single_wildcard)
505 {
506 // Full wildcard: any byte matches, so both value and mask stay zero.
507 }
508
4/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 72 → 73 taken 3862 times.
✓ Branch 72 → 96 taken 3 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 72 → 73 taken 16030 times.
✓ Branch 72 → 96 taken 2 times.
19898 else if (token.size() == 2)
509 {
510 19892 const int high = hex_digit(token[0]);
511 19892 const int low = hex_digit(token[1]);
512
8/8
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 77 → 78 taken 3856 times.
✓ Branch 77 → 80 taken 6 times.
✓ Branch 78 → 79 taken 3852 times.
✓ Branch 78 → 80 taken 4 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 77 → 78 taken 16020 times.
✓ Branch 77 → 80 taken 9 times.
✓ Branch 78 → 79 taken 16012 times.
✓ Branch 78 → 80 taken 8 times.
19891 if (high >= 0 && low >= 0)
513 {
514 19864 byte_value = static_cast<std::byte>(static_cast<unsigned char>((high << 4) | low));
515 19864 mask_value = std::byte{0xFF};
516 }
517
10/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 80 → 81 taken 4 times.
✓ Branch 80 → 84 taken 6 times.
✓ Branch 82 → 83 taken 4 times.
✗ Branch 82 → 84 not taken.
✓ Branch 85 → 86 taken 4 times.
✓ Branch 85 → 87 taken 6 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 80 → 81 taken 7 times.
✓ Branch 80 → 84 taken 10 times.
✓ Branch 82 → 83 taken 7 times.
✗ Branch 82 → 84 not taken.
✓ Branch 85 → 86 taken 7 times.
✓ Branch 85 → 87 taken 10 times.
27 else if (high >= 0 && token[1] == '?')
518 {
519 11 byte_value = static_cast<std::byte>(static_cast<unsigned char>(high << 4));
520 11 mask_value = std::byte{0xF0};
521 }
522
10/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 88 → 89 taken 3 times.
✓ Branch 88 → 91 taken 3 times.
✓ Branch 89 → 90 taken 3 times.
✗ Branch 89 → 91 not taken.
✓ Branch 92 → 93 taken 3 times.
✓ Branch 92 → 94 taken 3 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 88 → 89 taken 5 times.
✓ Branch 88 → 91 taken 5 times.
✓ Branch 89 → 90 taken 5 times.
✗ Branch 89 → 91 not taken.
✓ Branch 92 → 93 taken 5 times.
✓ Branch 92 → 94 taken 5 times.
16 else if (token[0] == '?' && low >= 0)
523 {
524 8 byte_value = static_cast<std::byte>(static_cast<unsigned char>(low));
525 8 mask_value = std::byte{0x0F};
526 }
527 else
528 {
529 8 return PatternStatus::InvalidToken;
530 }
531 }
532 else
533 {
534 5 return PatternStatus::InvalidToken;
535 }
536
537
5/6
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 98 → 99 taken 2 times.
✓ Branch 98 → 100 taken 4279 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 97 → 98 taken 16103 times.
✓ Branch 97 → 119 taken 1 time.
✗ Branch 98 → 99 not taken.
✓ Branch 98 → 100 taken 16103 times.
20384 if (!sink.add_byte(byte_value, mask_value))
538 {
539 2 return PatternStatus::TooLong;
540 }
541 }
542
543
4/4
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 106 → 107 taken 2 times.
✓ Branch 106 → 108 taken 414 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 106 → 107 taken 4 times.
✓ Branch 106 → 108 taken 838 times.
1258 if (sink.length() == 0)
544 {
545 6 return PatternStatus::Empty;
546 }
547
548 // A trailing jump leaves an empty final segment (no fixed byte was added after the last gap). last_boundary
549 // still equals the fixed length in that case, so this catches "48 8B [2-5]" without a separate flag.
550
12/12
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::detail::PatternBufferSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::detail::PatternBufferSink&):
✓ Branch 109 → 110 taken 36 times.
✓ Branch 109 → 113 taken 378 times.
✓ Branch 111 → 112 taken 1 time.
✓ Branch 111 → 113 taken 35 times.
✓ Branch 114 → 115 taken 1 time.
✓ Branch 114 → 116 taken 413 times.
DetourModKit::detail::PatternStatus DetourModKit::detail::parse_pattern_into<DetourModKit::(anonymous namespace)::EnginePatternSink>(std::basic_string_view<char, std::char_traits<char> >, DetourModKit::(anonymous namespace)::EnginePatternSink&):
✓ Branch 109 → 110 taken 21 times.
✓ Branch 109 → 113 taken 816 times.
✓ Branch 111 → 112 taken 1 time.
✓ Branch 111 → 113 taken 20 times.
✓ Branch 114 → 115 taken 1 time.
✓ Branch 114 → 116 taken 836 times.
1252 if (sink.jump_count() > 0 && last_boundary == sink.length())
551 {
552 2 return PatternStatus::InvalidJump;
553 }
554
555 1249 return PatternStatus::Ok;
556 }
557
558 /**
559 * @brief Parses an AOB DSL string into a fixed-array PatternBuffer (the compile-time / value-Pattern storage).
560 * @param dsl The whitespace-separated token string, e.g. "48 8B 05 ?? ?? ?? ??".
561 * @return A PatternParse whose status is Ok (with a filled buffer) or a specific failure; a pattern with more than
562 * MAX_PATTERN_BYTES fixed bytes fails with TooLong (the fixed-array cap the literal type imposes).
563 * @details A thin wrapper over @ref parse_pattern_into with the capped fixed-array sink, plus the segment-0
564 * rarest-byte anchor computed on success. The runtime engine parses the same grammar through a heap-backed
565 * sink that has no byte cap, so a long runtime pattern is not bound by this literal-storage limit.
566 */
567 435 [[nodiscard]] constexpr PatternParse parse_pattern(std::string_view dsl) noexcept
568 {
569 435 PatternParse result{};
570 435 PatternBufferSink sink{};
571 435 result.status = parse_pattern_into(dsl, sink);
572
2/2
✓ Branch 3 → 4 taken 413 times.
✓ Branch 3 → 6 taken 22 times.
435 if (result.status == PatternStatus::Ok)
573 {
574 413 result.buffer = sink.buffer;
575 413 result.buffer.anchor = select_anchor(result.buffer);
576 }
577 435 return result;
578 }
579
580 /**
581 * @brief The fewest bytes any match of @p buffer can occupy (fixed bytes plus every gap's minimum skip).
582 * @details A jump-free pattern's minimum span is just its length. With gaps the shortest possible match still
583 * consumes each gap's lower bound, so this is the true minimum window a match needs.
584 */
585 31 [[nodiscard]] constexpr std::size_t min_match_length(const PatternBuffer &buffer) noexcept
586 {
587 31 std::size_t total = buffer.length;
588
2/2
✓ Branch 5 → 3 taken 53 times.
✓ Branch 5 → 6 taken 31 times.
84 for (std::size_t i = 0; i < buffer.jump_count; ++i)
589 {
590 53 total += buffer.jumps[i].min_skip;
591 }
592 31 return total;
593 }
594
595 /**
596 * @brief The fewest physical bytes from the offset-applied result point through the end of any match.
597 * @details Gaps strictly after the fixed-stream marker contribute their minimum skip. A gap at the marker sits
598 * before the following segment's first byte, so it does not extend the suffix from that point.
599 */
600 42 [[nodiscard]] constexpr std::size_t min_match_suffix_length(const PatternBuffer &buffer) noexcept
601 {
602
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 42 times.
42 if (buffer.offset > buffer.length)
603 {
604 return 0;
605 }
606 42 std::size_t total = buffer.length - buffer.offset;
607
2/2
✓ Branch 10 → 5 taken 6 times.
✓ Branch 10 → 11 taken 42 times.
48 for (std::size_t i = 0; i < buffer.jump_count; ++i)
608 {
609
2/2
✓ Branch 6 → 7 taken 3 times.
✓ Branch 6 → 9 taken 3 times.
6 if (buffer.jumps[i].position > buffer.offset)
610 {
611 3 total += buffer.jumps[i].min_skip;
612 }
613 }
614 42 return total;
615 }
616
617 /**
618 * @brief The most bytes any match of @p buffer can occupy (fixed bytes plus every gap's maximum skip).
619 * @details The upper bound on a match's span. The page-gated scanner uses it to size the cross-region carry so a
620 * match straddling a protection boundary is still found, and to bound the needle self-exclusion window.
621 */
622 4 [[nodiscard]] constexpr std::size_t max_match_length(const PatternBuffer &buffer) noexcept
623 {
624 4 std::size_t total = buffer.length;
625
2/2
✓ Branch 5 → 3 taken 18 times.
✓ Branch 5 → 6 taken 4 times.
22 for (std::size_t i = 0; i < buffer.jump_count; ++i)
626 {
627 18 total += buffer.jumps[i].max_skip;
628 }
629 4 return total;
630 }
631
632 /**
633 * @brief Masked-compares one fixed segment run against a window at a given position.
634 * @param buffer The compiled pattern.
635 * @param window The candidate byte window.
636 * @param window_pos Offset into @p window at which the run must appear.
637 * @param body_begin First fixed-byte index of the run (inclusive).
638 * @param body_end One-past-last fixed-byte index of the run.
639 * @return True when the run fits in the window from @p window_pos and every masked byte agrees.
640 * @details The per-byte test is the same `(memory ^ pattern) & mask == 0` the scan engine uses, so a wildcard byte
641 * always agrees and a nibble mask compares only its fixed nibble. A run that would read past the window
642 * end cannot match.
643 */
644 131125 [[nodiscard]] constexpr bool run_matches_at(
645 const PatternBuffer &buffer,
646 std::span<const std::byte> window,
647 std::size_t window_pos,
648 std::size_t body_begin,
649 std::size_t body_end
650 ) noexcept
651 {
652 131125 const std::size_t run_length = body_end - body_begin;
653
5/6
✓ Branch 3 → 4 taken 131125 times.
✗ Branch 3 → 6 not taken.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 131124 times.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 131124 times.
131125 if (window_pos > window.size() || run_length > window.size() - window_pos)
654 {
655 1 return false;
656 }
657
2/2
✓ Branch 21 → 11 taken 131147 times.
✓ Branch 21 → 22 taken 556 times.
131703 for (std::size_t i = 0; i < run_length; ++i)
658 {
659 const std::byte masked_diff =
660 262294 (window[window_pos + i] ^ buffer.bytes[body_begin + i]) & buffer.mask[body_begin + i];
661
2/2
✓ Branch 18 → 19 taken 130568 times.
✓ Branch 18 → 20 taken 579 times.
131147 if (masked_diff != std::byte{0x00})
662 {
663 130568 return false;
664 }
665 }
666 556 return true;
667 }
668
669 /**
670 * @brief Backtracking segment match: does segment @p segment_index (and all that follow) match at @p window_pos?
671 * @details Matches the segment's fixed run, then for the gap that follows tries every skip in [min, max] in
672 * ascending order, recursing into the next segment. Ascending-skip order makes the overall match the
673 * leftmost feasible placement. Backtracking is required because a greedy choice for one segment can strand
674 * a later one: an earlier gap position that lets the tail match must be found even if a nearer position
675 * fails. Recursion DEPTH is bounded by the segment count (<= MAX_PATTERN_JUMPS + 1), and total WORK is
676 * bounded by
677 * @p steps, a shared node-visit counter for this one placement tree. On budget exhaustion the placement
678 * fails closed. In practice each segment run fails fast on its first literal byte, so a real signature
679 * (few gaps, literal-anchored segments) prunes to near-linear and never approaches the budget.
680 */
681 131127 [[nodiscard]] constexpr bool try_segments_at(
682 const PatternBuffer &buffer,
683 std::span<const std::byte> window,
684 std::size_t segment_index,
685 std::size_t window_pos,
686 std::size_t &steps
687 ) noexcept
688 {
689
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 131125 times.
131127 if (++steps > SEGMENT_MATCH_STEP_BUDGET)
690 {
691 2 return false;
692 }
693
694
2/2
✓ Branch 4 → 5 taken 131101 times.
✓ Branch 4 → 7 taken 24 times.
131125 const std::size_t segment_begin = (segment_index == 0) ? 0 : buffer.jumps[segment_index - 1].position;
695 const std::size_t segment_end =
696
2/2
✓ Branch 8 → 9 taken 546 times.
✓ Branch 8 → 11 taken 130579 times.
131125 (segment_index < buffer.jump_count) ? buffer.jumps[segment_index].position : buffer.length;
697
2/2
✓ Branch 13 → 14 taken 130569 times.
✓ Branch 13 → 15 taken 556 times.
131125 if (!run_matches_at(buffer, window, window_pos, segment_begin, segment_end))
698 {
699 130569 return false;
700 }
701
2/2
✓ Branch 15 → 16 taken 14 times.
✓ Branch 15 → 17 taken 542 times.
556 if (segment_index == buffer.jump_count)
702 {
703 // The last segment matched, so the whole pattern matched at this start position.
704 14 return true;
705 }
706 542 const std::size_t after = window_pos + (segment_end - segment_begin);
707 542 const PatternJump &gap = buffer.jumps[segment_index];
708 542 const std::size_t available = window.size() - after;
709
2/2
✓ Branch 28 → 20 taken 131103 times.
✓ Branch 28 → 29 taken 513 times.
131616 for (std::size_t skip = gap.min_skip; skip <= gap.max_skip; ++skip)
710 {
711
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 131103 times.
131103 if (skip > available)
712 {
713 break;
714 }
715
2/2
✓ Branch 23 → 24 taken 13 times.
✓ Branch 23 → 25 taken 131090 times.
131103 if (try_segments_at(buffer, window, segment_index + 1, after + skip, steps))
716 {
717 13 return true;
718 }
719
2/2
✓ Branch 25 → 26 taken 16 times.
✓ Branch 25 → 27 taken 131074 times.
131090 if (steps > SEGMENT_MATCH_STEP_BUDGET)
720 {
721 16 return false;
722 }
723 }
724 513 return false;
725 }
726
727 /**
728 * @brief Tests whether @p buffer matches at the start of @p window, honoring any bounded jumps.
729 * @return True when a placement of every segment and gap fits in the window with all masked bytes agreeing.
730 * @details For a jump-free pattern this is exactly the single fixed-width masked compare (one segment covering the
731 * whole length); a pattern with gaps runs the backtracking search (see try_segments_at for its cost
732 * profile). A window shorter than the pattern's minimum span can never match.
733 */
734 [[nodiscard]] constexpr bool
735 26 matches_buffer_at(const PatternBuffer &buffer, std::span<const std::byte> window) noexcept
736 {
737
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 26 times.
26 if (buffer.length == 0)
738 {
739 return false;
740 }
741
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 8 taken 24 times.
26 if (window.size() < min_match_length(buffer))
742 {
743 2 return false;
744 }
745 24 std::size_t steps = 0;
746 24 return try_segments_at(buffer, window, 0, 0, steps);
747 }
748
749 } // namespace DetourModKit::detail
750
751 #endif // DETOURMODKIT_DETAIL_PATTERN_CORE_HPP
752