Compare commits
55
Commits
0dc6a3e6a1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6300bebe33 | ||
|
|
040a51bf03 | ||
|
|
e8c84f0ba1 | ||
|
|
0ddd812829 | ||
|
|
30d5a9a5ec | ||
|
|
af7e3dc1b9 | ||
|
|
678cb00895 | ||
|
|
eda467bc46 | ||
|
|
286bb9cab1 | ||
|
|
a34389fbba | ||
|
|
1911514120 | ||
|
|
ffd72b37ed | ||
|
|
d04428b42e | ||
|
|
64be4f8275 | ||
|
|
c40f3fd791 | ||
|
|
614806f5f1 | ||
|
|
77736698ab | ||
|
|
fa0b5b5013 | ||
|
|
44a368de9c | ||
|
|
3f0bea6bd4 | ||
|
|
a0ca7050a2 | ||
|
|
75ff4a2320 | ||
|
|
e2654fff91 | ||
|
|
12b9b6c03e | ||
|
|
184dec86ca | ||
|
|
0380705e76 | ||
|
|
21d2dd0460 | ||
|
|
8c07d8048c | ||
|
|
b06a034072 | ||
|
|
cb437ef9b5 | ||
|
|
6fa12d8667 | ||
|
|
d520ac34f0 | ||
|
|
ea6e024f32 | ||
|
|
3649feae1f | ||
|
|
84a3a3eab6 | ||
|
|
2ecdd147a7 | ||
|
|
f39ecf820d | ||
|
|
98c53568d9 | ||
|
|
a09887812b | ||
|
|
7c5e72e0e0 | ||
|
|
f7236eea9b | ||
|
|
855595837f | ||
|
|
68255f907c | ||
|
|
6aaee0adce | ||
|
|
9f7848ffde | ||
|
|
991387198d | ||
|
|
8374650aac | ||
|
|
6eb78e7974 | ||
|
|
8219ac95a6 | ||
|
|
b2a5090533 | ||
|
|
6b34e0fab8 | ||
|
|
ccde012e45 | ||
|
|
302da5f3c2 | ||
|
|
eea175499d | ||
|
|
4405af15fd |
@@ -0,0 +1,48 @@
|
||||
# caveman
|
||||
|
||||
Talk like smart caveman. Same brain, fewer tokens.
|
||||
|
||||
## What it does
|
||||
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
|
||||
Six intensity levels:
|
||||
|
||||
| Level | What change |
|
||||
|-------|-------------|
|
||||
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
|
||||
| `full` | Default. Drop articles, fragments OK, short synonyms. |
|
||||
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
|
||||
| `wenyan-lite` | Classical Chinese register, light compression. |
|
||||
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
|
||||
| `wenyan-ultra` | Extreme classical compression. |
|
||||
|
||||
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
|
||||
|
||||
## How to invoke
|
||||
|
||||
```
|
||||
/caveman # full mode (default)
|
||||
/caveman lite # lighter compression
|
||||
/caveman ultra # extreme compression
|
||||
/caveman wenyan # classical Chinese
|
||||
stop caveman # back to normal prose
|
||||
```
|
||||
|
||||
## Example output
|
||||
|
||||
Question: "Why does my React component re-render?"
|
||||
|
||||
Normal prose:
|
||||
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
|
||||
|
||||
Caveman (full):
|
||||
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
|
||||
|
||||
Caveman (ultra):
|
||||
> Inline obj prop → new ref → re-render. `useMemo`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
|
||||
- [Caveman README](../../README.md) — repo overview, install, benchmarks
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman when:
|
||||
- Security warnings
|
||||
- Irreversible action confirmations
|
||||
- Multi-step sequences where fragment order or omitted conjunctions risk misread
|
||||
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
|
||||
- User asks to clarify or repeats question
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: fuck-slop
|
||||
description: >
|
||||
De-slop pass for any text: detects and erases the statistical fingerprints of
|
||||
AI writing (negative parallelism / "not X but Y", em-dash abuse, rule-of-three,
|
||||
false ranges, puffery vocabulary, uniform cadence, hedged both-sidesing) and
|
||||
rewrites the text into its target register — academic article, tweet, reddit
|
||||
post, email, blog, anything between. Use when the user says "fuck slop",
|
||||
"f*ck slop", "deslop", "de-slop this", "remove the AI tells", "humanize this",
|
||||
"make this not sound like AI", or invokes /fuck-slop. Also use before
|
||||
publishing any agent-drafted prose.
|
||||
---
|
||||
|
||||
# F*ck Slop
|
||||
|
||||
Strip every mark of AI writing from a text and make it good in its genre. Not "make it pass a detector" — make it read like a specific person with a specific point wrote it for a specific audience.
|
||||
|
||||
## Why this is a loop, not a style guide
|
||||
|
||||
The worst tells — above all the **"not X but Y"** family — are not vocabulary mistakes. They are emergent properties of how LLMs generate text: preference tuning rewards balanced, contrastive, comprehensive-sounding framing, so the contrast move is baked into the model's priors. Two consequences drive this skill's architecture:
|
||||
|
||||
1. **You cannot reliably see your own slop.** The same priors that produce the pattern make it invisible on re-read. Detection must be mechanical — regex against a fixed catalog — never "does this look AI to me?"
|
||||
2. **Rewriting reintroduces slop.** Ask a model to remove "it's not just X, it's Y" and it produces "this is less about X than Y" — the same move in a wig. So every rewrite gets re-scanned, and the loop runs until the scan is clean.
|
||||
|
||||
Workflow: **Scan → Diagnose → Rewrite by meaning → Re-scan → (repeat) → Register check.**
|
||||
|
||||
## Phase 0: Fix the target
|
||||
|
||||
Before touching the text, establish:
|
||||
|
||||
- **Genre and venue** — academic article, tweet, reddit post, LinkedIn, email, blog, docs, marketing. If not stated and not obvious from the text, ask. Genre decides which tells are fatal and what "good" means; see [references/voices.md](references/voices.md).
|
||||
- **Audience and stance** — who reads it, and what the author actually claims. Slop is what fills the space where a claim should be; you cannot remove it without knowing the claim.
|
||||
- **Constraints** — length limits, required citations, house style.
|
||||
|
||||
## Phase 1: Mechanical scan
|
||||
|
||||
Run the detection patterns from [references/tells.md](references/tells.md) against the text. If the text is in a file (or you can write it to a temp file), run the grep commands in that reference literally — the catalog is written as runnable `grep -Ein` patterns. Otherwise apply each pattern by hand, line by line.
|
||||
|
||||
Produce a finding list: line/sentence, matched pattern, tell category. Also run the two structural checks that regex can't fully catch:
|
||||
|
||||
- **Cadence**: flag any run of 3+ consecutive sentences within ±4 words of the same length, and any paragraph where every sentence has the same shape (subject–verb–elaboration).
|
||||
- **Formatting**: bold scattered through prose, emoji-decorated headers or bullets, "**Term:** definition" bullet lists, headers on a text too short to need them, a tidy intro–three-points–conclusion skeleton.
|
||||
|
||||
Report the findings to the user as a short table before rewriting (category, count, worst example). This is the diagnosis; the user should see what was wrong.
|
||||
|
||||
## Phase 2: Rewrite by meaning, not by frame
|
||||
|
||||
Go finding by finding. The cardinal rule: **never fix a pattern by paraphrasing the pattern.** Fix it by deciding what the sentence actually asserts, then asserting that.
|
||||
|
||||
### The "not X but Y" family — three-way triage
|
||||
|
||||
Every negative parallelism gets exactly one of these treatments:
|
||||
|
||||
1. **The negation is a strawman** (nobody believes X). Delete the X half entirely and assert Y directly, with whatever evidence the text has.
|
||||
- *"It's not just a tool, it's a fundamental shift in how teams work"* → *"Teams that adopted it stopped holding standups within a month."*
|
||||
2. **The contrast is real** (people genuinely hold X). Then earn it: name who holds X, say concretely why Y beats it. A real contrast survives being made specific; slop doesn't.
|
||||
3. **The sentence asserts nothing** (the contrast is decoration on an empty claim). Delete the whole sentence. Most cases are this one.
|
||||
|
||||
Banned escape hatches — these are the same move and count as new findings: "less about X than Y", "X matters, but Y matters more", "the real X is Y", "the question isn't X, it's Y", "X? Y." (rhetorical-question variant), and the em-dash variant "— not X, but Y".
|
||||
|
||||
### Everything else
|
||||
|
||||
- **Puffery and inflated vocabulary** (pivotal, seismic, testament, tapestry, landscape, delve…): replace with the plain word, or with the concrete fact the puffery was hiding. "Plays a vital role in" → "does".
|
||||
- **Rule-of-three lists**: keep the strongest item, cut the rest — unless all three carry distinct information, in which case keep them and break the rhythm (different lengths, different syntax).
|
||||
- **False ranges** ("from X to Y"): if you can't name a meaningful midpoint between X and Y, it's not a range — name the two things or cut one.
|
||||
- **Hedged both-sidesing** ("it's worth noting", auto-counterpoints, "while X, it's also true that Y"): commit. One opinion, stated, owned. A counterpoint stays only if the author genuinely concedes it.
|
||||
- **Uniform cadence**: vary deliberately. Follow a long sentence with a short one. Fragments are legal. Don't apply a formula (alternating long/short is its own tell) — read the paragraph aloud and break wherever the rhythm is metronomic.
|
||||
- **Low specificity**: replace "many companies" / "studies show" / "recent research" with the actual names, numbers, and dates — **only from the source text, the conversation, or verifiable research you actually do**. Never invent specifics. If the author needs to supply one, leave a marked placeholder: `[ADD: which study?]`.
|
||||
- **Stock skeleton**: kill throat-clearing openers ("In today's fast-paced world…"), summary conclusions ("In conclusion… Ultimately…"), and engagement-bait endings ("What do you think?"). Start where the point starts; stop when it's made.
|
||||
|
||||
### What not to do — overcorrection is also slop
|
||||
|
||||
- No fake typos, forced slang, or manufactured "voice". Humanizer-tool output is its own genre of slop.
|
||||
- Em dashes are not banned. Humans use them. The tell is density and the double-dash "— not X, but —" move. Budget: at most one em dash per ~150 words, never two in a sentence.
|
||||
- Don't trade precision for personality in academic or technical text. There, de-slopping means cutting puffery and committing to claims — not adding attitude.
|
||||
- Preserve the author's meaning, claims, and facts exactly. This is a style pass, not a content edit. Flag, don't silently fix, anything that looks factually wrong.
|
||||
|
||||
## Phase 3: Verify loop
|
||||
|
||||
Re-run the full Phase 1 scan **on your rewritten text**. This step is not optional and not a formality — expect your own rewrite to contain new tells, because the model writing it has the same priors that created them. Fix and re-scan until a pass produces zero pattern hits and the cadence check passes. Cap at 4 passes; if a pattern survives 4 passes, rewrite that sentence from scratch starting from its bare claim ("what fact or opinion is this sentence for?").
|
||||
|
||||
## Phase 4: Register check
|
||||
|
||||
Check the clean text against its genre profile in [references/voices.md](references/voices.md): right length, right formality, right person, genre-specific tells gone (e.g. on reddit: no bold, no bullet essay; in academic prose: no first-person hot takes added). Then the final test — read it aloud. Anywhere you wouldn't say it to the actual audience, rewrite that sentence.
|
||||
|
||||
Deliver: the rewritten text, plus a brief change log (categories fixed, counts, and number of verify passes it took).
|
||||
@@ -0,0 +1,171 @@
|
||||
# AI-Writing Tell Catalog
|
||||
|
||||
Detection patterns for the F*ck Slop scan. Patterns are written for `grep -Ein` (extended regex, case-insensitive, line numbers) so they can be run literally against a file:
|
||||
|
||||
```bash
|
||||
grep -Ein -f /dev/stdin draft.txt <<'PATTERNS'
|
||||
<paste patterns from a section below, one per line>
|
||||
PATTERNS
|
||||
```
|
||||
|
||||
When the text only exists in conversation, apply each pattern by hand. A match is a *finding*, not an automatic deletion — every finding goes through the Phase 2 triage in SKILL.md. Density matters: one em dash is nothing; one em dash plus a negative parallelism plus "delve" in the same paragraph is a verdict.
|
||||
|
||||
## 1. Negative parallelism — the "not X but Y" family
|
||||
|
||||
The highest-priority category. LLMs reach for the negation-then-assertion move roughly once a paragraph; humans use it occasionally and deliberately. It is an emergent generative habit, so expect it to reappear in paraphrased form after every rewrite pass — that is why the scan loops.
|
||||
|
||||
```
|
||||
not (just|only|merely|simply|solely) [^.;]{2,80}(but|it'?s| — )
|
||||
isn'?t (just|only|merely|simply|about)
|
||||
it'?s not (a|an|the|that|about|just) [^.;]{2,80}(it'?s|but)
|
||||
(is|was|are|were)n'?t about [^.;]{2,60}\. (it|this|that)'?s about
|
||||
less about [^.;]{2,60}(than|and more about)
|
||||
more than (just|a mere|simply)
|
||||
not because [^.;]{2,80}but because
|
||||
the (question|point|issue|problem|goal|real [a-z]+) is(n'?t| not) (whether|about|just|if)
|
||||
(doesn'?t|don'?t|didn'?t|won'?t) (just|merely|simply) [^.;]{2,80}(it|they|he|she|we)
|
||||
no [a-z]+, no [a-z]+(, no [a-z]+)?[,.]? just
|
||||
— not [^—.;]{2,60}, but
|
||||
not only [^.;]{2,80}but (also )?
|
||||
we'?re not (just )?(talking about|looking at|dealing with)
|
||||
gone are the days
|
||||
(here|this)'?s the (thing|kicker|catch|twist)
|
||||
```
|
||||
|
||||
Rhetorical-question variant (regex-resistant; check by hand): a one-line question immediately answered by a one-word or one-clause sentence. *"The result? Chaos."* / *"Sound familiar?"*
|
||||
|
||||
## 2. Puffery and inflated vocabulary
|
||||
|
||||
Single words that spike in LLM output. Each is fine in isolation; two or more per page is a finding. The fix is the plain word or the concrete fact the word was hiding.
|
||||
|
||||
```
|
||||
\b(delve|delving)\b
|
||||
\btapestry\b
|
||||
\b(testament|stands as)\b
|
||||
\bseamless(ly)?\b
|
||||
\b(pivotal|paramount|crucial)\b
|
||||
\bunderscore(s|d)?\b
|
||||
\b(landscape|realm|sphere) of\b
|
||||
\bnavigat(e|ing) the\b
|
||||
\bfoster(s|ing)?\b
|
||||
\bleverage(s|d)?\b
|
||||
\bmeticulous(ly)?\b
|
||||
\bintricate\b
|
||||
\bboasts\b
|
||||
\bgame.?chang(er|ing)\b
|
||||
\b(seismic|monumental|transformative) (shift|change)\b
|
||||
\bunwavering\b
|
||||
\bcommendable\b
|
||||
\belevate(s|d)? (the|your)\b
|
||||
\bshowcas(e|es|ing)\b
|
||||
\bresonate(s|d)?\b
|
||||
\bcompelling\b
|
||||
\brich (cultural )?(heritage|history|tradition)\b
|
||||
\bvibrant\b
|
||||
\bplays? a (vital|key|crucial|pivotal) role\b
|
||||
\bdeep(er)? dive\b
|
||||
\bunlock(s|ing)? (the|your)\b
|
||||
\bharness(es|ing)? the\b
|
||||
\bembark(s|ed|ing)? on\b
|
||||
\bever.?(evolving|changing)\b
|
||||
\bfast.?paced (world|environment)\b
|
||||
\bin today'?s\b
|
||||
\bat the end of the day\b
|
||||
\bwhen it comes to\b
|
||||
\bcutting.?edge\b
|
||||
\brobust\b
|
||||
\bholistic\b
|
||||
\bsynergy\b
|
||||
\bempower(s|ing|ment)?\b
|
||||
```
|
||||
|
||||
## 3. Hedging, both-sidesing, throat-clearing
|
||||
|
||||
The tell is reflexive balance: every claim gets a softener, every opinion gets a counterpoint. Commit or cut.
|
||||
|
||||
```
|
||||
it'?s (worth|important) (to note|noting|to remember|to consider)
|
||||
(that|it) (being )?said,
|
||||
while (it'?s|this is) (true|important)
|
||||
arguably
|
||||
in many ways
|
||||
to some (extent|degree)
|
||||
on the other hand
|
||||
at its core
|
||||
in essence
|
||||
essentially,
|
||||
ultimately,
|
||||
in conclusion
|
||||
in summary
|
||||
to sum(marize| up)
|
||||
overall,
|
||||
in the end,
|
||||
needless to say
|
||||
as (we|you) (can see|know|all know)
|
||||
let'?s (dive|unpack|explore|take a (look|closer look))
|
||||
whether you('re| are) [^.;]{2,60} or
|
||||
```
|
||||
|
||||
## 4. False ranges and rule-of-three
|
||||
|
||||
**False range** — a "from X to Y" with no actual spectrum between X and Y:
|
||||
|
||||
```
|
||||
from [^.;]{3,50} to [^.;]{3,50}
|
||||
```
|
||||
|
||||
Triage by hand: if you can name a meaningful midpoint, it's a real range and stays. If X and Y are just two loosely related examples, name them plainly or cut one.
|
||||
|
||||
**Rule of three** — LLMs default to triplets to make thin analysis look thorough. Regex only catches the simplest shape; check lists by hand too.
|
||||
|
||||
```
|
||||
\b\w+, \w+, and \w+[.!?]
|
||||
\b(\w+ \w+), (\w+ \w+), and (\w+ \w+)
|
||||
```
|
||||
|
||||
Triage: keep the strongest item, cut the rest — or keep all three only if each carries distinct information, and then break the rhythm.
|
||||
|
||||
## 5. Punctuation and formatting
|
||||
|
||||
Em dash: not banned — humans use it. Findings are about **density** and the contrast move:
|
||||
|
||||
- More than ~1 em dash per 150 words.
|
||||
- Two em dashes in one sentence.
|
||||
- `— not X, but Y` (already in section 1).
|
||||
- Em dash used for punchy emphasis where a comma works: `[a-z] — [a-z][^—]{1,25}\.$`
|
||||
|
||||
Other formatting tells (check by hand; most regexes here are layout-dependent):
|
||||
|
||||
- **Bold scattered through prose** like a textbook highlighting itself: `\*\*[^*]{2,40}\*\*` appearing more than ~once per 3 paragraphs of body prose.
|
||||
- **"Term: definition" bullets**: `^[-*] +\*\*[^*]+:?\*\*:? ` — the signature LLM list shape.
|
||||
- **Emoji headers/bullets** (🚀, ✅, 💡): needs PCRE, not `-E` — `LC_ALL=C.UTF-8 grep -Pn '^\s*[-*#]+\s.*[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]' draft.txt`.
|
||||
- **Headers on short texts** — section headers on anything under ~400 words.
|
||||
- **The tidy skeleton** — intro that previews three points, three matched sections, conclusion that restates them. Resolves too neatly; real writing has loose ends.
|
||||
- **Numbered lists where a paragraph would do.**
|
||||
- Curly quotes/apostrophes in a context where the author types straight ones (mixed within one text is the stronger tell).
|
||||
|
||||
## 6. Cadence and statistical shape
|
||||
|
||||
No regex; measure or eyeball.
|
||||
|
||||
- **Uniform sentence length** (the single strongest current tell): a run of 3+ consecutive sentences within ±4 words of each other, paragraph after paragraph of 18–24-word sentences. Quick measurement on a file:
|
||||
|
||||
```bash
|
||||
tr '\n' ' ' < draft.txt | sed 's/[.!?] /\n/g' | awk '{print NF}'
|
||||
```
|
||||
|
||||
Human prose mixes 4-word sentences with 30-word sentences. Variance should be obvious at a glance.
|
||||
- **Uniform sentence shape**: every sentence opens subject-first; no fragments, no questions, no inversions.
|
||||
- **Uniform paragraph length**: every paragraph 3–4 sentences.
|
||||
- **Low specificity**: "many companies", "studies show", "experts agree", "recent research", "various factors" — generic where a human who knew the material would name names, numbers, dates. (Fix only with real specifics; never invented ones.)
|
||||
- **No friction**: nothing colloquial, no aside, no opinion held without a softener, nothing that risks being disagreed with.
|
||||
|
||||
## 7. Genre-specific instant tells
|
||||
|
||||
Covered in detail in [voices.md](voices.md); the headline items:
|
||||
|
||||
- **Reddit/forums**: bold mid-comment, bullet-pointed comments, "Hope this helps!", perfectly balanced takes.
|
||||
- **Tweets/X**: "🧵", "Let that sink in", line-broken one-clause-per-line cadence, ending on a question to drive engagement.
|
||||
- **LinkedIn**: one-sentence paragraphs stacked vertically, "Agree?", the not-X-but-Y move (its natural habitat).
|
||||
- **Academic**: "delve", "novel insights", puffed significance claims ("crucial implications for the field"), citation-free superlatives.
|
||||
- **Email**: "I hope this email finds you well", restating the recipient's question back at them, three-paragraph symmetry for a one-line answer.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Register Guide
|
||||
|
||||
What "good" means per genre, what tells are fatal there, and what the de-slopped text should sound like. Use in Phase 0 (fix the target) and Phase 4 (register check). Two universal rules first:
|
||||
|
||||
1. **Voice comes from commitment, not decoration.** A text sounds human when it asserts specific things a specific person believes, at the level of detail only someone who did the work would know. Slang, typos, and "personality" sprinkled on top do not produce this and read as humanizer-tool output.
|
||||
2. **Match the author, not a persona.** If the user supplied earlier writing or a draft with their own phrasing in it, keep their words wherever they survive the scan. De-slopping someone into a generic "casual" voice is just different slop.
|
||||
|
||||
## Academic article / paper
|
||||
|
||||
- **Goal**: precise claims, honest hedges, dense information. Formality stays; puffery goes.
|
||||
- **Fatal tells here**: "delve", "novel", inflated significance ("crucial implications", "paradigm shift"), rule-of-three in abstracts, negative parallelism in intros ("X is not merely a tool but a fundamental…"), em-dash chains.
|
||||
- **De-slop moves**: replace significance puffery with the actual finding and effect size. Hedges must be calibrated, not reflexive — "may" because the evidence is genuinely uncertain, not as seasoning. Keep passive voice where the venue expects it; do not inject first person or attitude. Numbers, conditions, and citations beat adjectives.
|
||||
- **Cadence**: long sentences are fine and normal; the tell is uniformity, not length. Vary clause structure.
|
||||
|
||||
## Tweet / X post
|
||||
|
||||
- **Goal**: one idea, said like a person, under the limit.
|
||||
- **Fatal tells here**: "🧵", "Let that sink in", "Read that again", one-clause-per-line stacking, ending on an engagement question, hashtag clusters, the not-X-but-Y move compressed into 200 characters.
|
||||
- **De-slop moves**: cut to the single claim. Lowercase is fine if that's the author's habit. No setup ("Hot take:") — just the take. A tweet that states an opinion without insurance reads human; a tweet that balances itself does not.
|
||||
|
||||
## Reddit post / comment
|
||||
|
||||
- **Goal**: reads like a knowledgeable person typing in a text box, because that's what reddit is.
|
||||
- **Fatal tells here** (reddit users are the most slop-sensitive audience on the internet): **any** bold in a comment, bullet-point essays, headers, "Hope this helps!", "Great question!", symmetric pro/con framing, em-dash density, perfect paragraphing.
|
||||
- **De-slop moves**: plain paragraphs, contractions, direct answers first. Mild hedges are human here ("iirc", "I might be wrong but") — but only the author's own. Concrete personal detail ("ran into this on a 2019 Outback") is the strongest human marker; never fabricate it, ask the author or drop it.
|
||||
|
||||
## LinkedIn post
|
||||
|
||||
- **Goal**: professional but specific. The platform's native style is so slop-adjacent that the bar is: would a colleague forward this without cringing?
|
||||
- **Fatal tells here**: stacked one-line paragraphs, "Agree?", "Let's connect", broetry rhythm, negative parallelism (this is its natural habitat — scan twice), rule-of-three value statements, "I'm humbled to announce".
|
||||
- **De-slop moves**: write actual paragraphs. Lead with the concrete event or number, not the lesson. One lesson max, stated once, not echoed in a closer.
|
||||
|
||||
## Email
|
||||
|
||||
- **Goal**: shortest text that's still warm enough for the relationship.
|
||||
- **Fatal tells here**: "I hope this email finds you well", restating the recipient's email back to them, three symmetric paragraphs wrapping a one-line answer, "Please don't hesitate to reach out".
|
||||
- **De-slop moves**: answer in the first sentence. Greeting and sign-off match the existing thread's register. Cut every sentence whose only job is politeness padding except one, if the relationship needs it.
|
||||
|
||||
## Blog post / newsletter / essay
|
||||
|
||||
- **Goal**: a person with a view, walking the reader through it.
|
||||
- **Fatal tells here**: "In today's fast-paced world" openers, intro-that-previews-three-sections skeleton, "In conclusion", bold-scattered prose, section headers every two paragraphs, engagement-bait closers.
|
||||
- **De-slop moves**: open inside the subject (a scene, a number, a claim). Let structure follow the argument instead of a template — real essays have asymmetric sections and loose ends. First person and digressions are allowed; they are how essays sound human. Keep headers only when the piece is long enough to need navigation.
|
||||
|
||||
## Marketing / landing copy
|
||||
|
||||
- **Goal**: concrete benefit, named audience, zero filler.
|
||||
- **Fatal tells here**: "seamless", "unlock", "empower", "game-changing", "effortless", rule-of-three feature triplets, false ranges ("from startups to enterprises"), every header a not-X-but-Y.
|
||||
- **De-slop moves**: replace each abstraction with the mechanism or the number ("Set up in 4 minutes" beats "seamless onboarding"). One verb per claim. Specificity is the whole game; if no specifics exist, that's a product-marketing problem the text can't fix — say so.
|
||||
|
||||
## Technical docs / README
|
||||
|
||||
- **Goal**: the reader gets unblocked fast.
|
||||
- **Fatal tells here**: "robust", "powerful", "blazingly fast" without benchmarks, "simply"/"just" before steps that aren't, marketing voice in reference material, emoji section headers.
|
||||
- **De-slop moves**: imperative mood, exact commands, exact versions, expected output. Adjectives almost to zero. Lists are fine here — docs are the one genre where "Term: definition" bullets are legitimate structure, so don't strip them; strip the puffery inside them.
|
||||
|
||||
## Academic-adjacent: cover letters, statements, grant prose
|
||||
|
||||
- **Goal**: claims about the author backed by evidence, in formal register.
|
||||
- **Fatal tells here**: "passionate", "deeply committed", "unique perspective", testament/tapestry vocabulary, rule-of-three trait lists, every paragraph ending with a not-X-but-Y synthesis.
|
||||
- **De-slop moves**: every trait claim becomes an event ("I led X, which produced Y"). Keep formality; cut self-puffery. The reader has read ten thousand of these — only specifics differentiate.
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Calibrated grilling session for stress-testing a plan, design, idea, or decision. First assesses the user's topic knowledge, confidence, and desired pressure level, then asks one question at a time with recommended answers. Use when user says "grill me", "stress-test this", "challenge my plan", "interview me", or wants a plan probed without being overwhelmed.
|
||||
---
|
||||
|
||||
# Grill Me
|
||||
|
||||
Interview the user until the plan is clear, defensible, and ready for action.
|
||||
|
||||
This is not hostile debate. It is calibrated pressure. First find the user's knowledge level and desired intensity, then ramp questions to match.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Ask one question at a time.
|
||||
- Give a recommended answer for every question.
|
||||
- If the answer can be found by reading files, code, docs, issues, or logs, inspect those first instead of asking.
|
||||
- Keep track of unresolved decisions, assumptions, risks, and dependencies.
|
||||
- Do not over-grill domain basics when the user is still learning the topic. Teach the missing frame briefly, then ask the next useful question.
|
||||
- Do not under-grill confident experts. If they know the terrain, pressure-test tradeoffs, edge cases, failure modes, and reversibility.
|
||||
- Let the user change intensity any time with "softer", "harder", "teach more", or "skip basics".
|
||||
|
||||
## Phase 1: Frame The Target
|
||||
|
||||
Identify what should be grilled before asking about comfort. If the topic is not clear, ask:
|
||||
|
||||
> What plan, design, or decision should I grill?
|
||||
>
|
||||
> Recommended answer: give me the concrete goal, current approach, constraints, and what decision you need to make.
|
||||
|
||||
If context already contains the plan, summarize it in 3-6 bullets and ask for correction:
|
||||
|
||||
> I think target is: [...]
|
||||
>
|
||||
> Recommended answer: "Yes, grill that" or "Adjust: ..."
|
||||
|
||||
## Phase 2: Calibration
|
||||
|
||||
Before grilling the topic, ask a short calibration question unless the user's level is already obvious from context.
|
||||
|
||||
Ask:
|
||||
|
||||
> Before I grill the plan: what is your current comfort with this topic, and how hard do you want the pressure?
|
||||
>
|
||||
> Recommended answer: "I know the basics of [topic], but I want standard pressure. Explain missing concepts briefly, then keep pushing."
|
||||
|
||||
Use the user's answer to set two dials:
|
||||
|
||||
### Knowledge Level
|
||||
|
||||
- **New** - user lacks core vocabulary or model of the domain.
|
||||
- **Working** - user understands basics and can discuss tradeoffs.
|
||||
- **Expert** - user knows domain deeply and wants sharper critique.
|
||||
|
||||
### Pressure Level
|
||||
|
||||
- **Light** - clarify goals, constraints, and missing context.
|
||||
- **Standard** - challenge assumptions, tradeoffs, and execution path.
|
||||
- **Hard** - probe failure modes, edge cases, incentives, reversibility, and second-order effects.
|
||||
|
||||
If the user does not answer calibration, default to:
|
||||
|
||||
- Knowledge: **Working**
|
||||
- Pressure: **Standard**
|
||||
|
||||
## Phase 3: Build The Decision Map
|
||||
|
||||
Create a private decision map while asking questions one at a time:
|
||||
|
||||
- Goal - what success means.
|
||||
- User or customer - who this affects.
|
||||
- Constraints - time, money, stack, team, policy, risk.
|
||||
- Options - obvious alternatives and why current option wins.
|
||||
- Dependencies - what must be true first.
|
||||
- Risks - what breaks, gets expensive, or becomes irreversible.
|
||||
- Validation - how user will know it worked.
|
||||
- Rollback - how to undo or recover.
|
||||
|
||||
Do not dump the full map unless user asks. Use it to choose the next question.
|
||||
|
||||
## Phase 4: Question Ladder
|
||||
|
||||
Move through this ladder. Stop early if the plan becomes clear enough or user asks to stop.
|
||||
|
||||
### 1. Goal Fit
|
||||
|
||||
Questions:
|
||||
|
||||
- What outcome matters most?
|
||||
- What would make this not worth doing?
|
||||
- What problem are we solving, and for whom?
|
||||
|
||||
### 2. Constraint Reality
|
||||
|
||||
Questions:
|
||||
|
||||
- What hard constraint cannot move?
|
||||
- What resource bottleneck decides the plan?
|
||||
- What assumption would kill the plan if false?
|
||||
|
||||
### 3. Option Pressure
|
||||
|
||||
Questions:
|
||||
|
||||
- What are the top two alternatives?
|
||||
- Why this approach over the boring one?
|
||||
- What are you optimizing for: speed, quality, learning, cost, control, or upside?
|
||||
|
||||
### 4. Execution Path
|
||||
|
||||
Questions:
|
||||
|
||||
- What is the smallest useful version?
|
||||
- What has to happen first?
|
||||
- What can be deferred without harming the goal?
|
||||
|
||||
### 5. Failure Modes
|
||||
|
||||
Questions:
|
||||
|
||||
- How does this fail in production or real use?
|
||||
- What edge case would embarrass the plan?
|
||||
- What part is hardest to observe once it breaks?
|
||||
|
||||
### 6. Validation
|
||||
|
||||
Questions:
|
||||
|
||||
- What test, metric, screenshot, demo, or user behavior proves this works?
|
||||
- What would you check before trusting it?
|
||||
- What does done mean in observable terms?
|
||||
|
||||
### 7. Reversibility
|
||||
|
||||
Questions:
|
||||
|
||||
- What decision here is hardest to undo?
|
||||
- What backup, migration, rollback, or escape hatch exists?
|
||||
- What should be logged as an ADR or explicit tradeoff?
|
||||
|
||||
## Pressure Adaptation
|
||||
|
||||
### If Knowledge Is New
|
||||
|
||||
- Define one missing concept in 2-4 sentences before asking.
|
||||
- Avoid jargon unless you define it.
|
||||
- Ask fewer branching questions.
|
||||
- Focus on goals, constraints, and first principles.
|
||||
- Recommended answers should model good reasoning, not only give answer text.
|
||||
|
||||
### If Knowledge Is Working
|
||||
|
||||
- Ask normal tradeoff questions.
|
||||
- Surface alternatives.
|
||||
- Push for validation and smallest useful version.
|
||||
- Challenge vague words like "simple", "scalable", "good", "clean", or "fast".
|
||||
|
||||
### If Knowledge Is Expert
|
||||
|
||||
- Skip basics.
|
||||
- Ask sharper counterfactuals.
|
||||
- Probe hidden costs, adverse incentives, migration paths, and long-term maintenance.
|
||||
- Ask what evidence would change their mind.
|
||||
|
||||
### If Pressure Is Light
|
||||
|
||||
- Keep questions clarifying.
|
||||
- Use supportive framing.
|
||||
- Stop after top ambiguities are resolved.
|
||||
|
||||
### If Pressure Is Standard
|
||||
|
||||
- Challenge assumptions and tradeoffs.
|
||||
- Keep moving until implementation path is concrete.
|
||||
|
||||
### If Pressure Is Hard
|
||||
|
||||
- Be direct.
|
||||
- Name weak reasoning.
|
||||
- Ask about unpleasant edge cases.
|
||||
- Demand observable validation.
|
||||
- Still ask one question at a time.
|
||||
|
||||
## Recommended Answer Format
|
||||
|
||||
Every question includes:
|
||||
|
||||
```text
|
||||
Question: ...
|
||||
Recommended answer: ...
|
||||
Why it matters: ...
|
||||
```
|
||||
|
||||
Keep "Why it matters" to one sentence.
|
||||
|
||||
## When To Stop
|
||||
|
||||
Stop grilling when one of these is true:
|
||||
|
||||
- User says stop.
|
||||
- Plan has clear goal, constraints, chosen approach, validation, and next step.
|
||||
- Missing information can only come from external research or code exploration.
|
||||
- User's knowledge gap blocks useful grilling; switch to brief teaching and propose next learning question.
|
||||
|
||||
End with:
|
||||
|
||||
- Final decision or current best plan.
|
||||
- Remaining open questions.
|
||||
- Next concrete action.
|
||||
- Risks to watch.
|
||||
@@ -0,0 +1,582 @@
|
||||
---
|
||||
name: interface-kit
|
||||
description: |
|
||||
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
|
||||
design engineering philosophy, accessibility standards, animation principles, spatial design,
|
||||
typography, color systems, and component craft into a single actionable reference.
|
||||
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
|
||||
the HOW of implementation.
|
||||
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
|
||||
"frontend", "design", "polish UI", "implement design", "make it beautiful",
|
||||
"UI implementation", "component styling", "animation", "accessibility"
|
||||
---
|
||||
|
||||
# Interface Kit: Implementation Guide for Exceptional Interfaces
|
||||
|
||||
> If a DESIGN.md exists at the project root, its tokens and specifications override all defaults in this skill. This skill provides sensible defaults for when no design system exists, and implementation guidance that applies regardless.
|
||||
|
||||
> For deep dives on any section, see the reference files in this skill's `references/` directory.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Philosophy
|
||||
|
||||
Taste is trained, not innate. Study why great interfaces feel right. Deconstruct apps you admire — the spacing, the timing, the weight of a shadow. The gap between "fine" and "exceptional" is built from hundreds of micro-decisions that users feel but never consciously notice.
|
||||
|
||||
**Unseen details compound.** A single rounded corner, a single eased transition, a single well-chosen shadow — none of these matter alone. Together they become "a thousand barely audible voices singing in tune." The cumulative effect is what separates craft from output.
|
||||
|
||||
**Beauty is leverage.** Polish is not vanity. Good defaults, considered typography, and intentional motion are real differentiators. Users trust interfaces that feel cared for. Investors notice. Competitors can't easily replicate taste.
|
||||
|
||||
**Intentionality over intensity.** Both bold maximalism and refined minimalism work — what fails is the absence of a clear point of view. Every visual decision should trace back to a deliberate conceptual direction. If you can't articulate WHY a choice was made, reconsider it.
|
||||
|
||||
**Choose a direction and execute with precision.** Don't hedge between styles. A brutalist page committed fully will always outperform a page that's "a little bit of everything." Commit, then refine.
|
||||
|
||||
**NEVER produce generic "AI slop" aesthetics.** No gratuitous gradients on white backgrounds. No cookie-cutter hero sections with stock illustrations. No safe, forgettable layouts that could belong to any product. Every interface should have a point of view that makes it recognizable.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Priority Stack
|
||||
|
||||
When implementing UI, work through these priorities in order. Higher priorities are non-negotiable; lower priorities are polish that compounds quality.
|
||||
|
||||
| Priority | Level | What It Means |
|
||||
|----------|-------|---------------|
|
||||
| **Accessibility** | CRITICAL | Contrast 4.5:1, keyboard nav, ARIA semantics, visible focus rings. Ship nothing that excludes users. |
|
||||
| **Performance** | HIGH | WebP/AVIF images, lazy loading below fold, CLS < 0.1, transform-only animations on the compositor thread. |
|
||||
| **Typography** | HIGH | Font smoothing, text-wrap balance/pretty, tabular-nums for data, 65ch max line length. |
|
||||
| **Layout & Spatial** | HIGH | 4/8px grid, concentric border radius, optical alignment over geometric. |
|
||||
| **Color & Theme** | MEDIUM | HSL custom properties, semantic tokens, dark mode pairs tested separately. |
|
||||
| **Motion & Interaction** | MEDIUM | Frequency-based animation decisions, 150-300ms durations, ease-out default. |
|
||||
| **Polish & Details** | LOW | Layered shadows over borders, press feedback on buttons, staggered enter animations. |
|
||||
|
||||
Never skip a CRITICAL/HIGH item to chase a LOW item. A beautifully animated button that fails keyboard navigation is a net negative.
|
||||
|
||||
---
|
||||
|
||||
## 3. Aesthetic Direction
|
||||
|
||||
Before writing a single line of CSS, commit to a bold aesthetic direction. The most common failure mode in AI-generated UI is convergence on the same safe, forgettable look.
|
||||
|
||||
### Pick a Tone
|
||||
|
||||
Choose one and commit fully:
|
||||
|
||||
- **Brutally minimal** — generous whitespace, monospace type, stark contrast, near-zero decoration
|
||||
- **Maximalist chaos** — layered textures, clashing type scales, dense information, intentional visual noise
|
||||
- **Retro-futuristic** — CRT glow effects, monospace terminals, scan lines, neon on dark
|
||||
- **Organic / natural** — earth tones, rounded shapes, paper textures, hand-drawn accents
|
||||
- **Luxury / refined** — serif headlines, muted palettes, ample negative space, subtle gold or cream accents
|
||||
- **Editorial / magazine** — dramatic type hierarchy, full-bleed imagery, grid-breaking layouts
|
||||
- **Playful / bold** — bright primaries, chunky borders, exaggerated shadows, bouncy motion
|
||||
|
||||
### Match Complexity to Vision
|
||||
|
||||
Maximalist design demands elaborate code — layered backgrounds, complex grid structures, multiple font stacks. Minimalist design demands surgical precision — every pixel of spacing matters more when there's nothing to hide behind.
|
||||
|
||||
### The Ban List (When No DESIGN.md Exists)
|
||||
|
||||
When building without an existing design system, avoid these overused defaults that signal "AI-generated":
|
||||
|
||||
- **Fonts**: Inter, Roboto, Arial, system-ui as display fonts, Space Grotesk
|
||||
- **Colors**: Purple-to-blue gradients on white backgrounds
|
||||
- **Patterns**: Generic hero with centered text + CTA + stock illustration
|
||||
|
||||
Vary between light and dark themes, different font pairings, different aesthetic directions. Never converge on the same choices across projects.
|
||||
|
||||
### Visual Texture
|
||||
|
||||
Add depth through: gradient meshes, noise/grain overlays (`filter: url(#noise)`), layered transparencies, subtle background patterns, duotone image treatments.
|
||||
|
||||
**DESIGN.md overrides this entire section.** If DESIGN.md specifies Inter, use Inter. If it specifies purple gradients, use them. The ban list only applies when no design system exists and you're making aesthetic choices from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography Essentials
|
||||
|
||||
Typography is the single highest-leverage design element. Get it right and mediocre layouts still feel good. Get it wrong and nothing else saves it.
|
||||
|
||||
### Root Setup
|
||||
|
||||
```css
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
```
|
||||
|
||||
Apply font smoothing to the root layout. On macOS, the default sub-pixel rendering makes text appear heavier than the designer intended.
|
||||
|
||||
### Text Wrapping
|
||||
|
||||
```css
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p, li, dd, blockquote {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
```
|
||||
|
||||
`balance` distributes heading lines evenly. `pretty` avoids orphaned words in body text.
|
||||
|
||||
### Numeric Display
|
||||
|
||||
```css
|
||||
.data-value, .price, .counter, [data-numeric] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
```
|
||||
|
||||
Use `tabular-nums` for any number that updates dynamically — prices, counters, table columns. Without it, layout shifts as digit widths change.
|
||||
|
||||
### Scale and Rhythm
|
||||
|
||||
- **Base size**: 16px minimum for body text. Never go below 14px for any readable content.
|
||||
- **Line height**: 1.5-1.75 for body text, 1.1-1.3 for large headings.
|
||||
- **Max line length**: `max-width: 65ch` for body text. Long lines destroy readability.
|
||||
- **Type scale**: Pick a consistent scale and stick to it: 12 / 14 / 16 / 18 / 24 / 32 / 48 / 64.
|
||||
|
||||
### Font Pairing
|
||||
|
||||
Pair a distinctive display font with a refined body font. The display font carries personality; the body font carries readability. Use `font-weight` for hierarchy within a family:
|
||||
|
||||
- **Headings**: 600-700 (semibold to bold)
|
||||
- **Body**: 400 (regular)
|
||||
- **Labels / UI**: 500 (medium)
|
||||
|
||||
Always include font stack fallbacks:
|
||||
|
||||
```css
|
||||
--font-display: "Instrument Serif", "Georgia", serif;
|
||||
--font-body: "Söhne", "Helvetica Neue", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color & Theme
|
||||
|
||||
### HSL Custom Properties (shadcn Pattern)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
Define semantic tokens: primary, secondary, destructive, muted, accent, background, foreground. Reference colors by semantic name — never hardcode hex values in components.
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```css
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
/* ... desaturated, lighter tonal variants — NOT simply inverted */
|
||||
}
|
||||
```
|
||||
|
||||
Dark mode is not "invert colors." Use desaturated, lighter tonal variants. Backgrounds go dark but not pure black (`#000`). Text goes light but not pure white (`#fff`). Test contrast separately for dark mode — what passes in light may fail in dark.
|
||||
|
||||
### Contrast Requirements
|
||||
|
||||
- **WCAG AA minimum**: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular)
|
||||
- Never convey information by color alone — always pair with an icon, label, or pattern
|
||||
- Test with browser devtools contrast checker or axe-core
|
||||
|
||||
### Color Confidence
|
||||
|
||||
Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Pick one or two hero colors and let the rest of the palette recede. A confident palette has clear hierarchy; an uncertain palette spreads color evenly and feels flat.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spatial Design
|
||||
|
||||
### Concentric Border Radius
|
||||
|
||||
This is the single most common thing that makes nested UI elements feel "off":
|
||||
|
||||
```
|
||||
outer_radius = inner_radius + padding
|
||||
```
|
||||
|
||||
```css
|
||||
/* Correct: concentric */
|
||||
.card { border-radius: 16px; padding: 8px; }
|
||||
.card-inner { border-radius: 8px; } /* 16 - 8 = 8 */
|
||||
|
||||
/* Wrong: same radius on parent and child */
|
||||
.card { border-radius: 12px; }
|
||||
.card-inner { border-radius: 12px; } /* Looks bloated */
|
||||
```
|
||||
|
||||
When geometric centering looks off, align optically. Play/pause icons, dropdown carets, and asymmetric glyphs often need 1-2px manual nudges to look centered.
|
||||
|
||||
### Shadows Over Borders
|
||||
|
||||
Layer multiple transparent `box-shadow` values for natural depth instead of using borders:
|
||||
|
||||
```css
|
||||
.elevated {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 4px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
```
|
||||
|
||||
Multiple shadows at different spreads mimic how light works. A single hard shadow looks artificial.
|
||||
|
||||
### Image Outlines
|
||||
|
||||
Add a subtle inset outline to images and media for consistent depth against varied backgrounds:
|
||||
|
||||
```css
|
||||
img, video {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.06);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
Use a 4px / 8px base incremental system. Every spacing value should be a multiple of 4:
|
||||
|
||||
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128`
|
||||
|
||||
### Hit Areas
|
||||
|
||||
Minimum 44x44px for all interactive elements. If the visual element is smaller, extend the hit area with a pseudo-element:
|
||||
|
||||
```css
|
||||
.small-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
}
|
||||
```
|
||||
|
||||
### Z-Index Scale
|
||||
|
||||
Define a layered scale and never use arbitrary values:
|
||||
|
||||
```css
|
||||
--z-base: 0;
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 20;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 100;
|
||||
--z-toast: 1000;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Motion & Interaction
|
||||
|
||||
### The Frequency-Based Decision Framework
|
||||
|
||||
This is the most important mental model for animation decisions:
|
||||
|
||||
| Frequency | Examples | Animation |
|
||||
|-----------|----------|-----------|
|
||||
| **100+ times/day** | Keyboard shortcuts, command palette actions, tab switches | **None.** Zero animation. Instant. |
|
||||
| **Tens of times/day** | Hover effects, list item navigation, toggles | **Remove or drastically reduce.** 50-100ms max. |
|
||||
| **Occasional** | Modals, drawers, toasts, page transitions | **Standard animation.** 150-300ms. |
|
||||
| **Rare / first-time** | Onboarding, celebrations, empty states | **Can add delight.** 300-500ms, more elaborate. |
|
||||
|
||||
High-frequency animations feel sluggish. Low-frequency animations without motion feel jarring. Match the animation budget to usage frequency.
|
||||
|
||||
### Custom Easing Curves
|
||||
|
||||
Built-in CSS easings (`ease`, `ease-in-out`) are too weak. Define custom curves:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Duration Guide
|
||||
|
||||
| Element | Duration |
|
||||
|---------|----------|
|
||||
| Buttons, toggles | 100-160ms |
|
||||
| Tooltips | 125-200ms |
|
||||
| Dropdowns, popovers | 150-250ms |
|
||||
| Modals, drawers | 200-500ms |
|
||||
| Page transitions | 250-400ms |
|
||||
|
||||
UI animations should stay under 300ms. Never use `ease-in` for UI animations — it front-loads the pause and feels sluggish.
|
||||
|
||||
### Enter/Exit Asymmetry
|
||||
|
||||
Exits should be softer and faster than enters. An enter animation at 250ms should have its exit at 150-200ms.
|
||||
|
||||
### Split and Stagger Enter Animations
|
||||
|
||||
When multiple elements enter the viewport, stagger them by semantic chunks with ~50-100ms delay:
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
animation: fadeSlideIn 300ms var(--ease-out) both;
|
||||
}
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 60ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 120ms; }
|
||||
```
|
||||
|
||||
### Scale Animations
|
||||
|
||||
Never animate from `scale(0)`. Start from `scale(0.9)` or higher, combined with opacity:
|
||||
|
||||
```css
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
```
|
||||
|
||||
### Press Feedback
|
||||
|
||||
Every pressable element should scale down slightly on `:active`:
|
||||
|
||||
```css
|
||||
button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
### Interruptibility
|
||||
|
||||
Use CSS transitions (not keyframe animations) for interactive state changes. Transitions can be interrupted mid-way; keyframes cannot. This matters for hover states, toggles, and any element the user might interact with rapidly.
|
||||
|
||||
### Popover Origin
|
||||
|
||||
Make popovers transform-origin aware — they should grow from their trigger element, not from center. Exception: modals always originate from center.
|
||||
|
||||
### Tooltip Hover Delay
|
||||
|
||||
Skip the tooltip delay on subsequent hovers. If the user has already waited for one tooltip, show the next one immediately.
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Respect `prefers-reduced-motion`. Reduce animations — don't eliminate opacity and color transitions entirely, as those provide important feedback.
|
||||
|
||||
### Hover Gate
|
||||
|
||||
Gate hover animations behind a media query so touch devices don't trigger stuck hover states:
|
||||
|
||||
```css
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover { transform: translateY(-2px); }
|
||||
}
|
||||
```
|
||||
|
||||
> Reference `references/animation-playbook.md` for deep dives on spring physics, gesture-driven animation, and complex choreography.
|
||||
|
||||
---
|
||||
|
||||
## 8. Component Craft
|
||||
|
||||
### Primitives
|
||||
|
||||
Use Radix UI primitives for accessible, unstyled foundations. Use CVA (class-variance-authority) for type-safe component variants:
|
||||
|
||||
```tsx
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
default: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8 text-lg",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Button
|
||||
|
||||
- Scale on press (`transform: scale(0.97)` on `:active`)
|
||||
- Visible focus ring (never `outline: none` without replacement)
|
||||
- Loading state with spinner replacing label, maintaining button dimensions
|
||||
- Disabled state at `opacity: 0.5` with `pointer-events: none`
|
||||
|
||||
### Card
|
||||
|
||||
- Concentric border radius between card and inner elements
|
||||
- Layered shadows (not borders) for depth
|
||||
- Hover state: subtle elevation change (`translateY(-1px)` + shadow increase)
|
||||
|
||||
### Dialog / Modal
|
||||
|
||||
- Focus trap (keyboard cannot escape to elements behind)
|
||||
- ESC to close, click outside overlay to close
|
||||
- `transform-origin: center`, fade + scale enter animation
|
||||
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
|
||||
|
||||
### Form
|
||||
|
||||
- Visible labels always — never placeholder-only inputs
|
||||
- Error messages near the field with `aria-live="polite"` for screen readers
|
||||
- Progressive disclosure: show advanced fields only when needed
|
||||
- Use React Hook Form + Zod for validation
|
||||
|
||||
### Theming
|
||||
|
||||
Use shadcn CSS variable pattern (HSL format) for all component colors. Wrap client-interactive components in server components for Next.js App Router compatibility.
|
||||
|
||||
> Reference `references/component-patterns.md` for the full component catalog with copy-paste implementations.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility Essentials
|
||||
|
||||
### Semantic HTML First
|
||||
|
||||
Use `<button>`, `<nav>`, `<main>`, `<header>`, `<footer>`, `<article>`, `<section>` before reaching for ARIA. A `<button>` gives you keyboard handling, focus management, and screen reader semantics for free. A `<div onClick>` gives you none of that.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- **Tab / Shift+Tab**: move between focusable elements
|
||||
- **Enter / Space**: activate buttons and links
|
||||
- **Arrow keys**: navigate within lists, menus, tabs, radio groups
|
||||
- **Escape**: close modals, popovers, dropdowns
|
||||
- **Home / End**: jump to first/last item in lists
|
||||
|
||||
### Focus Management
|
||||
|
||||
- Visible focus rings on all interactive elements — NEVER use `outline: none` without a replacement
|
||||
- Trap focus inside modals (Tab wraps within the modal, not behind it)
|
||||
- Restore focus to the trigger element when a modal/popover closes
|
||||
- Use `focus-visible` to show rings only for keyboard users, not mouse clicks:
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### ARIA Attributes
|
||||
|
||||
- `aria-label` for icon-only buttons: `<button aria-label="Close menu">X</button>`
|
||||
- `aria-labelledby` to associate headings with sections
|
||||
- `aria-describedby` to link help text or error messages to inputs
|
||||
- `aria-live="polite"` for dynamic content updates (toast messages, form errors)
|
||||
- `aria-hidden="true"` for decorative elements (icons next to text labels)
|
||||
- `aria-expanded` for toggleable elements (dropdowns, accordions)
|
||||
|
||||
### Color and Contrast
|
||||
|
||||
- WCAG AA: 4.5:1 for normal text, 3:1 for large text
|
||||
- Never use color as the sole indicator — pair with icons, text, or patterns
|
||||
- Test in both light and dark modes
|
||||
|
||||
### Images and Media
|
||||
|
||||
- Descriptive `alt` text for meaningful images: `alt="Dashboard showing 23% revenue growth"`
|
||||
- Empty `alt=""` for purely decorative images
|
||||
- Captions for video, transcripts for audio
|
||||
|
||||
### Navigation Aids
|
||||
|
||||
- **Skip link**: first focusable element, hidden until focused:
|
||||
|
||||
```html
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only">
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
- **Heading hierarchy**: sequential h1 through h6, no level skips. One `<h1>` per page.
|
||||
|
||||
### Touch Targets
|
||||
|
||||
- Minimum 44x44px interactive area
|
||||
- 8px minimum spacing between adjacent touch targets
|
||||
- Extend small visual elements with invisible padding or pseudo-elements
|
||||
|
||||
### Testing
|
||||
|
||||
- **Automated**: axe-core in CI, Lighthouse accessibility score 90+
|
||||
- **Manual**: full keyboard-only navigation test
|
||||
- **Screen reader**: test with VoiceOver (macOS) or NVDA (Windows)
|
||||
- **Visual**: zoom to 200%, check nothing breaks or overlaps
|
||||
|
||||
> Reference `references/accessibility-checklist.md` for the full audit guide with pass/fail criteria.
|
||||
|
||||
---
|
||||
|
||||
## 10. Pre-Delivery Review
|
||||
|
||||
Run through this checklist before considering any UI implementation complete:
|
||||
|
||||
### Typography
|
||||
- [ ] Font smoothing applied (`-webkit-font-smoothing: antialiased`)
|
||||
- [ ] Headings use `text-wrap: balance`
|
||||
- [ ] Dynamic numbers use `font-variant-numeric: tabular-nums`
|
||||
|
||||
### Color
|
||||
- [ ] All colors referenced via semantic tokens, no hardcoded hex in components
|
||||
- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
|
||||
- [ ] Dark mode tested separately for contrast
|
||||
|
||||
### Spatial
|
||||
- [ ] Nested rounded elements use concentric border radius
|
||||
- [ ] Spacing follows 4px / 8px scale consistently
|
||||
- [ ] Interactive elements have 44x44px minimum hit area
|
||||
- [ ] Shadows used instead of borders where appropriate
|
||||
|
||||
### Motion
|
||||
- [ ] Animation frequency matches usage frequency (no animation on high-frequency actions)
|
||||
- [ ] No `transition: all` anywhere — specific properties only
|
||||
- [ ] Enter animations split and staggered where multiple elements appear
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
|
||||
### Accessibility
|
||||
- [ ] All interactive elements keyboard accessible
|
||||
- [ ] Focus rings visible on keyboard navigation (never `outline: none` without replacement)
|
||||
- [ ] Semantic HTML used before ARIA
|
||||
- [ ] `aria-live` on dynamic content updates
|
||||
|
||||
> Reference `references/review-checklist.md` for the extended 30-item checklist with severity ratings and automated testing commands.
|
||||
@@ -0,0 +1,425 @@
|
||||
# WCAG 2.1 AA Accessibility Audit Guide
|
||||
|
||||
Comprehensive checklist for building accessible web interfaces. Every requirement maps to WCAG 2.1 Level AA success criteria.
|
||||
|
||||
---
|
||||
|
||||
## 1. Semantic HTML Priority
|
||||
|
||||
ALWAYS use semantic HTML before reaching for ARIA. Native elements carry built-in keyboard behavior, focus management, and screen reader announcements that ARIA can only approximate.
|
||||
|
||||
### Element Selection Rules
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<div role="button">` | `<button>` |
|
||||
| `<div role="navigation">` | `<nav>` |
|
||||
| `<div class="header">` | `<header>` |
|
||||
| `<div class="footer">` | `<footer>` |
|
||||
| `<span onClick>` | `<a href>` or `<button>` |
|
||||
| `<div role="list">` | `<ul>` / `<ol>` |
|
||||
| `<div class="table">` | `<table>` with `<thead>`, `<tbody>`, `<th>` |
|
||||
|
||||
### Landmark Elements
|
||||
|
||||
- `<main>` — one per page, wraps primary content
|
||||
- `<nav>` — navigation sections (label with `aria-label` when multiple exist)
|
||||
- `<header>` — introductory content or navigation aids
|
||||
- `<footer>` — footer content, copyright, related links
|
||||
- `<aside>` — tangentially related content (sidebars, callouts)
|
||||
- `<article>` — self-contained composition (blog post, comment, widget)
|
||||
- `<section>` — thematic grouping of content (always pair with a heading)
|
||||
|
||||
### Form Associations
|
||||
|
||||
- `<label>` with `for` attribute connected to the input's `id`
|
||||
- Group related inputs with `<fieldset>` and `<legend>`
|
||||
- Use `<optgroup>` for grouped select options
|
||||
|
||||
### Heading Hierarchy
|
||||
|
||||
- Sequential order: h1 -> h2 -> h3 -> h4 -> h5 -> h6
|
||||
- NEVER skip levels (e.g., h1 directly to h3)
|
||||
- One `<h1>` per page (the page title)
|
||||
- Headings must describe the content that follows
|
||||
|
||||
---
|
||||
|
||||
## 2. Keyboard Navigation Patterns
|
||||
|
||||
Every interactive element must be operable with a keyboard alone. No mouse-only interactions.
|
||||
|
||||
### Global Key Bindings
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Move focus to next focusable element |
|
||||
| `Shift + Tab` | Move focus to previous focusable element |
|
||||
| `Enter` | Activate links, buttons, submit forms |
|
||||
| `Space` | Activate buttons, toggle checkboxes |
|
||||
| `Escape` | Close modals, dropdowns, popovers, tooltips |
|
||||
| `Arrow keys` | Navigate within composite widgets |
|
||||
| `Home` | Jump to first item in a list or range |
|
||||
| `End` | Jump to last item in a list or range |
|
||||
|
||||
### Composite Widget Navigation (Arrow Keys)
|
||||
|
||||
- **Tabs**: Left/Right arrows move between tabs
|
||||
- **Menus**: Up/Down arrows move between menu items
|
||||
- **Radio groups**: Arrow keys cycle through options, selecting as they go
|
||||
- **Listboxes**: Up/Down arrows move highlight, Space selects
|
||||
- **Tree views**: Up/Down navigate siblings, Right expands, Left collapses
|
||||
|
||||
### tabindex Rules
|
||||
|
||||
- `tabindex="0"` — places element in natural tab order (use for custom interactive elements)
|
||||
- `tabindex="-1"` — removes from tab order but allows programmatic focus via `element.focus()` (use for modal containers, skip-link targets, dynamically focused content)
|
||||
- **NEVER** use `tabindex > 0` — it overrides natural DOM order and creates an unpredictable, unmaintainable focus sequence
|
||||
|
||||
### Focus Order Principle
|
||||
|
||||
Focus order must match the visual reading order (left-to-right, top-to-bottom for LTR languages). If the DOM order does not match the visual layout, fix the DOM order rather than using positive tabindex values.
|
||||
|
||||
---
|
||||
|
||||
## 3. ARIA Attributes Reference
|
||||
|
||||
The first rule of ARIA: do not use ARIA if a native HTML element provides the behavior. When you must use ARIA, apply it correctly.
|
||||
|
||||
### Naming and Describing
|
||||
|
||||
| Attribute | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `aria-label` | Names an element without visible text | Icon button: `<button aria-label="Close">X</button>` |
|
||||
| `aria-labelledby` | Points to another element as the label | Modal: `aria-labelledby="dialog-title"` |
|
||||
| `aria-describedby` | Provides additional description | Form hint: `aria-describedby="password-hint"` |
|
||||
|
||||
### Live Regions
|
||||
|
||||
| Attribute | Behavior |
|
||||
|---|---|
|
||||
| `aria-live="polite"` | Waits for current speech to finish before announcing (toasts, status updates) |
|
||||
| `aria-live="assertive"` | Interrupts current speech immediately (critical errors, urgent alerts) |
|
||||
| `aria-atomic="true"` | Re-reads entire region content on change, not just the delta |
|
||||
| `role="alert"` | Shorthand for `aria-live="assertive"` + `aria-atomic="true"` |
|
||||
| `role="status"` | Shorthand for `aria-live="polite"` + `aria-atomic="true"` |
|
||||
|
||||
### State and Properties
|
||||
|
||||
| Attribute | Purpose |
|
||||
|---|---|
|
||||
| `aria-expanded` | Indicates whether a collapsible section is open (`true`) or closed (`false`) |
|
||||
| `aria-haspopup` | Indicates the trigger opens a popup (`menu`, `listbox`, `dialog`, `grid`, `tree`) |
|
||||
| `aria-modal="true"` | Marks a dialog as modal (assistive tech should ignore content outside) |
|
||||
| `aria-hidden="true"` | Hides element from assistive technology (decorative images, duplicate content) |
|
||||
| `aria-invalid` | Marks a form field as having an error (`true`, `grammar`, `spelling`) |
|
||||
| `aria-required` | Indicates the field is required before form submission |
|
||||
| `aria-sort` | Indicates sort direction on table column headers (`ascending`, `descending`, `none`) |
|
||||
| `aria-selected` | Indicates selected state in single/multi-select widgets |
|
||||
| `aria-controls` | Identifies the element(s) controlled by this element |
|
||||
| `aria-current` | Indicates the current item in a set (`page`, `step`, `location`, `date`, `true`) |
|
||||
| `aria-disabled` | Marks element as disabled but still perceivable (unlike `disabled` attribute which removes from tab order) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Focus Management
|
||||
|
||||
### Visible Focus Indicators
|
||||
|
||||
- **NEVER** use `outline: none` or `outline: 0` without providing a custom alternative
|
||||
- Recommended default: `outline: 3px solid currentColor; outline-offset: 2px;`
|
||||
- Use `:focus-visible` for keyboard-only focus styling (hides ring on mouse click):
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus-color, #2563eb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
```
|
||||
|
||||
- Focus indicators must meet 3:1 contrast ratio against adjacent colors (WCAG 2.4.11)
|
||||
- Minimum focus indicator area: at least 2px perimeter around the component
|
||||
|
||||
### Modal Focus Trapping
|
||||
|
||||
When a modal opens:
|
||||
1. Move focus to the first focusable element inside the modal (or the modal container with `tabindex="-1"`)
|
||||
2. Trap Tab/Shift+Tab to cycle only through focusable elements within the modal
|
||||
3. Pressing Escape closes the modal
|
||||
4. On close, return focus to the element that triggered the modal
|
||||
|
||||
### Focus Restoration
|
||||
|
||||
- When a dropdown/popover/modal closes, return focus to its trigger element
|
||||
- When an item is deleted from a list, move focus to the nearest remaining item
|
||||
- When a dialog confirms an action, focus the result or next logical element
|
||||
|
||||
### SPA Route Changes
|
||||
|
||||
- On navigation, move focus to the main content heading or a skip-link target
|
||||
- Announce the new page title to screen readers using an `aria-live` region or document.title update
|
||||
- Use `<title>` updates: "Page Name | Site Name"
|
||||
|
||||
### Skip Links
|
||||
|
||||
- First focusable element on the page should be "Skip to main content"
|
||||
- Link target: `<main id="main-content" tabindex="-1">`
|
||||
- Visually hidden until focused:
|
||||
|
||||
```css
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
position: static;
|
||||
left: auto;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color Contrast Requirements
|
||||
|
||||
### WCAG AA Minimum Ratios
|
||||
|
||||
| Element | Minimum Contrast Ratio |
|
||||
|---|---|
|
||||
| Normal text (< 24px, or < 18.66px if bold) | 4.5:1 |
|
||||
| Large text (>= 24px, or >= 18.66px if bold) | 3:1 |
|
||||
| UI components (borders, icons, form controls) | 3:1 |
|
||||
| Graphical objects (charts, infographics) | 3:1 |
|
||||
| Disabled elements | No requirement (but keep readable) |
|
||||
| Placeholder text | 4.5:1 (it is regular text) |
|
||||
|
||||
### Testing Tools
|
||||
|
||||
- Chrome DevTools: Elements panel -> Styles -> color swatch -> contrast ratio
|
||||
- axe-core browser extension
|
||||
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
|
||||
- Stark (Figma/Sketch plugin)
|
||||
|
||||
### Color Independence Rules
|
||||
|
||||
- **NEVER** convey information by color alone
|
||||
- Error states: red color + error icon + descriptive text message
|
||||
- Required fields: asterisk + "required" label text (not just red border)
|
||||
- Status indicators: color + icon + text label (e.g., green checkmark + "Complete")
|
||||
- Links in body text: color + underline (or other non-color differentiator)
|
||||
- Charts/graphs: use patterns, labels, or shapes in addition to color
|
||||
|
||||
### Dark Mode Considerations
|
||||
|
||||
- Test contrast ratios separately in dark mode
|
||||
- Use desaturated color variants, not simple CSS `invert()`
|
||||
- Background and foreground pairs must both be intentionally chosen
|
||||
- Semi-transparent overlays can reduce effective contrast -- verify computed values
|
||||
|
||||
---
|
||||
|
||||
## 6. Accessible Component Patterns
|
||||
|
||||
### Dropdown / Select
|
||||
|
||||
```
|
||||
trigger: aria-haspopup="listbox", aria-expanded="false|true"
|
||||
container: role="listbox"
|
||||
options: role="option", aria-selected="true|false"
|
||||
```
|
||||
|
||||
- Arrow keys navigate options
|
||||
- Typeahead: typing characters jumps to matching option
|
||||
- Enter/Space selects highlighted option
|
||||
- Escape closes without selecting
|
||||
- Selected option text updates trigger label
|
||||
|
||||
### Modal / Dialog
|
||||
|
||||
```
|
||||
container: role="dialog", aria-modal="true", aria-labelledby="title-id"
|
||||
title: id="title-id"
|
||||
close button: aria-label="Close dialog"
|
||||
```
|
||||
|
||||
- Focus moves into modal on open
|
||||
- Tab cycles within modal (focus trap)
|
||||
- Escape closes modal
|
||||
- Click on backdrop closes modal
|
||||
- Focus returns to trigger on close
|
||||
- Background content gets `aria-hidden="true"` or `inert`
|
||||
|
||||
### Tabs
|
||||
|
||||
```
|
||||
container: role="tablist"
|
||||
tab: role="tab", aria-selected="true|false", aria-controls="panel-id", tabindex="0|-1"
|
||||
panel: role="tabpanel", aria-labelledby="tab-id", tabindex="0"
|
||||
```
|
||||
|
||||
- Only the active tab has `tabindex="0"`; inactive tabs have `tabindex="-1"`
|
||||
- Left/Right arrows move between tabs (wrapping optional)
|
||||
- Home/End jump to first/last tab
|
||||
- Tab key moves focus from the active tab into the panel content
|
||||
|
||||
### Forms
|
||||
|
||||
- Every `<input>`, `<select>`, `<textarea>` has a visible `<label>`
|
||||
- Required fields: `aria-required="true"` + visual asterisk indicator
|
||||
- Error fields: `aria-invalid="true"` + `aria-describedby` pointing to error message element
|
||||
- Error messages: use `role="alert"` or `aria-live="assertive"` region
|
||||
- On failed submission: focus the first invalid field
|
||||
- Helper text: linked via `aria-describedby` to the associated input
|
||||
- Password fields: toggle visibility button with `aria-label` describing current state
|
||||
- Groups of related controls: `<fieldset>` + `<legend>`
|
||||
|
||||
### Accordion
|
||||
|
||||
```
|
||||
trigger: <button aria-expanded="true|false" aria-controls="panel-id">
|
||||
panel: id="panel-id", role="region", aria-labelledby="trigger-id"
|
||||
```
|
||||
|
||||
- Enter/Space toggles section
|
||||
- Only one section open at a time (optional, depends on design)
|
||||
- Panel content hidden with `hidden` attribute or `display: none` (not just visually)
|
||||
|
||||
### Toast / Notification
|
||||
|
||||
- Container: `role="status"` or `aria-live="polite"` (non-critical)
|
||||
- Critical notifications: `role="alert"` (assertive)
|
||||
- Must be dismissible (close button or auto-dismiss with sufficient time)
|
||||
- Auto-dismiss: minimum 5 seconds visible, pauses on hover/focus
|
||||
|
||||
---
|
||||
|
||||
## 7. prefers-reduced-motion
|
||||
|
||||
### Global Reset
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nuanced Approach
|
||||
|
||||
Reduced motion means fewer/gentler animations, not zero motion:
|
||||
- **Keep**: opacity fades, color transitions that aid comprehension
|
||||
- **Remove**: parallax scrolling, zoom/scale transforms, slide/translate animations, auto-playing carousels
|
||||
- **Simplify**: complex multi-step animations to simple fades
|
||||
|
||||
### Framework Integration
|
||||
|
||||
React (framer-motion):
|
||||
```jsx
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
|
||||
function Component() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ x: shouldReduceMotion ? 0 : 100 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
CSS custom property approach:
|
||||
```css
|
||||
:root {
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-speed: 0.01ms;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Approach
|
||||
|
||||
### Automated Testing
|
||||
|
||||
| Tool | Usage |
|
||||
|---|---|
|
||||
| axe-core | `npm install jest-axe` for unit tests; `expect(container).toHaveNoViolations()` |
|
||||
| Lighthouse | Accessibility score target: 90+ |
|
||||
| eslint-plugin-jsx-a11y | Static analysis for JSX accessibility issues |
|
||||
| pa11y | CLI/CI integration for automated page-level audits |
|
||||
| Playwright/axe | `@axe-core/playwright` for integration test accessibility checks |
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
1. **Keyboard-only navigation**: unplug mouse, navigate entire page with Tab, Enter, Arrows, Escape
|
||||
2. **Screen reader**: VoiceOver (macOS: Cmd+F5), NVDA (Windows, free), JAWS (Windows)
|
||||
3. **Zoom 200%**: content should reflow without horizontal scrolling or content clipping
|
||||
4. **Zoom 400%**: text should remain readable (WCAG 1.4.10 Reflow)
|
||||
5. **Focus indicators**: every interactive element shows a visible focus ring when focused via keyboard
|
||||
6. **Forced colors mode**: test in Windows High Contrast Mode (use `forced-colors` media query)
|
||||
7. **Text spacing**: override letter-spacing (0.12em), word-spacing (0.16em), line-height (1.5), paragraph-spacing (2em) -- content must remain readable
|
||||
|
||||
### CI Integration
|
||||
|
||||
```bash
|
||||
# Example: axe-core with Playwright in CI
|
||||
npx playwright test --project=accessibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---|---|
|
||||
| `outline: none` on focus | Use `:focus-visible` with a custom focus ring |
|
||||
| Placeholder as only label | Always use `<label>` element |
|
||||
| Icon button without label | Add `aria-label="Action description"` |
|
||||
| Color-only error indication | Add icon + descriptive text alongside color |
|
||||
| Missing alt text on images | Descriptive `alt` text, or `alt=""` for decorative images |
|
||||
| Heading level skip (h1 to h3) | Sequential hierarchy: h1 -> h2 -> h3 |
|
||||
| `tabindex > 0` | Use natural DOM order; only use `0` or `-1` |
|
||||
| Emoji used as functional icons | Use SVG icons with `aria-label` |
|
||||
| Auto-playing animation | Respect `prefers-reduced-motion` media query |
|
||||
| Non-dismissible modal | Always support Escape key to close |
|
||||
| `aria-hidden="true"` on focusable elements | Remove from tab order or remove `aria-hidden` |
|
||||
| Missing `lang` attribute on `<html>` | Set `<html lang="en">` (or appropriate language code) |
|
||||
| Autoplaying video/audio with sound | Require user interaction to start, or mute by default with controls |
|
||||
| Tiny tap targets on mobile | Minimum 44x44 CSS pixels for touch targets |
|
||||
| Using `title` attribute as primary label | `title` is unreliable; use `aria-label` or visible `<label>` |
|
||||
| Links that say "click here" or "read more" | Descriptive link text: "Read the accessibility guide" |
|
||||
| Missing form error summary | On submit failure, show summary of all errors at top of form |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Testing a New Component
|
||||
|
||||
Before marking any component as complete, verify:
|
||||
|
||||
1. Can you reach and operate it using only a keyboard?
|
||||
2. Does it have a visible focus indicator?
|
||||
3. Does it announce correctly in a screen reader?
|
||||
4. Does it meet color contrast ratios?
|
||||
5. Does it work at 200% zoom?
|
||||
6. Does it respect `prefers-reduced-motion`?
|
||||
7. Does it pass `jest-axe` / axe-core automated checks?
|
||||
8. Does it have appropriate semantic HTML or ARIA roles?
|
||||
9. Are all images, icons, and media labeled?
|
||||
10. Can it be operated with one hand on mobile (44x44px touch targets)?
|
||||
@@ -0,0 +1,545 @@
|
||||
# Animation Playbook
|
||||
|
||||
Deep-dive reference for animation patterns. The main SKILL.md references these techniques
|
||||
but does not include the full detail needed for implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Easing Curve Library
|
||||
|
||||
The built-in CSS keywords (`ease`, `ease-in`, `ease-out`, `ease-in-out`) produce weak,
|
||||
generic motion. Define custom curves as CSS custom properties so every animation in the
|
||||
project shares the same vocabulary.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Strong ease-out — the default for UI interactions (enter, appear, respond) */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
/* Strong ease-in-out — on-screen movement and morphing transitions */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
|
||||
/* iOS-like drawer curve — slide-up sheets, bottom drawers */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
/* Snappy — fast micro-interactions, toggles, checkboxes */
|
||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
/* Emphasized deceleration — large surface transitions, page-level changes */
|
||||
--ease-decel: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
| Curve | Use case |
|
||||
| ---------------- | ---------------------------------------------- |
|
||||
| `--ease-out` | Elements entering the viewport, appearing |
|
||||
| `--ease-in-out` | Elements morphing shape, moving across screen |
|
||||
| `--ease-drawer` | Sheets, drawers, panels sliding into view |
|
||||
| `--ease-snappy` | Micro-interactions: toggles, checks, switches |
|
||||
| `--ease-decel` | Large page transitions, route changes |
|
||||
| `linear` | Constant-rate motion only: progress bars, spin |
|
||||
|
||||
Never use `ease-in` alone for UI elements — it makes things feel sluggish at the start.
|
||||
Reserve `linear` for continuous motion (loading spinners, progress indicators) where
|
||||
deceleration would look wrong.
|
||||
|
||||
**Resources**: [easing.dev](https://easing.dev), [easings.co](https://easings.co)
|
||||
for visual curve comparison and copying.
|
||||
|
||||
---
|
||||
|
||||
## 2. Spring Animations
|
||||
|
||||
Springs are physics-based. They do not have a fixed duration — they simulate mass,
|
||||
stiffness, and damping. This makes them ideal for anything interactive.
|
||||
|
||||
### When to use springs instead of easing curves
|
||||
|
||||
- Drag interactions (the element should follow the finger naturally)
|
||||
- Elements that feel "alive" (cards, floating actions, avatars)
|
||||
- Gestures that can be interrupted mid-animation
|
||||
- Mouse-tracking interactions (cursor followers, magnetic buttons)
|
||||
|
||||
### Apple-style spring (duration + bounce)
|
||||
|
||||
```js
|
||||
// Framer Motion / Motion One
|
||||
animate(element, { x: 100 }, {
|
||||
type: "spring",
|
||||
duration: 0.5,
|
||||
bounce: 0.2
|
||||
})
|
||||
```
|
||||
|
||||
This is the simpler API. `duration` controls overall timing, `bounce` controls overshoot.
|
||||
|
||||
### Traditional physics spring (mass + stiffness + damping)
|
||||
|
||||
```js
|
||||
animate(element, { x: 100 }, {
|
||||
type: "spring",
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 10
|
||||
})
|
||||
```
|
||||
|
||||
More control, but harder to tune. Start with mass=1 and adjust stiffness/damping.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Keep bounce subtle: **0.1 to 0.3** for most UI. Higher values feel toy-like.
|
||||
- Avoid bounce entirely for actions that need to feel decisive (confirms, deletes).
|
||||
- Springs **maintain velocity when interrupted** — if you change the target mid-animation,
|
||||
the element smoothly redirects. Keyframe animations restart from scratch.
|
||||
- Use `useSpring` (or equivalent) for mouse-tracking: it makes cursor followers feel
|
||||
natural instead of artificial. The lag is intentional and pleasant.
|
||||
- For lists, spring each item separately so they can settle independently.
|
||||
|
||||
---
|
||||
|
||||
## 3. clip-path Animation Patterns
|
||||
|
||||
`clip-path` is one of the most underused animation tools. It lets you reveal, hide,
|
||||
and transition content without layout shifts.
|
||||
|
||||
### Inset shape basics
|
||||
|
||||
```css
|
||||
/* Full visibility */
|
||||
clip-path: inset(0 0 0 0);
|
||||
|
||||
/* Clipped from bottom — only top portion visible */
|
||||
clip-path: inset(0 0 50% 0);
|
||||
|
||||
/* Fully hidden — clipped from all sides */
|
||||
clip-path: inset(50% 50% 50% 50%);
|
||||
|
||||
/* With border-radius */
|
||||
clip-path: inset(10px round 8px);
|
||||
```
|
||||
|
||||
The values are `inset(top right bottom left)` — how far each edge clips inward.
|
||||
|
||||
### Pattern: Tabs with perfect color transitions
|
||||
|
||||
Duplicate the entire tab list. Place one copy on top of the other. The bottom copy has
|
||||
inactive styles; the top copy has active styles. Animate `clip-path: inset(...)` on the
|
||||
top copy to reveal only the active tab region. The color transition is instantaneous and
|
||||
pixel-perfect — no fade needed.
|
||||
|
||||
```css
|
||||
.tabs-active-overlay {
|
||||
clip-path: inset(0 calc(100% - var(--tab-right)) 0 var(--tab-left));
|
||||
transition: clip-path 300ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Hold-to-delete
|
||||
|
||||
Overlay a colored fill on the button. On `:active`, animate `clip-path` from
|
||||
`inset(0 100% 0 0)` to `inset(0 0 0 0)` over 2 seconds with `linear` timing (the user
|
||||
needs to see constant progress). On release, snap back with `200ms ease-out`.
|
||||
|
||||
```css
|
||||
.delete-btn::after {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms var(--ease-out);
|
||||
}
|
||||
.delete-btn:active::after {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Image reveals on scroll
|
||||
|
||||
Start with `clip-path: inset(0 0 100% 0)` (image hidden, clipped from bottom).
|
||||
Use IntersectionObserver to detect viewport entry, then animate to `inset(0 0 0 0)`.
|
||||
|
||||
```js
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.clipPath = 'inset(0 0 0 0)';
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
```
|
||||
|
||||
### Pattern: Comparison sliders
|
||||
|
||||
Overlay two images. Clip the top image by the drag position:
|
||||
`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`. Update `--pos` on pointer move.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gesture Design
|
||||
|
||||
Gestures are the hardest animation category because they involve real-time user input
|
||||
and require physics-aware feedback.
|
||||
|
||||
### Momentum-based dismissal
|
||||
|
||||
Calculate velocity during drag:
|
||||
|
||||
```js
|
||||
const velocity = distance / elapsed; // px per ms
|
||||
if (velocity > 0.11) {
|
||||
dismiss(); // Fast enough — dismiss regardless of distance
|
||||
} else if (Math.abs(offset) > threshold) {
|
||||
dismiss(); // Far enough — dismiss regardless of speed
|
||||
} else {
|
||||
snapBack(); // Neither fast nor far — return to origin
|
||||
}
|
||||
```
|
||||
|
||||
The velocity threshold (0.11 px/ms) matters more than distance. A quick flick should
|
||||
dismiss even from a small offset.
|
||||
|
||||
### Damping at boundaries
|
||||
|
||||
When the user drags past a natural boundary (e.g., top of a scroll view), apply
|
||||
increasing resistance:
|
||||
|
||||
```js
|
||||
function dampedOffset(raw, boundary) {
|
||||
const overflow = raw - boundary;
|
||||
// Logarithmic damping — diminishing returns
|
||||
return boundary + Math.log(1 + Math.abs(overflow)) * 30 * Math.sign(overflow);
|
||||
}
|
||||
```
|
||||
|
||||
This produces the rubber-band effect. The element still moves, but progressively less.
|
||||
|
||||
### Pointer capture
|
||||
|
||||
Once a drag begins, call `element.setPointerCapture(event.pointerId)`. This ensures
|
||||
all subsequent pointer events route to this element even if the pointer leaves its
|
||||
bounds. Release on `pointerup`.
|
||||
|
||||
### Multi-touch protection
|
||||
|
||||
Track only the first pointer. If a second finger touches during a drag, ignore it:
|
||||
|
||||
```js
|
||||
let activePointerId = null;
|
||||
element.addEventListener('pointerdown', (e) => {
|
||||
if (activePointerId !== null) return; // Already tracking
|
||||
activePointerId = e.pointerId;
|
||||
element.setPointerCapture(e.pointerId);
|
||||
});
|
||||
```
|
||||
|
||||
### Friction instead of hard stops
|
||||
|
||||
Never hard-clamp position. Always allow movement with increasing resistance. Hard stops
|
||||
feel broken. Friction feels physical.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stagger Patterns
|
||||
|
||||
Staggering creates a sense of flow by delaying each item slightly.
|
||||
|
||||
### CSS implementation
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: stagger-in 400ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes stagger-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 40ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 80ms; }
|
||||
.stagger-item:nth-child(4) { animation-delay: 120ms; }
|
||||
.stagger-item:nth-child(5) { animation-delay: 160ms; }
|
||||
```
|
||||
|
||||
Or with a custom property:
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
animation-delay: calc(var(--index) * 40ms);
|
||||
}
|
||||
```
|
||||
|
||||
Set `--index` via `style` attribute in markup or JS.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- **30-80ms** per step is the sweet spot. Under 30ms looks simultaneous. Over 80ms
|
||||
feels sluggish.
|
||||
- Break content into **semantic chunks** — stagger cards, not individual lines of text.
|
||||
- **Never block interaction** during stagger animations. All items should be clickable
|
||||
immediately, even if not yet visible.
|
||||
- Cap the total stagger time. For a list of 20 items, stagger the first 5-6 and let the
|
||||
rest appear together.
|
||||
- Stagger on initial load only. Re-renders should not re-stagger.
|
||||
|
||||
---
|
||||
|
||||
## 6. Exit Animation Patterns
|
||||
|
||||
Exits are often neglected. They deserve as much care as entries.
|
||||
|
||||
### Principles
|
||||
|
||||
- **Exits should be faster than enters.** If enter is 400ms, exit should be 200-250ms.
|
||||
- **Use small fixed translateY** (8-12px) instead of full-height slides. Large movements
|
||||
during exit draw too much attention away from what remains.
|
||||
- **Opacity + scale combination** works better than opacity alone for removal. A slight
|
||||
`scale(0.96)` during fade-out makes it feel more physical.
|
||||
- **Asymmetric timing is intentional.** A hold-to-delete might take 2 seconds (deliberate),
|
||||
but the actual removal should be 200ms (snappy). The weight is in the decision, not
|
||||
the consequence.
|
||||
|
||||
### Exit with height collapse
|
||||
|
||||
When removing an item from a list, animate both the content (opacity + translate) and
|
||||
the container height. The content fades first, then the gap closes:
|
||||
|
||||
```css
|
||||
.item-exiting {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: opacity 150ms var(--ease-out),
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
.item-exiting-collapse {
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: height 200ms var(--ease-out) 100ms, /* delayed start */
|
||||
margin 200ms var(--ease-out) 100ms,
|
||||
padding 200ms var(--ease-out) 100ms;
|
||||
}
|
||||
```
|
||||
|
||||
### Tuning
|
||||
|
||||
There is no formula for the right opacity/height/transform combination. Adjust until it
|
||||
feels right. Test by performing the action 10 times quickly — if anything feels off on
|
||||
repetition, it needs work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance Rules
|
||||
|
||||
Animation jank is unacceptable. These rules keep animations at 60fps.
|
||||
|
||||
### The compositing-only rule
|
||||
|
||||
Only animate properties that skip layout and paint:
|
||||
- `transform` (translate, scale, rotate)
|
||||
- `opacity`
|
||||
- `filter` (with caveats — see below)
|
||||
|
||||
Everything else triggers layout recalculation (width, height, margin, padding, top, left)
|
||||
or paint (background-color, box-shadow, border). Both are expensive.
|
||||
|
||||
### CSS vs JavaScript animations
|
||||
|
||||
- **CSS animations and transitions** run off the main thread on the compositor. Use them
|
||||
for predetermined animations (hover effects, enter/exit, state changes).
|
||||
- **Framer Motion `x`/`y` props are NOT hardware-accelerated.** They animate inline
|
||||
styles, which run on the main thread. Use the full transform string or CSS-based
|
||||
approaches for performance-critical animations.
|
||||
- **CSS variables on parent elements** cause expensive style recalculation when updated.
|
||||
If animating a CSS variable, update the `transform` property directly instead.
|
||||
|
||||
### Web Animations API (WAAPI)
|
||||
|
||||
For programmatic animations that need CSS-level performance:
|
||||
|
||||
```js
|
||||
element.animate(
|
||||
[
|
||||
{ transform: 'translateY(20px)', opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
],
|
||||
{ duration: 400, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
|
||||
);
|
||||
```
|
||||
|
||||
WAAPI runs on the compositor like CSS animations but is controlled from JavaScript.
|
||||
|
||||
### Blur and filter performance
|
||||
|
||||
- Keep `blur()` under **20px**, especially on Safari where large blurs are expensive.
|
||||
- `backdrop-filter: blur()` is even more expensive — use sparingly.
|
||||
- Prefer pre-blurred images over real-time blur when possible.
|
||||
|
||||
### will-change
|
||||
|
||||
- Only use `will-change` for `transform`, `opacity`, or `filter`.
|
||||
- **Never** use `will-change: all` — it promotes every property and wastes GPU memory.
|
||||
- Add `will-change` only when you observe first-frame stutter on an animation. It is a
|
||||
last resort, not a default.
|
||||
- Remove `will-change` after the animation completes if the element is long-lived.
|
||||
|
||||
### transition: all is banned
|
||||
|
||||
```css
|
||||
/* Bad — animates every property change, including ones you did not intend */
|
||||
transition: all 200ms ease;
|
||||
|
||||
/* Good — explicit about what animates */
|
||||
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
|
||||
```
|
||||
|
||||
`transition: all` causes unexpected animations when other properties change and makes
|
||||
debugging difficult.
|
||||
|
||||
---
|
||||
|
||||
## 8. The Sonner Principles
|
||||
|
||||
Sonner (the toast library) demonstrates principles that apply broadly to dynamic UI
|
||||
components.
|
||||
|
||||
### Good defaults matter more than options
|
||||
|
||||
If you need 12 configuration props to make a component feel right, the defaults are
|
||||
wrong. The component should feel right out of the box.
|
||||
|
||||
### Use transitions, not keyframes, for dynamic UI
|
||||
|
||||
Toasts are added rapidly and unpredictably. Keyframe animations have fixed timelines
|
||||
that cannot adapt to rapid state changes. CSS transitions respond to the current state
|
||||
and interpolate naturally.
|
||||
|
||||
### Handle edge cases invisibly
|
||||
|
||||
- Pause toast timers when the browser tab is hidden (the user should not miss toasts).
|
||||
- When a toast is dismissed from the middle of a stack, the remaining toasts should
|
||||
fill the gap smoothly.
|
||||
- When multiple toasts arrive simultaneously, batch the visual update.
|
||||
|
||||
### Match motion personality to component personality
|
||||
|
||||
A success toast can be slightly bouncy. An error toast should be direct and firm.
|
||||
A loading toast should feel steady and patient. The animation communicates as much as
|
||||
the content.
|
||||
|
||||
---
|
||||
|
||||
## 9. @starting-style for Modern CSS Enter Animations
|
||||
|
||||
`@starting-style` defines the initial style of an element when it first renders.
|
||||
Combined with transitions, it creates enter animations in pure CSS — no JavaScript
|
||||
`useEffect` + `mounted` state needed.
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When the `.toast` element is inserted into the DOM, the browser starts from the
|
||||
`@starting-style` values and transitions to the normal values.
|
||||
|
||||
### Works with display: none toggling
|
||||
|
||||
```css
|
||||
.dialog {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transition: opacity 300ms var(--ease-out), display 300ms allow-discrete;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.dialog[hidden] {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
The `allow-discrete` keyword lets `display` participate in the transition timeline.
|
||||
|
||||
### Fallback for older browsers
|
||||
|
||||
When `@starting-style` is not supported, fall back to a `data-mounted` attribute pattern:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
}
|
||||
.toast:not([data-mounted]) {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
```
|
||||
|
||||
Add `data-mounted` via JavaScript after a single `requestAnimationFrame`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Debug Techniques
|
||||
|
||||
### Slow motion testing
|
||||
|
||||
Increase animation duration by 2-5x during development. At normal speed, problems are
|
||||
invisible. At 5x, you see every hitch, wrong easing, and misaligned property.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--debug-speed: 1; /* Change to 5 for slow-mo */
|
||||
}
|
||||
.animated {
|
||||
transition-duration: calc(200ms * var(--debug-speed));
|
||||
}
|
||||
```
|
||||
|
||||
### Chrome DevTools Animations panel
|
||||
|
||||
Open DevTools > More Tools > Animations. This panel shows:
|
||||
- A timeline of all running animations
|
||||
- Frame-by-frame scrubbing
|
||||
- Easing curve visualization
|
||||
- Duration and delay for each animation
|
||||
|
||||
Use the playback speed controls (25%, 10%) for detailed inspection.
|
||||
|
||||
### Real device testing
|
||||
|
||||
Touch interactions feel completely different on a real phone versus a trackpad simulator.
|
||||
Always test gestures, drag interactions, and spring animations on physical devices.
|
||||
|
||||
### Fresh eyes check
|
||||
|
||||
Review animations with fresh eyes the next day. What felt right at 11pm during
|
||||
development often feels too fast, too slow, or too dramatic the next morning.
|
||||
|
||||
### The checklist
|
||||
|
||||
Before shipping any animation, verify:
|
||||
- Smooth color transitions (no banding or flashing)?
|
||||
- Correct easing curve for the interaction type?
|
||||
- Right `transform-origin` (elements scaling/rotating from the expected point)?
|
||||
- All animated properties in sync (opacity and transform finishing together)?
|
||||
- No layout shift during the animation?
|
||||
- Works with `prefers-reduced-motion: reduce`?
|
||||
- Performs at 60fps on a mid-range device?
|
||||
@@ -0,0 +1,604 @@
|
||||
# Component Implementation Patterns
|
||||
|
||||
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
|
||||
|
||||
---
|
||||
|
||||
## 1. shadcn/ui Setup
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init
|
||||
npx shadcn@latest add button input form card dialog select sheet toast
|
||||
```
|
||||
|
||||
Key concepts:
|
||||
|
||||
- **Not an npm package** -- components are copied into your project. You own the code and can modify it freely.
|
||||
- Built on **Radix UI** primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
|
||||
- Styled with **Tailwind CSS** utilities -- no CSS-in-JS runtime.
|
||||
- Required dependencies:
|
||||
- `class-variance-authority` (CVA) -- variant management
|
||||
- `clsx` -- conditional class joining
|
||||
- `tailwind-merge` -- deduplicates conflicting Tailwind classes
|
||||
- `lucide-react` -- icon library
|
||||
- `tailwindcss-animate` -- animation utilities
|
||||
|
||||
The `cn()` utility combines `clsx` and `tailwind-merge`:
|
||||
|
||||
```ts
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. CSS Variables for Theming (HSL Format)
|
||||
|
||||
shadcn uses HSL values without the `hsl()` wrapper so Tailwind can apply opacity modifiers:
|
||||
|
||||
```css
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
/* ... remaining dark overrides */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in `tailwind.config.ts`:
|
||||
|
||||
```ts
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
// ...
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Button Patterns
|
||||
|
||||
Use CVA to define variants declaratively:
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **Press feedback**: `active:scale-[0.97]` gives tactile response without layout shift.
|
||||
- **Focus ring**: Always visible via `focus-visible:ring-2`. Never use `outline: none` without a replacement.
|
||||
- **Loading state**: Disable the button and show a spinner inline.
|
||||
|
||||
```tsx
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, isLoading, children, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Form Patterns (React Hook Form + Zod)
|
||||
|
||||
Schema-first validation keeps validation logic co-located and type-safe:
|
||||
|
||||
```tsx
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
name: z.string().min(2).max(50),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
```
|
||||
|
||||
The shadcn Form components wire React Hook Form to accessible markup:
|
||||
|
||||
```tsx
|
||||
function SignUpForm() {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
mode: "onBlur", // validate on blur, not keystroke
|
||||
})
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
// handle submission
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* ...more fields */}
|
||||
<Button type="submit" isLoading={form.formState.isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Accessibility rules:
|
||||
|
||||
- `FormMessage` renders error text with `aria-describedby` linked to the input.
|
||||
- Inputs get `aria-invalid="true"` when in error state automatically.
|
||||
- Mark required fields with `aria-required="true"`.
|
||||
- Validate on **blur**, not on every keystroke -- reduces noise and respects user flow.
|
||||
- Use **progressive disclosure** for complex forms: show additional fields only when relevant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Card Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription,
|
||||
CardContent, CardFooter,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Project Settings</CardTitle>
|
||||
<CardDescription>Manage your project configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* form fields or content */}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **Concentric border radius**: Outer radius = inner radius + padding. If inner elements have `rounded-md` (6px) and padding is 16px, outer card should be `rounded-xl` (12px) or greater.
|
||||
- **Layered shadows**: Use multiple shadow values for natural depth -- `shadow-sm` at rest, `shadow-lg` on hover.
|
||||
- **Hover lift**: Subtle `translateY(-2px)` on hover, never more than 4px.
|
||||
- Use semantic color tokens (`bg-card`, `text-card-foreground`) so cards adapt to theme changes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dialog (Modal) Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Dialog, DialogTrigger, DialogContent,
|
||||
DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Profile</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your profile here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* form content */}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
Accessibility and interaction rules (handled by Radix):
|
||||
|
||||
- **Focus trap**: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
|
||||
- **ESC to close**: Always. No exceptions.
|
||||
- **Click outside overlay**: Closes the dialog by default.
|
||||
- `aria-modal="true"` is set automatically.
|
||||
- `aria-labelledby` points to `DialogTitle`, `aria-describedby` points to `DialogDescription`.
|
||||
- **Restore focus**: When dialog closes, focus returns to the trigger element.
|
||||
- **Animation origin**: `transform-origin: center` -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Select/Dropdown Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue,
|
||||
SelectContent, SelectItem, SelectGroup, SelectLabel,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Fruits</SelectLabel>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="blueberry">Blueberry</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
Interaction rules:
|
||||
|
||||
- **Keyboard navigation**: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
|
||||
- ARIA: `aria-haspopup="listbox"` on trigger, `aria-expanded` toggles with open state.
|
||||
- **Transform origin**: Popover should animate from the trigger position (origin-aware), not from center.
|
||||
- **Tooltip delay skip**: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sheet (Slide-over) Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Sheet, SheetTrigger, SheetContent,
|
||||
SheetHeader, SheetTitle, SheetDescription,
|
||||
SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Menu</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Browse sections of the app.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-2 py-4">
|
||||
{/* nav links */}
|
||||
</nav>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
Use cases:
|
||||
|
||||
- **Mobile navigation**: Slide from left with full-height overlay.
|
||||
- **Detail panels**: Slide from right to show item details without leaving the list view.
|
||||
- **Filters**: Slide from bottom on mobile for filter controls.
|
||||
|
||||
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
|
||||
|
||||
---
|
||||
|
||||
## 9. Toast/Notification Patterns
|
||||
|
||||
Using the shadcn Toast (or Sonner for a lighter API):
|
||||
|
||||
```tsx
|
||||
// With shadcn toast
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
function SaveButton() {
|
||||
const { toast } = useToast()
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
toast({
|
||||
title: "Changes saved",
|
||||
description: "Your settings have been updated.",
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// With Sonner (simpler API)
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved")
|
||||
toast.error("Something went wrong")
|
||||
toast.promise(saveSettings(), {
|
||||
loading: "Saving...",
|
||||
success: "Settings saved",
|
||||
error: "Could not save",
|
||||
})
|
||||
```
|
||||
|
||||
Design and accessibility rules:
|
||||
|
||||
- **Auto-dismiss**: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
|
||||
- `aria-live="polite"` -- screen readers announce without stealing focus.
|
||||
- **CSS transitions, not keyframes** -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
|
||||
- **Pause timers** when the browser tab is hidden (`document.visibilityState`).
|
||||
- **Swipe to dismiss**: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
|
||||
|
||||
---
|
||||
|
||||
## 10. Table Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Table, TableHeader, TableBody, TableFooter,
|
||||
TableHead, TableRow, TableCell, TableCaption,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableCaption>A list of recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">{invoice.id}</TableCell>
|
||||
<TableCell>{invoice.status}</TableCell>
|
||||
<TableCell className="text-right">{invoice.amount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Responsive**: Wrap table in `overflow-x-auto` container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
|
||||
- **Sortable columns**: Use `aria-sort="ascending"` or `aria-sort="descending"` on the active `TableHead`. Show a visual indicator (chevron icon).
|
||||
- **Virtualization**: For lists exceeding ~50 items, use `@tanstack/react-virtual` or similar to render only visible rows.
|
||||
- **Row distinction**: Use zebra striping (`even:bg-muted/50`) or subtle borders between rows. Never rely on color alone.
|
||||
|
||||
---
|
||||
|
||||
## 11. Chart Integration
|
||||
|
||||
When integrating charts (Recharts, Chart.js, or similar):
|
||||
|
||||
- **Match chart type to data intent**:
|
||||
- Trend over time: line chart
|
||||
- Comparison across categories: bar chart
|
||||
- Part-of-whole: pie/donut chart
|
||||
- Distribution: histogram
|
||||
- Correlation: scatter plot
|
||||
|
||||
- **Accessible color palettes**: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
|
||||
- **Always include a legend** and provide **tooltips on hover/focus** for precise values.
|
||||
- **Screen reader alternative**: Provide a visually hidden `<table>` with the same data so screen readers can access it.
|
||||
- **Respect `prefers-reduced-motion`**: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
|
||||
|
||||
```tsx
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches
|
||||
|
||||
<LineChart data={data}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
animationDuration={prefersReducedMotion ? 0 : 500}
|
||||
/>
|
||||
</LineChart>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Server Component Wrapping (Next.js)
|
||||
|
||||
Most shadcn/ui components use React state or event handlers and require `"use client"`. Structure your components to keep data fetching in server components:
|
||||
|
||||
```tsx
|
||||
// app/dashboard/page.tsx (Server Component -- no "use client")
|
||||
import { getProjects } from "@/lib/data"
|
||||
import { ProjectList } from "./project-list"
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const projects = await getProjects()
|
||||
return <ProjectList projects={projects} />
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/dashboard/project-list.tsx (Client Component)
|
||||
"use client"
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ProjectListProps {
|
||||
projects: { id: string; name: string; status: string }[]
|
||||
}
|
||||
|
||||
export function ProjectList({ projects }: ProjectListProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{project.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{project.status}</p>
|
||||
<Button variant="outline" size="sm">View</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The pattern: **Server component fetches data, passes to client component as serializable props.** This keeps the client bundle small and data fetching on the server.
|
||||
|
||||
---
|
||||
|
||||
## 13. CVA (class-variance-authority) Deep Dive
|
||||
|
||||
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
|
||||
|
||||
```ts
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
|
||||
- **Compose with `cn()`**: Always wrap CVA output with `cn()` so consumer-passed `className` can override defaults via `tailwind-merge`.
|
||||
- **Type extraction**: `VariantProps<typeof badgeVariants>` generates the TypeScript type for variant props automatically.
|
||||
- **Compound variants**: Handle combinations of variant values that need special styling:
|
||||
|
||||
```ts
|
||||
const inputVariants = cva("...", {
|
||||
variants: {
|
||||
size: { sm: "...", lg: "..." },
|
||||
state: { error: "...", success: "..." },
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: "sm", state: "error", class: "border-2 border-red-500" },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
- **Use CVA for any component with visual variants** -- buttons, badges, alerts, inputs, cards. It replaces manual `if/else` class concatenation with a declarative, type-safe API.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Pre-Delivery Review Checklist
|
||||
|
||||
Extended 30-item checklist for UI implementation quality. Run through this before marking any UI task as complete.
|
||||
|
||||
## Typography (6 items)
|
||||
|
||||
### 1. Font Smoothing Applied
|
||||
- **Check**: Root layout has `-webkit-font-smoothing: antialiased`
|
||||
- **How**: Inspect `<html>` or `<body>` computed styles
|
||||
- **Failing looks like**: Text appears heavy/blurry on macOS, especially at small sizes
|
||||
|
||||
### 2. Headings Use text-wrap: balance
|
||||
- **Check**: All `<h1>`–`<h4>` elements have `text-wrap: balance`
|
||||
- **How**: Resize viewport to trigger wrapping — headings should break evenly
|
||||
- **Failing looks like**: One long line followed by a single orphan word
|
||||
|
||||
### 3. Body Text Uses text-wrap: pretty
|
||||
- **Check**: Paragraphs and body text use `text-wrap: pretty`
|
||||
- **How**: Check for orphaned words at the end of paragraphs
|
||||
- **Failing looks like**: A single short word sitting alone on the last line
|
||||
|
||||
### 4. Dynamic Numbers Use tabular-nums
|
||||
- **Check**: Counters, prices, timers, and data columns have `font-variant-numeric: tabular-nums`
|
||||
- **How**: Watch numbers update — layout should not shift
|
||||
- **Failing looks like**: Content jumps horizontally as digits change width
|
||||
|
||||
### 5. Line Length Controlled
|
||||
- **Check**: Body text containers are capped at `max-width: 65ch`
|
||||
- **How**: Measure character count on a full-width line
|
||||
- **Failing looks like**: Text stretching edge-to-edge on wide monitors, hard to read
|
||||
|
||||
### 6. Type Scale Consistency
|
||||
- **Check**: All text sizes come from the defined type scale (no arbitrary sizes)
|
||||
- **How**: Inspect font sizes — they should match scale values (12/14/16/18/24/32/48)
|
||||
- **Failing looks like**: Random sizes like 15px, 19px, 22px that aren't in the scale
|
||||
|
||||
## Color & Theme (5 items)
|
||||
|
||||
### 7. Semantic Color Tokens Only
|
||||
- **Check**: No hardcoded hex/rgb values in component code
|
||||
- **How**: Search for `#[0-9a-f]` or `rgb(` in component files
|
||||
- **Failing looks like**: `background: #3b82f6` instead of `bg-primary` or `var(--primary)`
|
||||
|
||||
### 8. WCAG AA Contrast Met
|
||||
- **Check**: Normal text ≥ 4.5:1, large text ≥ 3:1, UI components ≥ 3:1
|
||||
- **How**: Run axe-core or Chrome DevTools contrast checker
|
||||
- **Failing looks like**: Light gray text on white background, low-contrast placeholders
|
||||
|
||||
### 9. Dark Mode Contrast Verified
|
||||
- **Check**: Contrast ratios pass in dark mode separately
|
||||
- **How**: Toggle dark mode, re-run contrast checks
|
||||
- **Failing looks like**: Passing in light mode but failing in dark (common with desaturated variants)
|
||||
|
||||
### 10. Color Not Sole Information Channel
|
||||
- **Check**: Error, success, warning states use icon + text alongside color
|
||||
- **How**: View the page in grayscale (browser DevTools → Rendering → Emulate vision deficiency)
|
||||
- **Failing looks like**: Red border on error field with no icon or text explanation
|
||||
|
||||
### 11. Dark Mode Visual Review
|
||||
- **Check**: All surfaces, borders, shadows, and text are legible in dark mode
|
||||
- **How**: Toggle dark mode and visually scan every component
|
||||
- **Failing looks like**: Invisible borders, washed-out shadows, or text-on-background collision
|
||||
|
||||
## Layout & Spatial (5 items)
|
||||
|
||||
### 12. Concentric Border Radius
|
||||
- **Check**: Outer radius = inner radius + padding on all nested rounded elements
|
||||
- **How**: Inspect nested cards, buttons-in-containers, input groups
|
||||
- **Failing looks like**: Inner and outer corners don't follow the same curvature — looks "off"
|
||||
|
||||
| Before | After | Why |
|
||||
|--------|-------|-----|
|
||||
| Parent `rounded-lg` (12px), child `rounded-lg` (12px), padding 8px | Parent `rounded-xl` (16px), child `rounded-md` (8px), padding 8px | 8 + 8 = 16 — radii now concentric |
|
||||
|
||||
### 13. Spacing Follows Scale
|
||||
- **Check**: All padding, margin, and gap values are multiples of 4px
|
||||
- **How**: Inspect spacing values — no 5px, 7px, 13px, 19px etc.
|
||||
- **Failing looks like**: Inconsistent spacing that makes the layout feel uneven
|
||||
|
||||
### 14. Hit Areas Meet Minimum
|
||||
- **Check**: All interactive elements have at least 44×44px clickable area
|
||||
- **How**: Use browser DevTools to measure element + padding dimensions
|
||||
- **Failing looks like**: Tiny icon buttons, close buttons, or links that are hard to tap on mobile
|
||||
|
||||
### 15. Shadows Over Borders
|
||||
- **Check**: Depth is created with layered box-shadows, not solid borders between sections
|
||||
- **How**: Look for `border: 1px solid` between content sections
|
||||
- **Failing looks like**: Hard dividing lines instead of natural depth transitions
|
||||
|
||||
### 16. Optical Alignment Verified
|
||||
- **Check**: Icons in buttons, play triangles, and asymmetric elements are optically centered
|
||||
- **How**: Squint at the element — does it look centered to the eye?
|
||||
- **Failing looks like**: A play triangle that's geometrically centered but looks shifted left
|
||||
|
||||
## Motion & Interaction (7 items)
|
||||
|
||||
### 17. Animation Frequency Appropriate
|
||||
- **Check**: High-frequency actions (keyboard shortcuts, command palette) have NO animation
|
||||
- **How**: Review the frequency table — occasional actions get standard animation, frequent actions get none
|
||||
- **Failing looks like**: A command palette with a 300ms open animation that feels sluggish after the 50th use
|
||||
|
||||
### 18. No `transition: all`
|
||||
- **Check**: Every transition specifies exact properties
|
||||
- **How**: Search for `transition: all` or `transition-property: all`
|
||||
- **Failing looks like**: Unintended properties animating (color, padding, border) causing jank
|
||||
|
||||
### 19. Custom Easing Curves Used
|
||||
- **Check**: UI animations use custom bezier curves, not built-in `ease`, `ease-in`, `ease-out`
|
||||
- **How**: Inspect transition/animation easing values
|
||||
- **Failing looks like**: Animations feel generic and lack punch
|
||||
|
||||
### 20. Enter Animations Split and Staggered
|
||||
- **Check**: Multi-element entrances use 30-80ms stagger between items
|
||||
- **How**: Watch page load or section reveal — elements should cascade, not appear all at once
|
||||
- **Failing looks like**: An entire section popping in as one block
|
||||
|
||||
### 21. Press Feedback on Buttons
|
||||
- **Check**: All pressable elements have subtle `scale(0.96-0.97)` on `:active`
|
||||
- **How**: Click and hold buttons — they should compress slightly
|
||||
- **Failing looks like**: Clicking a button with zero visual feedback
|
||||
|
||||
### 22. prefers-reduced-motion Respected
|
||||
- **Check**: Animations reduce/simplify when the user has reduced motion enabled
|
||||
- **How**: Enable reduced motion in OS settings, reload, check all animations
|
||||
- **Failing looks like**: Full animations playing for users who opted out
|
||||
|
||||
### 23. Hover States Gated
|
||||
- **Check**: Hover animations are behind `@media (hover: hover) and (pointer: fine)`
|
||||
- **How**: Test on touch device or emulate touch in DevTools
|
||||
- **Failing looks like**: Hover states triggering on tap on mobile, causing sticky hover effects
|
||||
|
||||
## Accessibility (7 items)
|
||||
|
||||
### 24. Keyboard Navigation Complete
|
||||
- **Check**: Every interactive element is reachable and operable with keyboard only
|
||||
- **How**: Unplug mouse, Tab through entire page, operate every control
|
||||
- **Failing looks like**: Unreachable buttons, inoperable dropdowns, trapped focus
|
||||
|
||||
### 25. Focus Rings Visible
|
||||
- **Check**: Every focusable element has a visible focus indicator
|
||||
- **How**: Tab through the page and verify each element shows focus
|
||||
- **Failing looks like**: `outline: none` with no replacement, invisible focus state
|
||||
|
||||
### 26. Semantic HTML Used
|
||||
- **Check**: `<button>` for actions, `<a>` for links, `<nav>` for navigation, proper heading hierarchy
|
||||
- **How**: Inspect the DOM — look for `<div onclick>` or `<span>` where buttons should be
|
||||
- **Failing looks like**: Divs with click handlers instead of buttons, missing landmarks
|
||||
|
||||
### 27. ARIA Labels on Icon Buttons
|
||||
- **Check**: Every icon-only button has `aria-label` describing its action
|
||||
- **How**: Inspect icon buttons in DevTools or run axe-core
|
||||
- **Failing looks like**: Screen reader announcing "button" with no context
|
||||
|
||||
### 28. Form Errors Accessible
|
||||
- **Check**: Error messages use `aria-live` or `role="alert"`, linked via `aria-describedby`
|
||||
- **How**: Submit an invalid form, check screen reader announces errors
|
||||
- **Failing looks like**: Visual error message that screen reader users never hear
|
||||
|
||||
### 29. Images Have Alt Text
|
||||
- **Check**: Meaningful images have descriptive `alt`, decorative images have `alt=""`
|
||||
- **How**: Search for `<img>` without `alt` attribute
|
||||
- **Failing looks like**: Screen reader announcing file names or nothing for important images
|
||||
|
||||
### 30. Skip Link Present
|
||||
- **Check**: First focusable element is "Skip to main content" link
|
||||
- **How**: Tab once on page load — skip link should appear
|
||||
- **Failing looks like**: Keyboard users forced to Tab through entire header/nav on every page
|
||||
|
||||
## Quick Pass/Fail Summary
|
||||
|
||||
Use this table to record results:
|
||||
|
||||
| # | Item | Pass | Notes |
|
||||
|---|------|------|-------|
|
||||
| 1 | Font smoothing | | |
|
||||
| 2 | text-wrap: balance | | |
|
||||
| 3 | text-wrap: pretty | | |
|
||||
| 4 | tabular-nums | | |
|
||||
| 5 | Line length | | |
|
||||
| 6 | Type scale | | |
|
||||
| 7 | Semantic tokens | | |
|
||||
| 8 | WCAG contrast | | |
|
||||
| 9 | Dark mode contrast | | |
|
||||
| 10 | Color not sole channel | | |
|
||||
| 11 | Dark mode visual | | |
|
||||
| 12 | Concentric radius | | |
|
||||
| 13 | Spacing scale | | |
|
||||
| 14 | Hit areas | | |
|
||||
| 15 | Shadows over borders | | |
|
||||
| 16 | Optical alignment | | |
|
||||
| 17 | Animation frequency | | |
|
||||
| 18 | No transition: all | | |
|
||||
| 19 | Custom easing | | |
|
||||
| 20 | Staggered enter | | |
|
||||
| 21 | Press feedback | | |
|
||||
| 22 | Reduced motion | | |
|
||||
| 23 | Hover gated | | |
|
||||
| 24 | Keyboard nav | | |
|
||||
| 25 | Focus rings | | |
|
||||
| 26 | Semantic HTML | | |
|
||||
| 27 | ARIA labels | | |
|
||||
| 28 | Form errors | | |
|
||||
| 29 | Alt text | | |
|
||||
| 30 | Skip link | | |
|
||||
@@ -0,0 +1,48 @@
|
||||
# caveman
|
||||
|
||||
Talk like smart caveman. Same brain, fewer tokens.
|
||||
|
||||
## What it does
|
||||
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
|
||||
Six intensity levels:
|
||||
|
||||
| Level | What change |
|
||||
|-------|-------------|
|
||||
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
|
||||
| `full` | Default. Drop articles, fragments OK, short synonyms. |
|
||||
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
|
||||
| `wenyan-lite` | Classical Chinese register, light compression. |
|
||||
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
|
||||
| `wenyan-ultra` | Extreme classical compression. |
|
||||
|
||||
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
|
||||
|
||||
## How to invoke
|
||||
|
||||
```
|
||||
/caveman # full mode (default)
|
||||
/caveman lite # lighter compression
|
||||
/caveman ultra # extreme compression
|
||||
/caveman wenyan # classical Chinese
|
||||
stop caveman # back to normal prose
|
||||
```
|
||||
|
||||
## Example output
|
||||
|
||||
Question: "Why does my React component re-render?"
|
||||
|
||||
Normal prose:
|
||||
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
|
||||
|
||||
Caveman (full):
|
||||
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
|
||||
|
||||
Caveman (ultra):
|
||||
> Inline obj prop → new ref → re-render. `useMemo`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
|
||||
- [Caveman README](../../README.md) — repo overview, install, benchmarks
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman when:
|
||||
- Security warnings
|
||||
- Irreversible action confirmations
|
||||
- Multi-step sequences where fragment order or omitted conjunctions risk misread
|
||||
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
|
||||
- User asks to clarify or repeats question
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: fuck-slop
|
||||
description: >
|
||||
De-slop pass for any text: detects and erases the statistical fingerprints of
|
||||
AI writing (negative parallelism / "not X but Y", em-dash abuse, rule-of-three,
|
||||
false ranges, puffery vocabulary, uniform cadence, hedged both-sidesing) and
|
||||
rewrites the text into its target register — academic article, tweet, reddit
|
||||
post, email, blog, anything between. Use when the user says "fuck slop",
|
||||
"f*ck slop", "deslop", "de-slop this", "remove the AI tells", "humanize this",
|
||||
"make this not sound like AI", or invokes /fuck-slop. Also use before
|
||||
publishing any agent-drafted prose.
|
||||
---
|
||||
|
||||
# F*ck Slop
|
||||
|
||||
Strip every mark of AI writing from a text and make it good in its genre. Not "make it pass a detector" — make it read like a specific person with a specific point wrote it for a specific audience.
|
||||
|
||||
## Why this is a loop, not a style guide
|
||||
|
||||
The worst tells — above all the **"not X but Y"** family — are not vocabulary mistakes. They are emergent properties of how LLMs generate text: preference tuning rewards balanced, contrastive, comprehensive-sounding framing, so the contrast move is baked into the model's priors. Two consequences drive this skill's architecture:
|
||||
|
||||
1. **You cannot reliably see your own slop.** The same priors that produce the pattern make it invisible on re-read. Detection must be mechanical — regex against a fixed catalog — never "does this look AI to me?"
|
||||
2. **Rewriting reintroduces slop.** Ask a model to remove "it's not just X, it's Y" and it produces "this is less about X than Y" — the same move in a wig. So every rewrite gets re-scanned, and the loop runs until the scan is clean.
|
||||
|
||||
Workflow: **Scan → Diagnose → Rewrite by meaning → Re-scan → (repeat) → Register check.**
|
||||
|
||||
## Phase 0: Fix the target
|
||||
|
||||
Before touching the text, establish:
|
||||
|
||||
- **Genre and venue** — academic article, tweet, reddit post, LinkedIn, email, blog, docs, marketing. If not stated and not obvious from the text, ask. Genre decides which tells are fatal and what "good" means; see [references/voices.md](references/voices.md).
|
||||
- **Audience and stance** — who reads it, and what the author actually claims. Slop is what fills the space where a claim should be; you cannot remove it without knowing the claim.
|
||||
- **Constraints** — length limits, required citations, house style.
|
||||
|
||||
## Phase 1: Mechanical scan
|
||||
|
||||
Run the detection patterns from [references/tells.md](references/tells.md) against the text. If the text is in a file (or you can write it to a temp file), run the grep commands in that reference literally — the catalog is written as runnable `grep -Ein` patterns. Otherwise apply each pattern by hand, line by line.
|
||||
|
||||
Produce a finding list: line/sentence, matched pattern, tell category. Also run the two structural checks that regex can't fully catch:
|
||||
|
||||
- **Cadence**: flag any run of 3+ consecutive sentences within ±4 words of the same length, and any paragraph where every sentence has the same shape (subject–verb–elaboration).
|
||||
- **Formatting**: bold scattered through prose, emoji-decorated headers or bullets, "**Term:** definition" bullet lists, headers on a text too short to need them, a tidy intro–three-points–conclusion skeleton.
|
||||
|
||||
Report the findings to the user as a short table before rewriting (category, count, worst example). This is the diagnosis; the user should see what was wrong.
|
||||
|
||||
## Phase 2: Rewrite by meaning, not by frame
|
||||
|
||||
Go finding by finding. The cardinal rule: **never fix a pattern by paraphrasing the pattern.** Fix it by deciding what the sentence actually asserts, then asserting that.
|
||||
|
||||
### The "not X but Y" family — three-way triage
|
||||
|
||||
Every negative parallelism gets exactly one of these treatments:
|
||||
|
||||
1. **The negation is a strawman** (nobody believes X). Delete the X half entirely and assert Y directly, with whatever evidence the text has.
|
||||
- *"It's not just a tool, it's a fundamental shift in how teams work"* → *"Teams that adopted it stopped holding standups within a month."*
|
||||
2. **The contrast is real** (people genuinely hold X). Then earn it: name who holds X, say concretely why Y beats it. A real contrast survives being made specific; slop doesn't.
|
||||
3. **The sentence asserts nothing** (the contrast is decoration on an empty claim). Delete the whole sentence. Most cases are this one.
|
||||
|
||||
Banned escape hatches — these are the same move and count as new findings: "less about X than Y", "X matters, but Y matters more", "the real X is Y", "the question isn't X, it's Y", "X? Y." (rhetorical-question variant), and the em-dash variant "— not X, but Y".
|
||||
|
||||
### Everything else
|
||||
|
||||
- **Puffery and inflated vocabulary** (pivotal, seismic, testament, tapestry, landscape, delve…): replace with the plain word, or with the concrete fact the puffery was hiding. "Plays a vital role in" → "does".
|
||||
- **Rule-of-three lists**: keep the strongest item, cut the rest — unless all three carry distinct information, in which case keep them and break the rhythm (different lengths, different syntax).
|
||||
- **False ranges** ("from X to Y"): if you can't name a meaningful midpoint between X and Y, it's not a range — name the two things or cut one.
|
||||
- **Hedged both-sidesing** ("it's worth noting", auto-counterpoints, "while X, it's also true that Y"): commit. One opinion, stated, owned. A counterpoint stays only if the author genuinely concedes it.
|
||||
- **Uniform cadence**: vary deliberately. Follow a long sentence with a short one. Fragments are legal. Don't apply a formula (alternating long/short is its own tell) — read the paragraph aloud and break wherever the rhythm is metronomic.
|
||||
- **Low specificity**: replace "many companies" / "studies show" / "recent research" with the actual names, numbers, and dates — **only from the source text, the conversation, or verifiable research you actually do**. Never invent specifics. If the author needs to supply one, leave a marked placeholder: `[ADD: which study?]`.
|
||||
- **Stock skeleton**: kill throat-clearing openers ("In today's fast-paced world…"), summary conclusions ("In conclusion… Ultimately…"), and engagement-bait endings ("What do you think?"). Start where the point starts; stop when it's made.
|
||||
|
||||
### What not to do — overcorrection is also slop
|
||||
|
||||
- No fake typos, forced slang, or manufactured "voice". Humanizer-tool output is its own genre of slop.
|
||||
- Em dashes are not banned. Humans use them. The tell is density and the double-dash "— not X, but —" move. Budget: at most one em dash per ~150 words, never two in a sentence.
|
||||
- Don't trade precision for personality in academic or technical text. There, de-slopping means cutting puffery and committing to claims — not adding attitude.
|
||||
- Preserve the author's meaning, claims, and facts exactly. This is a style pass, not a content edit. Flag, don't silently fix, anything that looks factually wrong.
|
||||
|
||||
## Phase 3: Verify loop
|
||||
|
||||
Re-run the full Phase 1 scan **on your rewritten text**. This step is not optional and not a formality — expect your own rewrite to contain new tells, because the model writing it has the same priors that created them. Fix and re-scan until a pass produces zero pattern hits and the cadence check passes. Cap at 4 passes; if a pattern survives 4 passes, rewrite that sentence from scratch starting from its bare claim ("what fact or opinion is this sentence for?").
|
||||
|
||||
## Phase 4: Register check
|
||||
|
||||
Check the clean text against its genre profile in [references/voices.md](references/voices.md): right length, right formality, right person, genre-specific tells gone (e.g. on reddit: no bold, no bullet essay; in academic prose: no first-person hot takes added). Then the final test — read it aloud. Anywhere you wouldn't say it to the actual audience, rewrite that sentence.
|
||||
|
||||
Deliver: the rewritten text, plus a brief change log (categories fixed, counts, and number of verify passes it took).
|
||||
@@ -0,0 +1,171 @@
|
||||
# AI-Writing Tell Catalog
|
||||
|
||||
Detection patterns for the F*ck Slop scan. Patterns are written for `grep -Ein` (extended regex, case-insensitive, line numbers) so they can be run literally against a file:
|
||||
|
||||
```bash
|
||||
grep -Ein -f /dev/stdin draft.txt <<'PATTERNS'
|
||||
<paste patterns from a section below, one per line>
|
||||
PATTERNS
|
||||
```
|
||||
|
||||
When the text only exists in conversation, apply each pattern by hand. A match is a *finding*, not an automatic deletion — every finding goes through the Phase 2 triage in SKILL.md. Density matters: one em dash is nothing; one em dash plus a negative parallelism plus "delve" in the same paragraph is a verdict.
|
||||
|
||||
## 1. Negative parallelism — the "not X but Y" family
|
||||
|
||||
The highest-priority category. LLMs reach for the negation-then-assertion move roughly once a paragraph; humans use it occasionally and deliberately. It is an emergent generative habit, so expect it to reappear in paraphrased form after every rewrite pass — that is why the scan loops.
|
||||
|
||||
```
|
||||
not (just|only|merely|simply|solely) [^.;]{2,80}(but|it'?s| — )
|
||||
isn'?t (just|only|merely|simply|about)
|
||||
it'?s not (a|an|the|that|about|just) [^.;]{2,80}(it'?s|but)
|
||||
(is|was|are|were)n'?t about [^.;]{2,60}\. (it|this|that)'?s about
|
||||
less about [^.;]{2,60}(than|and more about)
|
||||
more than (just|a mere|simply)
|
||||
not because [^.;]{2,80}but because
|
||||
the (question|point|issue|problem|goal|real [a-z]+) is(n'?t| not) (whether|about|just|if)
|
||||
(doesn'?t|don'?t|didn'?t|won'?t) (just|merely|simply) [^.;]{2,80}(it|they|he|she|we)
|
||||
no [a-z]+, no [a-z]+(, no [a-z]+)?[,.]? just
|
||||
— not [^—.;]{2,60}, but
|
||||
not only [^.;]{2,80}but (also )?
|
||||
we'?re not (just )?(talking about|looking at|dealing with)
|
||||
gone are the days
|
||||
(here|this)'?s the (thing|kicker|catch|twist)
|
||||
```
|
||||
|
||||
Rhetorical-question variant (regex-resistant; check by hand): a one-line question immediately answered by a one-word or one-clause sentence. *"The result? Chaos."* / *"Sound familiar?"*
|
||||
|
||||
## 2. Puffery and inflated vocabulary
|
||||
|
||||
Single words that spike in LLM output. Each is fine in isolation; two or more per page is a finding. The fix is the plain word or the concrete fact the word was hiding.
|
||||
|
||||
```
|
||||
\b(delve|delving)\b
|
||||
\btapestry\b
|
||||
\b(testament|stands as)\b
|
||||
\bseamless(ly)?\b
|
||||
\b(pivotal|paramount|crucial)\b
|
||||
\bunderscore(s|d)?\b
|
||||
\b(landscape|realm|sphere) of\b
|
||||
\bnavigat(e|ing) the\b
|
||||
\bfoster(s|ing)?\b
|
||||
\bleverage(s|d)?\b
|
||||
\bmeticulous(ly)?\b
|
||||
\bintricate\b
|
||||
\bboasts\b
|
||||
\bgame.?chang(er|ing)\b
|
||||
\b(seismic|monumental|transformative) (shift|change)\b
|
||||
\bunwavering\b
|
||||
\bcommendable\b
|
||||
\belevate(s|d)? (the|your)\b
|
||||
\bshowcas(e|es|ing)\b
|
||||
\bresonate(s|d)?\b
|
||||
\bcompelling\b
|
||||
\brich (cultural )?(heritage|history|tradition)\b
|
||||
\bvibrant\b
|
||||
\bplays? a (vital|key|crucial|pivotal) role\b
|
||||
\bdeep(er)? dive\b
|
||||
\bunlock(s|ing)? (the|your)\b
|
||||
\bharness(es|ing)? the\b
|
||||
\bembark(s|ed|ing)? on\b
|
||||
\bever.?(evolving|changing)\b
|
||||
\bfast.?paced (world|environment)\b
|
||||
\bin today'?s\b
|
||||
\bat the end of the day\b
|
||||
\bwhen it comes to\b
|
||||
\bcutting.?edge\b
|
||||
\brobust\b
|
||||
\bholistic\b
|
||||
\bsynergy\b
|
||||
\bempower(s|ing|ment)?\b
|
||||
```
|
||||
|
||||
## 3. Hedging, both-sidesing, throat-clearing
|
||||
|
||||
The tell is reflexive balance: every claim gets a softener, every opinion gets a counterpoint. Commit or cut.
|
||||
|
||||
```
|
||||
it'?s (worth|important) (to note|noting|to remember|to consider)
|
||||
(that|it) (being )?said,
|
||||
while (it'?s|this is) (true|important)
|
||||
arguably
|
||||
in many ways
|
||||
to some (extent|degree)
|
||||
on the other hand
|
||||
at its core
|
||||
in essence
|
||||
essentially,
|
||||
ultimately,
|
||||
in conclusion
|
||||
in summary
|
||||
to sum(marize| up)
|
||||
overall,
|
||||
in the end,
|
||||
needless to say
|
||||
as (we|you) (can see|know|all know)
|
||||
let'?s (dive|unpack|explore|take a (look|closer look))
|
||||
whether you('re| are) [^.;]{2,60} or
|
||||
```
|
||||
|
||||
## 4. False ranges and rule-of-three
|
||||
|
||||
**False range** — a "from X to Y" with no actual spectrum between X and Y:
|
||||
|
||||
```
|
||||
from [^.;]{3,50} to [^.;]{3,50}
|
||||
```
|
||||
|
||||
Triage by hand: if you can name a meaningful midpoint, it's a real range and stays. If X and Y are just two loosely related examples, name them plainly or cut one.
|
||||
|
||||
**Rule of three** — LLMs default to triplets to make thin analysis look thorough. Regex only catches the simplest shape; check lists by hand too.
|
||||
|
||||
```
|
||||
\b\w+, \w+, and \w+[.!?]
|
||||
\b(\w+ \w+), (\w+ \w+), and (\w+ \w+)
|
||||
```
|
||||
|
||||
Triage: keep the strongest item, cut the rest — or keep all three only if each carries distinct information, and then break the rhythm.
|
||||
|
||||
## 5. Punctuation and formatting
|
||||
|
||||
Em dash: not banned — humans use it. Findings are about **density** and the contrast move:
|
||||
|
||||
- More than ~1 em dash per 150 words.
|
||||
- Two em dashes in one sentence.
|
||||
- `— not X, but Y` (already in section 1).
|
||||
- Em dash used for punchy emphasis where a comma works: `[a-z] — [a-z][^—]{1,25}\.$`
|
||||
|
||||
Other formatting tells (check by hand; most regexes here are layout-dependent):
|
||||
|
||||
- **Bold scattered through prose** like a textbook highlighting itself: `\*\*[^*]{2,40}\*\*` appearing more than ~once per 3 paragraphs of body prose.
|
||||
- **"Term: definition" bullets**: `^[-*] +\*\*[^*]+:?\*\*:? ` — the signature LLM list shape.
|
||||
- **Emoji headers/bullets** (🚀, ✅, 💡): needs PCRE, not `-E` — `LC_ALL=C.UTF-8 grep -Pn '^\s*[-*#]+\s.*[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]' draft.txt`.
|
||||
- **Headers on short texts** — section headers on anything under ~400 words.
|
||||
- **The tidy skeleton** — intro that previews three points, three matched sections, conclusion that restates them. Resolves too neatly; real writing has loose ends.
|
||||
- **Numbered lists where a paragraph would do.**
|
||||
- Curly quotes/apostrophes in a context where the author types straight ones (mixed within one text is the stronger tell).
|
||||
|
||||
## 6. Cadence and statistical shape
|
||||
|
||||
No regex; measure or eyeball.
|
||||
|
||||
- **Uniform sentence length** (the single strongest current tell): a run of 3+ consecutive sentences within ±4 words of each other, paragraph after paragraph of 18–24-word sentences. Quick measurement on a file:
|
||||
|
||||
```bash
|
||||
tr '\n' ' ' < draft.txt | sed 's/[.!?] /\n/g' | awk '{print NF}'
|
||||
```
|
||||
|
||||
Human prose mixes 4-word sentences with 30-word sentences. Variance should be obvious at a glance.
|
||||
- **Uniform sentence shape**: every sentence opens subject-first; no fragments, no questions, no inversions.
|
||||
- **Uniform paragraph length**: every paragraph 3–4 sentences.
|
||||
- **Low specificity**: "many companies", "studies show", "experts agree", "recent research", "various factors" — generic where a human who knew the material would name names, numbers, dates. (Fix only with real specifics; never invented ones.)
|
||||
- **No friction**: nothing colloquial, no aside, no opinion held without a softener, nothing that risks being disagreed with.
|
||||
|
||||
## 7. Genre-specific instant tells
|
||||
|
||||
Covered in detail in [voices.md](voices.md); the headline items:
|
||||
|
||||
- **Reddit/forums**: bold mid-comment, bullet-pointed comments, "Hope this helps!", perfectly balanced takes.
|
||||
- **Tweets/X**: "🧵", "Let that sink in", line-broken one-clause-per-line cadence, ending on a question to drive engagement.
|
||||
- **LinkedIn**: one-sentence paragraphs stacked vertically, "Agree?", the not-X-but-Y move (its natural habitat).
|
||||
- **Academic**: "delve", "novel insights", puffed significance claims ("crucial implications for the field"), citation-free superlatives.
|
||||
- **Email**: "I hope this email finds you well", restating the recipient's question back at them, three-paragraph symmetry for a one-line answer.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Register Guide
|
||||
|
||||
What "good" means per genre, what tells are fatal there, and what the de-slopped text should sound like. Use in Phase 0 (fix the target) and Phase 4 (register check). Two universal rules first:
|
||||
|
||||
1. **Voice comes from commitment, not decoration.** A text sounds human when it asserts specific things a specific person believes, at the level of detail only someone who did the work would know. Slang, typos, and "personality" sprinkled on top do not produce this and read as humanizer-tool output.
|
||||
2. **Match the author, not a persona.** If the user supplied earlier writing or a draft with their own phrasing in it, keep their words wherever they survive the scan. De-slopping someone into a generic "casual" voice is just different slop.
|
||||
|
||||
## Academic article / paper
|
||||
|
||||
- **Goal**: precise claims, honest hedges, dense information. Formality stays; puffery goes.
|
||||
- **Fatal tells here**: "delve", "novel", inflated significance ("crucial implications", "paradigm shift"), rule-of-three in abstracts, negative parallelism in intros ("X is not merely a tool but a fundamental…"), em-dash chains.
|
||||
- **De-slop moves**: replace significance puffery with the actual finding and effect size. Hedges must be calibrated, not reflexive — "may" because the evidence is genuinely uncertain, not as seasoning. Keep passive voice where the venue expects it; do not inject first person or attitude. Numbers, conditions, and citations beat adjectives.
|
||||
- **Cadence**: long sentences are fine and normal; the tell is uniformity, not length. Vary clause structure.
|
||||
|
||||
## Tweet / X post
|
||||
|
||||
- **Goal**: one idea, said like a person, under the limit.
|
||||
- **Fatal tells here**: "🧵", "Let that sink in", "Read that again", one-clause-per-line stacking, ending on an engagement question, hashtag clusters, the not-X-but-Y move compressed into 200 characters.
|
||||
- **De-slop moves**: cut to the single claim. Lowercase is fine if that's the author's habit. No setup ("Hot take:") — just the take. A tweet that states an opinion without insurance reads human; a tweet that balances itself does not.
|
||||
|
||||
## Reddit post / comment
|
||||
|
||||
- **Goal**: reads like a knowledgeable person typing in a text box, because that's what reddit is.
|
||||
- **Fatal tells here** (reddit users are the most slop-sensitive audience on the internet): **any** bold in a comment, bullet-point essays, headers, "Hope this helps!", "Great question!", symmetric pro/con framing, em-dash density, perfect paragraphing.
|
||||
- **De-slop moves**: plain paragraphs, contractions, direct answers first. Mild hedges are human here ("iirc", "I might be wrong but") — but only the author's own. Concrete personal detail ("ran into this on a 2019 Outback") is the strongest human marker; never fabricate it, ask the author or drop it.
|
||||
|
||||
## LinkedIn post
|
||||
|
||||
- **Goal**: professional but specific. The platform's native style is so slop-adjacent that the bar is: would a colleague forward this without cringing?
|
||||
- **Fatal tells here**: stacked one-line paragraphs, "Agree?", "Let's connect", broetry rhythm, negative parallelism (this is its natural habitat — scan twice), rule-of-three value statements, "I'm humbled to announce".
|
||||
- **De-slop moves**: write actual paragraphs. Lead with the concrete event or number, not the lesson. One lesson max, stated once, not echoed in a closer.
|
||||
|
||||
## Email
|
||||
|
||||
- **Goal**: shortest text that's still warm enough for the relationship.
|
||||
- **Fatal tells here**: "I hope this email finds you well", restating the recipient's email back to them, three symmetric paragraphs wrapping a one-line answer, "Please don't hesitate to reach out".
|
||||
- **De-slop moves**: answer in the first sentence. Greeting and sign-off match the existing thread's register. Cut every sentence whose only job is politeness padding except one, if the relationship needs it.
|
||||
|
||||
## Blog post / newsletter / essay
|
||||
|
||||
- **Goal**: a person with a view, walking the reader through it.
|
||||
- **Fatal tells here**: "In today's fast-paced world" openers, intro-that-previews-three-sections skeleton, "In conclusion", bold-scattered prose, section headers every two paragraphs, engagement-bait closers.
|
||||
- **De-slop moves**: open inside the subject (a scene, a number, a claim). Let structure follow the argument instead of a template — real essays have asymmetric sections and loose ends. First person and digressions are allowed; they are how essays sound human. Keep headers only when the piece is long enough to need navigation.
|
||||
|
||||
## Marketing / landing copy
|
||||
|
||||
- **Goal**: concrete benefit, named audience, zero filler.
|
||||
- **Fatal tells here**: "seamless", "unlock", "empower", "game-changing", "effortless", rule-of-three feature triplets, false ranges ("from startups to enterprises"), every header a not-X-but-Y.
|
||||
- **De-slop moves**: replace each abstraction with the mechanism or the number ("Set up in 4 minutes" beats "seamless onboarding"). One verb per claim. Specificity is the whole game; if no specifics exist, that's a product-marketing problem the text can't fix — say so.
|
||||
|
||||
## Technical docs / README
|
||||
|
||||
- **Goal**: the reader gets unblocked fast.
|
||||
- **Fatal tells here**: "robust", "powerful", "blazingly fast" without benchmarks, "simply"/"just" before steps that aren't, marketing voice in reference material, emoji section headers.
|
||||
- **De-slop moves**: imperative mood, exact commands, exact versions, expected output. Adjectives almost to zero. Lists are fine here — docs are the one genre where "Term: definition" bullets are legitimate structure, so don't strip them; strip the puffery inside them.
|
||||
|
||||
## Academic-adjacent: cover letters, statements, grant prose
|
||||
|
||||
- **Goal**: claims about the author backed by evidence, in formal register.
|
||||
- **Fatal tells here**: "passionate", "deeply committed", "unique perspective", testament/tapestry vocabulary, rule-of-three trait lists, every paragraph ending with a not-X-but-Y synthesis.
|
||||
- **De-slop moves**: every trait claim becomes an event ("I led X, which produced Y"). Keep formality; cut self-puffery. The reader has read ten thousand of these — only specifics differentiate.
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Calibrated grilling session for stress-testing a plan, design, idea, or decision. First assesses the user's topic knowledge, confidence, and desired pressure level, then asks one question at a time with recommended answers. Use when user says "grill me", "stress-test this", "challenge my plan", "interview me", or wants a plan probed without being overwhelmed.
|
||||
---
|
||||
|
||||
# Grill Me
|
||||
|
||||
Interview the user until the plan is clear, defensible, and ready for action.
|
||||
|
||||
This is not hostile debate. It is calibrated pressure. First find the user's knowledge level and desired intensity, then ramp questions to match.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Ask one question at a time.
|
||||
- Give a recommended answer for every question.
|
||||
- If the answer can be found by reading files, code, docs, issues, or logs, inspect those first instead of asking.
|
||||
- Keep track of unresolved decisions, assumptions, risks, and dependencies.
|
||||
- Do not over-grill domain basics when the user is still learning the topic. Teach the missing frame briefly, then ask the next useful question.
|
||||
- Do not under-grill confident experts. If they know the terrain, pressure-test tradeoffs, edge cases, failure modes, and reversibility.
|
||||
- Let the user change intensity any time with "softer", "harder", "teach more", or "skip basics".
|
||||
|
||||
## Phase 1: Frame The Target
|
||||
|
||||
Identify what should be grilled before asking about comfort. If the topic is not clear, ask:
|
||||
|
||||
> What plan, design, or decision should I grill?
|
||||
>
|
||||
> Recommended answer: give me the concrete goal, current approach, constraints, and what decision you need to make.
|
||||
|
||||
If context already contains the plan, summarize it in 3-6 bullets and ask for correction:
|
||||
|
||||
> I think target is: [...]
|
||||
>
|
||||
> Recommended answer: "Yes, grill that" or "Adjust: ..."
|
||||
|
||||
## Phase 2: Calibration
|
||||
|
||||
Before grilling the topic, ask a short calibration question unless the user's level is already obvious from context.
|
||||
|
||||
Ask:
|
||||
|
||||
> Before I grill the plan: what is your current comfort with this topic, and how hard do you want the pressure?
|
||||
>
|
||||
> Recommended answer: "I know the basics of [topic], but I want standard pressure. Explain missing concepts briefly, then keep pushing."
|
||||
|
||||
Use the user's answer to set two dials:
|
||||
|
||||
### Knowledge Level
|
||||
|
||||
- **New** - user lacks core vocabulary or model of the domain.
|
||||
- **Working** - user understands basics and can discuss tradeoffs.
|
||||
- **Expert** - user knows domain deeply and wants sharper critique.
|
||||
|
||||
### Pressure Level
|
||||
|
||||
- **Light** - clarify goals, constraints, and missing context.
|
||||
- **Standard** - challenge assumptions, tradeoffs, and execution path.
|
||||
- **Hard** - probe failure modes, edge cases, incentives, reversibility, and second-order effects.
|
||||
|
||||
If the user does not answer calibration, default to:
|
||||
|
||||
- Knowledge: **Working**
|
||||
- Pressure: **Standard**
|
||||
|
||||
## Phase 3: Build The Decision Map
|
||||
|
||||
Create a private decision map while asking questions one at a time:
|
||||
|
||||
- Goal - what success means.
|
||||
- User or customer - who this affects.
|
||||
- Constraints - time, money, stack, team, policy, risk.
|
||||
- Options - obvious alternatives and why current option wins.
|
||||
- Dependencies - what must be true first.
|
||||
- Risks - what breaks, gets expensive, or becomes irreversible.
|
||||
- Validation - how user will know it worked.
|
||||
- Rollback - how to undo or recover.
|
||||
|
||||
Do not dump the full map unless user asks. Use it to choose the next question.
|
||||
|
||||
## Phase 4: Question Ladder
|
||||
|
||||
Move through this ladder. Stop early if the plan becomes clear enough or user asks to stop.
|
||||
|
||||
### 1. Goal Fit
|
||||
|
||||
Questions:
|
||||
|
||||
- What outcome matters most?
|
||||
- What would make this not worth doing?
|
||||
- What problem are we solving, and for whom?
|
||||
|
||||
### 2. Constraint Reality
|
||||
|
||||
Questions:
|
||||
|
||||
- What hard constraint cannot move?
|
||||
- What resource bottleneck decides the plan?
|
||||
- What assumption would kill the plan if false?
|
||||
|
||||
### 3. Option Pressure
|
||||
|
||||
Questions:
|
||||
|
||||
- What are the top two alternatives?
|
||||
- Why this approach over the boring one?
|
||||
- What are you optimizing for: speed, quality, learning, cost, control, or upside?
|
||||
|
||||
### 4. Execution Path
|
||||
|
||||
Questions:
|
||||
|
||||
- What is the smallest useful version?
|
||||
- What has to happen first?
|
||||
- What can be deferred without harming the goal?
|
||||
|
||||
### 5. Failure Modes
|
||||
|
||||
Questions:
|
||||
|
||||
- How does this fail in production or real use?
|
||||
- What edge case would embarrass the plan?
|
||||
- What part is hardest to observe once it breaks?
|
||||
|
||||
### 6. Validation
|
||||
|
||||
Questions:
|
||||
|
||||
- What test, metric, screenshot, demo, or user behavior proves this works?
|
||||
- What would you check before trusting it?
|
||||
- What does done mean in observable terms?
|
||||
|
||||
### 7. Reversibility
|
||||
|
||||
Questions:
|
||||
|
||||
- What decision here is hardest to undo?
|
||||
- What backup, migration, rollback, or escape hatch exists?
|
||||
- What should be logged as an ADR or explicit tradeoff?
|
||||
|
||||
## Pressure Adaptation
|
||||
|
||||
### If Knowledge Is New
|
||||
|
||||
- Define one missing concept in 2-4 sentences before asking.
|
||||
- Avoid jargon unless you define it.
|
||||
- Ask fewer branching questions.
|
||||
- Focus on goals, constraints, and first principles.
|
||||
- Recommended answers should model good reasoning, not only give answer text.
|
||||
|
||||
### If Knowledge Is Working
|
||||
|
||||
- Ask normal tradeoff questions.
|
||||
- Surface alternatives.
|
||||
- Push for validation and smallest useful version.
|
||||
- Challenge vague words like "simple", "scalable", "good", "clean", or "fast".
|
||||
|
||||
### If Knowledge Is Expert
|
||||
|
||||
- Skip basics.
|
||||
- Ask sharper counterfactuals.
|
||||
- Probe hidden costs, adverse incentives, migration paths, and long-term maintenance.
|
||||
- Ask what evidence would change their mind.
|
||||
|
||||
### If Pressure Is Light
|
||||
|
||||
- Keep questions clarifying.
|
||||
- Use supportive framing.
|
||||
- Stop after top ambiguities are resolved.
|
||||
|
||||
### If Pressure Is Standard
|
||||
|
||||
- Challenge assumptions and tradeoffs.
|
||||
- Keep moving until implementation path is concrete.
|
||||
|
||||
### If Pressure Is Hard
|
||||
|
||||
- Be direct.
|
||||
- Name weak reasoning.
|
||||
- Ask about unpleasant edge cases.
|
||||
- Demand observable validation.
|
||||
- Still ask one question at a time.
|
||||
|
||||
## Recommended Answer Format
|
||||
|
||||
Every question includes:
|
||||
|
||||
```text
|
||||
Question: ...
|
||||
Recommended answer: ...
|
||||
Why it matters: ...
|
||||
```
|
||||
|
||||
Keep "Why it matters" to one sentence.
|
||||
|
||||
## When To Stop
|
||||
|
||||
Stop grilling when one of these is true:
|
||||
|
||||
- User says stop.
|
||||
- Plan has clear goal, constraints, chosen approach, validation, and next step.
|
||||
- Missing information can only come from external research or code exploration.
|
||||
- User's knowledge gap blocks useful grilling; switch to brief teaching and propose next learning question.
|
||||
|
||||
End with:
|
||||
|
||||
- Final decision or current best plan.
|
||||
- Remaining open questions.
|
||||
- Next concrete action.
|
||||
- Risks to watch.
|
||||
@@ -0,0 +1,582 @@
|
||||
---
|
||||
name: interface-kit
|
||||
description: |
|
||||
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
|
||||
design engineering philosophy, accessibility standards, animation principles, spatial design,
|
||||
typography, color systems, and component craft into a single actionable reference.
|
||||
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
|
||||
the HOW of implementation.
|
||||
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
|
||||
"frontend", "design", "polish UI", "implement design", "make it beautiful",
|
||||
"UI implementation", "component styling", "animation", "accessibility"
|
||||
---
|
||||
|
||||
# Interface Kit: Implementation Guide for Exceptional Interfaces
|
||||
|
||||
> If a DESIGN.md exists at the project root, its tokens and specifications override all defaults in this skill. This skill provides sensible defaults for when no design system exists, and implementation guidance that applies regardless.
|
||||
|
||||
> For deep dives on any section, see the reference files in this skill's `references/` directory.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Philosophy
|
||||
|
||||
Taste is trained, not innate. Study why great interfaces feel right. Deconstruct apps you admire — the spacing, the timing, the weight of a shadow. The gap between "fine" and "exceptional" is built from hundreds of micro-decisions that users feel but never consciously notice.
|
||||
|
||||
**Unseen details compound.** A single rounded corner, a single eased transition, a single well-chosen shadow — none of these matter alone. Together they become "a thousand barely audible voices singing in tune." The cumulative effect is what separates craft from output.
|
||||
|
||||
**Beauty is leverage.** Polish is not vanity. Good defaults, considered typography, and intentional motion are real differentiators. Users trust interfaces that feel cared for. Investors notice. Competitors can't easily replicate taste.
|
||||
|
||||
**Intentionality over intensity.** Both bold maximalism and refined minimalism work — what fails is the absence of a clear point of view. Every visual decision should trace back to a deliberate conceptual direction. If you can't articulate WHY a choice was made, reconsider it.
|
||||
|
||||
**Choose a direction and execute with precision.** Don't hedge between styles. A brutalist page committed fully will always outperform a page that's "a little bit of everything." Commit, then refine.
|
||||
|
||||
**NEVER produce generic "AI slop" aesthetics.** No gratuitous gradients on white backgrounds. No cookie-cutter hero sections with stock illustrations. No safe, forgettable layouts that could belong to any product. Every interface should have a point of view that makes it recognizable.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Priority Stack
|
||||
|
||||
When implementing UI, work through these priorities in order. Higher priorities are non-negotiable; lower priorities are polish that compounds quality.
|
||||
|
||||
| Priority | Level | What It Means |
|
||||
|----------|-------|---------------|
|
||||
| **Accessibility** | CRITICAL | Contrast 4.5:1, keyboard nav, ARIA semantics, visible focus rings. Ship nothing that excludes users. |
|
||||
| **Performance** | HIGH | WebP/AVIF images, lazy loading below fold, CLS < 0.1, transform-only animations on the compositor thread. |
|
||||
| **Typography** | HIGH | Font smoothing, text-wrap balance/pretty, tabular-nums for data, 65ch max line length. |
|
||||
| **Layout & Spatial** | HIGH | 4/8px grid, concentric border radius, optical alignment over geometric. |
|
||||
| **Color & Theme** | MEDIUM | HSL custom properties, semantic tokens, dark mode pairs tested separately. |
|
||||
| **Motion & Interaction** | MEDIUM | Frequency-based animation decisions, 150-300ms durations, ease-out default. |
|
||||
| **Polish & Details** | LOW | Layered shadows over borders, press feedback on buttons, staggered enter animations. |
|
||||
|
||||
Never skip a CRITICAL/HIGH item to chase a LOW item. A beautifully animated button that fails keyboard navigation is a net negative.
|
||||
|
||||
---
|
||||
|
||||
## 3. Aesthetic Direction
|
||||
|
||||
Before writing a single line of CSS, commit to a bold aesthetic direction. The most common failure mode in AI-generated UI is convergence on the same safe, forgettable look.
|
||||
|
||||
### Pick a Tone
|
||||
|
||||
Choose one and commit fully:
|
||||
|
||||
- **Brutally minimal** — generous whitespace, monospace type, stark contrast, near-zero decoration
|
||||
- **Maximalist chaos** — layered textures, clashing type scales, dense information, intentional visual noise
|
||||
- **Retro-futuristic** — CRT glow effects, monospace terminals, scan lines, neon on dark
|
||||
- **Organic / natural** — earth tones, rounded shapes, paper textures, hand-drawn accents
|
||||
- **Luxury / refined** — serif headlines, muted palettes, ample negative space, subtle gold or cream accents
|
||||
- **Editorial / magazine** — dramatic type hierarchy, full-bleed imagery, grid-breaking layouts
|
||||
- **Playful / bold** — bright primaries, chunky borders, exaggerated shadows, bouncy motion
|
||||
|
||||
### Match Complexity to Vision
|
||||
|
||||
Maximalist design demands elaborate code — layered backgrounds, complex grid structures, multiple font stacks. Minimalist design demands surgical precision — every pixel of spacing matters more when there's nothing to hide behind.
|
||||
|
||||
### The Ban List (When No DESIGN.md Exists)
|
||||
|
||||
When building without an existing design system, avoid these overused defaults that signal "AI-generated":
|
||||
|
||||
- **Fonts**: Inter, Roboto, Arial, system-ui as display fonts, Space Grotesk
|
||||
- **Colors**: Purple-to-blue gradients on white backgrounds
|
||||
- **Patterns**: Generic hero with centered text + CTA + stock illustration
|
||||
|
||||
Vary between light and dark themes, different font pairings, different aesthetic directions. Never converge on the same choices across projects.
|
||||
|
||||
### Visual Texture
|
||||
|
||||
Add depth through: gradient meshes, noise/grain overlays (`filter: url(#noise)`), layered transparencies, subtle background patterns, duotone image treatments.
|
||||
|
||||
**DESIGN.md overrides this entire section.** If DESIGN.md specifies Inter, use Inter. If it specifies purple gradients, use them. The ban list only applies when no design system exists and you're making aesthetic choices from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography Essentials
|
||||
|
||||
Typography is the single highest-leverage design element. Get it right and mediocre layouts still feel good. Get it wrong and nothing else saves it.
|
||||
|
||||
### Root Setup
|
||||
|
||||
```css
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
```
|
||||
|
||||
Apply font smoothing to the root layout. On macOS, the default sub-pixel rendering makes text appear heavier than the designer intended.
|
||||
|
||||
### Text Wrapping
|
||||
|
||||
```css
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p, li, dd, blockquote {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
```
|
||||
|
||||
`balance` distributes heading lines evenly. `pretty` avoids orphaned words in body text.
|
||||
|
||||
### Numeric Display
|
||||
|
||||
```css
|
||||
.data-value, .price, .counter, [data-numeric] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
```
|
||||
|
||||
Use `tabular-nums` for any number that updates dynamically — prices, counters, table columns. Without it, layout shifts as digit widths change.
|
||||
|
||||
### Scale and Rhythm
|
||||
|
||||
- **Base size**: 16px minimum for body text. Never go below 14px for any readable content.
|
||||
- **Line height**: 1.5-1.75 for body text, 1.1-1.3 for large headings.
|
||||
- **Max line length**: `max-width: 65ch` for body text. Long lines destroy readability.
|
||||
- **Type scale**: Pick a consistent scale and stick to it: 12 / 14 / 16 / 18 / 24 / 32 / 48 / 64.
|
||||
|
||||
### Font Pairing
|
||||
|
||||
Pair a distinctive display font with a refined body font. The display font carries personality; the body font carries readability. Use `font-weight` for hierarchy within a family:
|
||||
|
||||
- **Headings**: 600-700 (semibold to bold)
|
||||
- **Body**: 400 (regular)
|
||||
- **Labels / UI**: 500 (medium)
|
||||
|
||||
Always include font stack fallbacks:
|
||||
|
||||
```css
|
||||
--font-display: "Instrument Serif", "Georgia", serif;
|
||||
--font-body: "Söhne", "Helvetica Neue", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color & Theme
|
||||
|
||||
### HSL Custom Properties (shadcn Pattern)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
Define semantic tokens: primary, secondary, destructive, muted, accent, background, foreground. Reference colors by semantic name — never hardcode hex values in components.
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```css
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
/* ... desaturated, lighter tonal variants — NOT simply inverted */
|
||||
}
|
||||
```
|
||||
|
||||
Dark mode is not "invert colors." Use desaturated, lighter tonal variants. Backgrounds go dark but not pure black (`#000`). Text goes light but not pure white (`#fff`). Test contrast separately for dark mode — what passes in light may fail in dark.
|
||||
|
||||
### Contrast Requirements
|
||||
|
||||
- **WCAG AA minimum**: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular)
|
||||
- Never convey information by color alone — always pair with an icon, label, or pattern
|
||||
- Test with browser devtools contrast checker or axe-core
|
||||
|
||||
### Color Confidence
|
||||
|
||||
Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Pick one or two hero colors and let the rest of the palette recede. A confident palette has clear hierarchy; an uncertain palette spreads color evenly and feels flat.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spatial Design
|
||||
|
||||
### Concentric Border Radius
|
||||
|
||||
This is the single most common thing that makes nested UI elements feel "off":
|
||||
|
||||
```
|
||||
outer_radius = inner_radius + padding
|
||||
```
|
||||
|
||||
```css
|
||||
/* Correct: concentric */
|
||||
.card { border-radius: 16px; padding: 8px; }
|
||||
.card-inner { border-radius: 8px; } /* 16 - 8 = 8 */
|
||||
|
||||
/* Wrong: same radius on parent and child */
|
||||
.card { border-radius: 12px; }
|
||||
.card-inner { border-radius: 12px; } /* Looks bloated */
|
||||
```
|
||||
|
||||
When geometric centering looks off, align optically. Play/pause icons, dropdown carets, and asymmetric glyphs often need 1-2px manual nudges to look centered.
|
||||
|
||||
### Shadows Over Borders
|
||||
|
||||
Layer multiple transparent `box-shadow` values for natural depth instead of using borders:
|
||||
|
||||
```css
|
||||
.elevated {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 4px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
```
|
||||
|
||||
Multiple shadows at different spreads mimic how light works. A single hard shadow looks artificial.
|
||||
|
||||
### Image Outlines
|
||||
|
||||
Add a subtle inset outline to images and media for consistent depth against varied backgrounds:
|
||||
|
||||
```css
|
||||
img, video {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.06);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
Use a 4px / 8px base incremental system. Every spacing value should be a multiple of 4:
|
||||
|
||||
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128`
|
||||
|
||||
### Hit Areas
|
||||
|
||||
Minimum 44x44px for all interactive elements. If the visual element is smaller, extend the hit area with a pseudo-element:
|
||||
|
||||
```css
|
||||
.small-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
}
|
||||
```
|
||||
|
||||
### Z-Index Scale
|
||||
|
||||
Define a layered scale and never use arbitrary values:
|
||||
|
||||
```css
|
||||
--z-base: 0;
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 20;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 100;
|
||||
--z-toast: 1000;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Motion & Interaction
|
||||
|
||||
### The Frequency-Based Decision Framework
|
||||
|
||||
This is the most important mental model for animation decisions:
|
||||
|
||||
| Frequency | Examples | Animation |
|
||||
|-----------|----------|-----------|
|
||||
| **100+ times/day** | Keyboard shortcuts, command palette actions, tab switches | **None.** Zero animation. Instant. |
|
||||
| **Tens of times/day** | Hover effects, list item navigation, toggles | **Remove or drastically reduce.** 50-100ms max. |
|
||||
| **Occasional** | Modals, drawers, toasts, page transitions | **Standard animation.** 150-300ms. |
|
||||
| **Rare / first-time** | Onboarding, celebrations, empty states | **Can add delight.** 300-500ms, more elaborate. |
|
||||
|
||||
High-frequency animations feel sluggish. Low-frequency animations without motion feel jarring. Match the animation budget to usage frequency.
|
||||
|
||||
### Custom Easing Curves
|
||||
|
||||
Built-in CSS easings (`ease`, `ease-in-out`) are too weak. Define custom curves:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Duration Guide
|
||||
|
||||
| Element | Duration |
|
||||
|---------|----------|
|
||||
| Buttons, toggles | 100-160ms |
|
||||
| Tooltips | 125-200ms |
|
||||
| Dropdowns, popovers | 150-250ms |
|
||||
| Modals, drawers | 200-500ms |
|
||||
| Page transitions | 250-400ms |
|
||||
|
||||
UI animations should stay under 300ms. Never use `ease-in` for UI animations — it front-loads the pause and feels sluggish.
|
||||
|
||||
### Enter/Exit Asymmetry
|
||||
|
||||
Exits should be softer and faster than enters. An enter animation at 250ms should have its exit at 150-200ms.
|
||||
|
||||
### Split and Stagger Enter Animations
|
||||
|
||||
When multiple elements enter the viewport, stagger them by semantic chunks with ~50-100ms delay:
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
animation: fadeSlideIn 300ms var(--ease-out) both;
|
||||
}
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 60ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 120ms; }
|
||||
```
|
||||
|
||||
### Scale Animations
|
||||
|
||||
Never animate from `scale(0)`. Start from `scale(0.9)` or higher, combined with opacity:
|
||||
|
||||
```css
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
```
|
||||
|
||||
### Press Feedback
|
||||
|
||||
Every pressable element should scale down slightly on `:active`:
|
||||
|
||||
```css
|
||||
button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
### Interruptibility
|
||||
|
||||
Use CSS transitions (not keyframe animations) for interactive state changes. Transitions can be interrupted mid-way; keyframes cannot. This matters for hover states, toggles, and any element the user might interact with rapidly.
|
||||
|
||||
### Popover Origin
|
||||
|
||||
Make popovers transform-origin aware — they should grow from their trigger element, not from center. Exception: modals always originate from center.
|
||||
|
||||
### Tooltip Hover Delay
|
||||
|
||||
Skip the tooltip delay on subsequent hovers. If the user has already waited for one tooltip, show the next one immediately.
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Respect `prefers-reduced-motion`. Reduce animations — don't eliminate opacity and color transitions entirely, as those provide important feedback.
|
||||
|
||||
### Hover Gate
|
||||
|
||||
Gate hover animations behind a media query so touch devices don't trigger stuck hover states:
|
||||
|
||||
```css
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover { transform: translateY(-2px); }
|
||||
}
|
||||
```
|
||||
|
||||
> Reference `references/animation-playbook.md` for deep dives on spring physics, gesture-driven animation, and complex choreography.
|
||||
|
||||
---
|
||||
|
||||
## 8. Component Craft
|
||||
|
||||
### Primitives
|
||||
|
||||
Use Radix UI primitives for accessible, unstyled foundations. Use CVA (class-variance-authority) for type-safe component variants:
|
||||
|
||||
```tsx
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
default: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8 text-lg",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Button
|
||||
|
||||
- Scale on press (`transform: scale(0.97)` on `:active`)
|
||||
- Visible focus ring (never `outline: none` without replacement)
|
||||
- Loading state with spinner replacing label, maintaining button dimensions
|
||||
- Disabled state at `opacity: 0.5` with `pointer-events: none`
|
||||
|
||||
### Card
|
||||
|
||||
- Concentric border radius between card and inner elements
|
||||
- Layered shadows (not borders) for depth
|
||||
- Hover state: subtle elevation change (`translateY(-1px)` + shadow increase)
|
||||
|
||||
### Dialog / Modal
|
||||
|
||||
- Focus trap (keyboard cannot escape to elements behind)
|
||||
- ESC to close, click outside overlay to close
|
||||
- `transform-origin: center`, fade + scale enter animation
|
||||
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
|
||||
|
||||
### Form
|
||||
|
||||
- Visible labels always — never placeholder-only inputs
|
||||
- Error messages near the field with `aria-live="polite"` for screen readers
|
||||
- Progressive disclosure: show advanced fields only when needed
|
||||
- Use React Hook Form + Zod for validation
|
||||
|
||||
### Theming
|
||||
|
||||
Use shadcn CSS variable pattern (HSL format) for all component colors. Wrap client-interactive components in server components for Next.js App Router compatibility.
|
||||
|
||||
> Reference `references/component-patterns.md` for the full component catalog with copy-paste implementations.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility Essentials
|
||||
|
||||
### Semantic HTML First
|
||||
|
||||
Use `<button>`, `<nav>`, `<main>`, `<header>`, `<footer>`, `<article>`, `<section>` before reaching for ARIA. A `<button>` gives you keyboard handling, focus management, and screen reader semantics for free. A `<div onClick>` gives you none of that.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- **Tab / Shift+Tab**: move between focusable elements
|
||||
- **Enter / Space**: activate buttons and links
|
||||
- **Arrow keys**: navigate within lists, menus, tabs, radio groups
|
||||
- **Escape**: close modals, popovers, dropdowns
|
||||
- **Home / End**: jump to first/last item in lists
|
||||
|
||||
### Focus Management
|
||||
|
||||
- Visible focus rings on all interactive elements — NEVER use `outline: none` without a replacement
|
||||
- Trap focus inside modals (Tab wraps within the modal, not behind it)
|
||||
- Restore focus to the trigger element when a modal/popover closes
|
||||
- Use `focus-visible` to show rings only for keyboard users, not mouse clicks:
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### ARIA Attributes
|
||||
|
||||
- `aria-label` for icon-only buttons: `<button aria-label="Close menu">X</button>`
|
||||
- `aria-labelledby` to associate headings with sections
|
||||
- `aria-describedby` to link help text or error messages to inputs
|
||||
- `aria-live="polite"` for dynamic content updates (toast messages, form errors)
|
||||
- `aria-hidden="true"` for decorative elements (icons next to text labels)
|
||||
- `aria-expanded` for toggleable elements (dropdowns, accordions)
|
||||
|
||||
### Color and Contrast
|
||||
|
||||
- WCAG AA: 4.5:1 for normal text, 3:1 for large text
|
||||
- Never use color as the sole indicator — pair with icons, text, or patterns
|
||||
- Test in both light and dark modes
|
||||
|
||||
### Images and Media
|
||||
|
||||
- Descriptive `alt` text for meaningful images: `alt="Dashboard showing 23% revenue growth"`
|
||||
- Empty `alt=""` for purely decorative images
|
||||
- Captions for video, transcripts for audio
|
||||
|
||||
### Navigation Aids
|
||||
|
||||
- **Skip link**: first focusable element, hidden until focused:
|
||||
|
||||
```html
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only">
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
- **Heading hierarchy**: sequential h1 through h6, no level skips. One `<h1>` per page.
|
||||
|
||||
### Touch Targets
|
||||
|
||||
- Minimum 44x44px interactive area
|
||||
- 8px minimum spacing between adjacent touch targets
|
||||
- Extend small visual elements with invisible padding or pseudo-elements
|
||||
|
||||
### Testing
|
||||
|
||||
- **Automated**: axe-core in CI, Lighthouse accessibility score 90+
|
||||
- **Manual**: full keyboard-only navigation test
|
||||
- **Screen reader**: test with VoiceOver (macOS) or NVDA (Windows)
|
||||
- **Visual**: zoom to 200%, check nothing breaks or overlaps
|
||||
|
||||
> Reference `references/accessibility-checklist.md` for the full audit guide with pass/fail criteria.
|
||||
|
||||
---
|
||||
|
||||
## 10. Pre-Delivery Review
|
||||
|
||||
Run through this checklist before considering any UI implementation complete:
|
||||
|
||||
### Typography
|
||||
- [ ] Font smoothing applied (`-webkit-font-smoothing: antialiased`)
|
||||
- [ ] Headings use `text-wrap: balance`
|
||||
- [ ] Dynamic numbers use `font-variant-numeric: tabular-nums`
|
||||
|
||||
### Color
|
||||
- [ ] All colors referenced via semantic tokens, no hardcoded hex in components
|
||||
- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
|
||||
- [ ] Dark mode tested separately for contrast
|
||||
|
||||
### Spatial
|
||||
- [ ] Nested rounded elements use concentric border radius
|
||||
- [ ] Spacing follows 4px / 8px scale consistently
|
||||
- [ ] Interactive elements have 44x44px minimum hit area
|
||||
- [ ] Shadows used instead of borders where appropriate
|
||||
|
||||
### Motion
|
||||
- [ ] Animation frequency matches usage frequency (no animation on high-frequency actions)
|
||||
- [ ] No `transition: all` anywhere — specific properties only
|
||||
- [ ] Enter animations split and staggered where multiple elements appear
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
|
||||
### Accessibility
|
||||
- [ ] All interactive elements keyboard accessible
|
||||
- [ ] Focus rings visible on keyboard navigation (never `outline: none` without replacement)
|
||||
- [ ] Semantic HTML used before ARIA
|
||||
- [ ] `aria-live` on dynamic content updates
|
||||
|
||||
> Reference `references/review-checklist.md` for the extended 30-item checklist with severity ratings and automated testing commands.
|
||||
@@ -0,0 +1,425 @@
|
||||
# WCAG 2.1 AA Accessibility Audit Guide
|
||||
|
||||
Comprehensive checklist for building accessible web interfaces. Every requirement maps to WCAG 2.1 Level AA success criteria.
|
||||
|
||||
---
|
||||
|
||||
## 1. Semantic HTML Priority
|
||||
|
||||
ALWAYS use semantic HTML before reaching for ARIA. Native elements carry built-in keyboard behavior, focus management, and screen reader announcements that ARIA can only approximate.
|
||||
|
||||
### Element Selection Rules
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<div role="button">` | `<button>` |
|
||||
| `<div role="navigation">` | `<nav>` |
|
||||
| `<div class="header">` | `<header>` |
|
||||
| `<div class="footer">` | `<footer>` |
|
||||
| `<span onClick>` | `<a href>` or `<button>` |
|
||||
| `<div role="list">` | `<ul>` / `<ol>` |
|
||||
| `<div class="table">` | `<table>` with `<thead>`, `<tbody>`, `<th>` |
|
||||
|
||||
### Landmark Elements
|
||||
|
||||
- `<main>` — one per page, wraps primary content
|
||||
- `<nav>` — navigation sections (label with `aria-label` when multiple exist)
|
||||
- `<header>` — introductory content or navigation aids
|
||||
- `<footer>` — footer content, copyright, related links
|
||||
- `<aside>` — tangentially related content (sidebars, callouts)
|
||||
- `<article>` — self-contained composition (blog post, comment, widget)
|
||||
- `<section>` — thematic grouping of content (always pair with a heading)
|
||||
|
||||
### Form Associations
|
||||
|
||||
- `<label>` with `for` attribute connected to the input's `id`
|
||||
- Group related inputs with `<fieldset>` and `<legend>`
|
||||
- Use `<optgroup>` for grouped select options
|
||||
|
||||
### Heading Hierarchy
|
||||
|
||||
- Sequential order: h1 -> h2 -> h3 -> h4 -> h5 -> h6
|
||||
- NEVER skip levels (e.g., h1 directly to h3)
|
||||
- One `<h1>` per page (the page title)
|
||||
- Headings must describe the content that follows
|
||||
|
||||
---
|
||||
|
||||
## 2. Keyboard Navigation Patterns
|
||||
|
||||
Every interactive element must be operable with a keyboard alone. No mouse-only interactions.
|
||||
|
||||
### Global Key Bindings
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Move focus to next focusable element |
|
||||
| `Shift + Tab` | Move focus to previous focusable element |
|
||||
| `Enter` | Activate links, buttons, submit forms |
|
||||
| `Space` | Activate buttons, toggle checkboxes |
|
||||
| `Escape` | Close modals, dropdowns, popovers, tooltips |
|
||||
| `Arrow keys` | Navigate within composite widgets |
|
||||
| `Home` | Jump to first item in a list or range |
|
||||
| `End` | Jump to last item in a list or range |
|
||||
|
||||
### Composite Widget Navigation (Arrow Keys)
|
||||
|
||||
- **Tabs**: Left/Right arrows move between tabs
|
||||
- **Menus**: Up/Down arrows move between menu items
|
||||
- **Radio groups**: Arrow keys cycle through options, selecting as they go
|
||||
- **Listboxes**: Up/Down arrows move highlight, Space selects
|
||||
- **Tree views**: Up/Down navigate siblings, Right expands, Left collapses
|
||||
|
||||
### tabindex Rules
|
||||
|
||||
- `tabindex="0"` — places element in natural tab order (use for custom interactive elements)
|
||||
- `tabindex="-1"` — removes from tab order but allows programmatic focus via `element.focus()` (use for modal containers, skip-link targets, dynamically focused content)
|
||||
- **NEVER** use `tabindex > 0` — it overrides natural DOM order and creates an unpredictable, unmaintainable focus sequence
|
||||
|
||||
### Focus Order Principle
|
||||
|
||||
Focus order must match the visual reading order (left-to-right, top-to-bottom for LTR languages). If the DOM order does not match the visual layout, fix the DOM order rather than using positive tabindex values.
|
||||
|
||||
---
|
||||
|
||||
## 3. ARIA Attributes Reference
|
||||
|
||||
The first rule of ARIA: do not use ARIA if a native HTML element provides the behavior. When you must use ARIA, apply it correctly.
|
||||
|
||||
### Naming and Describing
|
||||
|
||||
| Attribute | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `aria-label` | Names an element without visible text | Icon button: `<button aria-label="Close">X</button>` |
|
||||
| `aria-labelledby` | Points to another element as the label | Modal: `aria-labelledby="dialog-title"` |
|
||||
| `aria-describedby` | Provides additional description | Form hint: `aria-describedby="password-hint"` |
|
||||
|
||||
### Live Regions
|
||||
|
||||
| Attribute | Behavior |
|
||||
|---|---|
|
||||
| `aria-live="polite"` | Waits for current speech to finish before announcing (toasts, status updates) |
|
||||
| `aria-live="assertive"` | Interrupts current speech immediately (critical errors, urgent alerts) |
|
||||
| `aria-atomic="true"` | Re-reads entire region content on change, not just the delta |
|
||||
| `role="alert"` | Shorthand for `aria-live="assertive"` + `aria-atomic="true"` |
|
||||
| `role="status"` | Shorthand for `aria-live="polite"` + `aria-atomic="true"` |
|
||||
|
||||
### State and Properties
|
||||
|
||||
| Attribute | Purpose |
|
||||
|---|---|
|
||||
| `aria-expanded` | Indicates whether a collapsible section is open (`true`) or closed (`false`) |
|
||||
| `aria-haspopup` | Indicates the trigger opens a popup (`menu`, `listbox`, `dialog`, `grid`, `tree`) |
|
||||
| `aria-modal="true"` | Marks a dialog as modal (assistive tech should ignore content outside) |
|
||||
| `aria-hidden="true"` | Hides element from assistive technology (decorative images, duplicate content) |
|
||||
| `aria-invalid` | Marks a form field as having an error (`true`, `grammar`, `spelling`) |
|
||||
| `aria-required` | Indicates the field is required before form submission |
|
||||
| `aria-sort` | Indicates sort direction on table column headers (`ascending`, `descending`, `none`) |
|
||||
| `aria-selected` | Indicates selected state in single/multi-select widgets |
|
||||
| `aria-controls` | Identifies the element(s) controlled by this element |
|
||||
| `aria-current` | Indicates the current item in a set (`page`, `step`, `location`, `date`, `true`) |
|
||||
| `aria-disabled` | Marks element as disabled but still perceivable (unlike `disabled` attribute which removes from tab order) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Focus Management
|
||||
|
||||
### Visible Focus Indicators
|
||||
|
||||
- **NEVER** use `outline: none` or `outline: 0` without providing a custom alternative
|
||||
- Recommended default: `outline: 3px solid currentColor; outline-offset: 2px;`
|
||||
- Use `:focus-visible` for keyboard-only focus styling (hides ring on mouse click):
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus-color, #2563eb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
```
|
||||
|
||||
- Focus indicators must meet 3:1 contrast ratio against adjacent colors (WCAG 2.4.11)
|
||||
- Minimum focus indicator area: at least 2px perimeter around the component
|
||||
|
||||
### Modal Focus Trapping
|
||||
|
||||
When a modal opens:
|
||||
1. Move focus to the first focusable element inside the modal (or the modal container with `tabindex="-1"`)
|
||||
2. Trap Tab/Shift+Tab to cycle only through focusable elements within the modal
|
||||
3. Pressing Escape closes the modal
|
||||
4. On close, return focus to the element that triggered the modal
|
||||
|
||||
### Focus Restoration
|
||||
|
||||
- When a dropdown/popover/modal closes, return focus to its trigger element
|
||||
- When an item is deleted from a list, move focus to the nearest remaining item
|
||||
- When a dialog confirms an action, focus the result or next logical element
|
||||
|
||||
### SPA Route Changes
|
||||
|
||||
- On navigation, move focus to the main content heading or a skip-link target
|
||||
- Announce the new page title to screen readers using an `aria-live` region or document.title update
|
||||
- Use `<title>` updates: "Page Name | Site Name"
|
||||
|
||||
### Skip Links
|
||||
|
||||
- First focusable element on the page should be "Skip to main content"
|
||||
- Link target: `<main id="main-content" tabindex="-1">`
|
||||
- Visually hidden until focused:
|
||||
|
||||
```css
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
position: static;
|
||||
left: auto;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color Contrast Requirements
|
||||
|
||||
### WCAG AA Minimum Ratios
|
||||
|
||||
| Element | Minimum Contrast Ratio |
|
||||
|---|---|
|
||||
| Normal text (< 24px, or < 18.66px if bold) | 4.5:1 |
|
||||
| Large text (>= 24px, or >= 18.66px if bold) | 3:1 |
|
||||
| UI components (borders, icons, form controls) | 3:1 |
|
||||
| Graphical objects (charts, infographics) | 3:1 |
|
||||
| Disabled elements | No requirement (but keep readable) |
|
||||
| Placeholder text | 4.5:1 (it is regular text) |
|
||||
|
||||
### Testing Tools
|
||||
|
||||
- Chrome DevTools: Elements panel -> Styles -> color swatch -> contrast ratio
|
||||
- axe-core browser extension
|
||||
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
|
||||
- Stark (Figma/Sketch plugin)
|
||||
|
||||
### Color Independence Rules
|
||||
|
||||
- **NEVER** convey information by color alone
|
||||
- Error states: red color + error icon + descriptive text message
|
||||
- Required fields: asterisk + "required" label text (not just red border)
|
||||
- Status indicators: color + icon + text label (e.g., green checkmark + "Complete")
|
||||
- Links in body text: color + underline (or other non-color differentiator)
|
||||
- Charts/graphs: use patterns, labels, or shapes in addition to color
|
||||
|
||||
### Dark Mode Considerations
|
||||
|
||||
- Test contrast ratios separately in dark mode
|
||||
- Use desaturated color variants, not simple CSS `invert()`
|
||||
- Background and foreground pairs must both be intentionally chosen
|
||||
- Semi-transparent overlays can reduce effective contrast -- verify computed values
|
||||
|
||||
---
|
||||
|
||||
## 6. Accessible Component Patterns
|
||||
|
||||
### Dropdown / Select
|
||||
|
||||
```
|
||||
trigger: aria-haspopup="listbox", aria-expanded="false|true"
|
||||
container: role="listbox"
|
||||
options: role="option", aria-selected="true|false"
|
||||
```
|
||||
|
||||
- Arrow keys navigate options
|
||||
- Typeahead: typing characters jumps to matching option
|
||||
- Enter/Space selects highlighted option
|
||||
- Escape closes without selecting
|
||||
- Selected option text updates trigger label
|
||||
|
||||
### Modal / Dialog
|
||||
|
||||
```
|
||||
container: role="dialog", aria-modal="true", aria-labelledby="title-id"
|
||||
title: id="title-id"
|
||||
close button: aria-label="Close dialog"
|
||||
```
|
||||
|
||||
- Focus moves into modal on open
|
||||
- Tab cycles within modal (focus trap)
|
||||
- Escape closes modal
|
||||
- Click on backdrop closes modal
|
||||
- Focus returns to trigger on close
|
||||
- Background content gets `aria-hidden="true"` or `inert`
|
||||
|
||||
### Tabs
|
||||
|
||||
```
|
||||
container: role="tablist"
|
||||
tab: role="tab", aria-selected="true|false", aria-controls="panel-id", tabindex="0|-1"
|
||||
panel: role="tabpanel", aria-labelledby="tab-id", tabindex="0"
|
||||
```
|
||||
|
||||
- Only the active tab has `tabindex="0"`; inactive tabs have `tabindex="-1"`
|
||||
- Left/Right arrows move between tabs (wrapping optional)
|
||||
- Home/End jump to first/last tab
|
||||
- Tab key moves focus from the active tab into the panel content
|
||||
|
||||
### Forms
|
||||
|
||||
- Every `<input>`, `<select>`, `<textarea>` has a visible `<label>`
|
||||
- Required fields: `aria-required="true"` + visual asterisk indicator
|
||||
- Error fields: `aria-invalid="true"` + `aria-describedby` pointing to error message element
|
||||
- Error messages: use `role="alert"` or `aria-live="assertive"` region
|
||||
- On failed submission: focus the first invalid field
|
||||
- Helper text: linked via `aria-describedby` to the associated input
|
||||
- Password fields: toggle visibility button with `aria-label` describing current state
|
||||
- Groups of related controls: `<fieldset>` + `<legend>`
|
||||
|
||||
### Accordion
|
||||
|
||||
```
|
||||
trigger: <button aria-expanded="true|false" aria-controls="panel-id">
|
||||
panel: id="panel-id", role="region", aria-labelledby="trigger-id"
|
||||
```
|
||||
|
||||
- Enter/Space toggles section
|
||||
- Only one section open at a time (optional, depends on design)
|
||||
- Panel content hidden with `hidden` attribute or `display: none` (not just visually)
|
||||
|
||||
### Toast / Notification
|
||||
|
||||
- Container: `role="status"` or `aria-live="polite"` (non-critical)
|
||||
- Critical notifications: `role="alert"` (assertive)
|
||||
- Must be dismissible (close button or auto-dismiss with sufficient time)
|
||||
- Auto-dismiss: minimum 5 seconds visible, pauses on hover/focus
|
||||
|
||||
---
|
||||
|
||||
## 7. prefers-reduced-motion
|
||||
|
||||
### Global Reset
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nuanced Approach
|
||||
|
||||
Reduced motion means fewer/gentler animations, not zero motion:
|
||||
- **Keep**: opacity fades, color transitions that aid comprehension
|
||||
- **Remove**: parallax scrolling, zoom/scale transforms, slide/translate animations, auto-playing carousels
|
||||
- **Simplify**: complex multi-step animations to simple fades
|
||||
|
||||
### Framework Integration
|
||||
|
||||
React (framer-motion):
|
||||
```jsx
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
|
||||
function Component() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ x: shouldReduceMotion ? 0 : 100 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
CSS custom property approach:
|
||||
```css
|
||||
:root {
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-speed: 0.01ms;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Approach
|
||||
|
||||
### Automated Testing
|
||||
|
||||
| Tool | Usage |
|
||||
|---|---|
|
||||
| axe-core | `npm install jest-axe` for unit tests; `expect(container).toHaveNoViolations()` |
|
||||
| Lighthouse | Accessibility score target: 90+ |
|
||||
| eslint-plugin-jsx-a11y | Static analysis for JSX accessibility issues |
|
||||
| pa11y | CLI/CI integration for automated page-level audits |
|
||||
| Playwright/axe | `@axe-core/playwright` for integration test accessibility checks |
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
1. **Keyboard-only navigation**: unplug mouse, navigate entire page with Tab, Enter, Arrows, Escape
|
||||
2. **Screen reader**: VoiceOver (macOS: Cmd+F5), NVDA (Windows, free), JAWS (Windows)
|
||||
3. **Zoom 200%**: content should reflow without horizontal scrolling or content clipping
|
||||
4. **Zoom 400%**: text should remain readable (WCAG 1.4.10 Reflow)
|
||||
5. **Focus indicators**: every interactive element shows a visible focus ring when focused via keyboard
|
||||
6. **Forced colors mode**: test in Windows High Contrast Mode (use `forced-colors` media query)
|
||||
7. **Text spacing**: override letter-spacing (0.12em), word-spacing (0.16em), line-height (1.5), paragraph-spacing (2em) -- content must remain readable
|
||||
|
||||
### CI Integration
|
||||
|
||||
```bash
|
||||
# Example: axe-core with Playwright in CI
|
||||
npx playwright test --project=accessibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---|---|
|
||||
| `outline: none` on focus | Use `:focus-visible` with a custom focus ring |
|
||||
| Placeholder as only label | Always use `<label>` element |
|
||||
| Icon button without label | Add `aria-label="Action description"` |
|
||||
| Color-only error indication | Add icon + descriptive text alongside color |
|
||||
| Missing alt text on images | Descriptive `alt` text, or `alt=""` for decorative images |
|
||||
| Heading level skip (h1 to h3) | Sequential hierarchy: h1 -> h2 -> h3 |
|
||||
| `tabindex > 0` | Use natural DOM order; only use `0` or `-1` |
|
||||
| Emoji used as functional icons | Use SVG icons with `aria-label` |
|
||||
| Auto-playing animation | Respect `prefers-reduced-motion` media query |
|
||||
| Non-dismissible modal | Always support Escape key to close |
|
||||
| `aria-hidden="true"` on focusable elements | Remove from tab order or remove `aria-hidden` |
|
||||
| Missing `lang` attribute on `<html>` | Set `<html lang="en">` (or appropriate language code) |
|
||||
| Autoplaying video/audio with sound | Require user interaction to start, or mute by default with controls |
|
||||
| Tiny tap targets on mobile | Minimum 44x44 CSS pixels for touch targets |
|
||||
| Using `title` attribute as primary label | `title` is unreliable; use `aria-label` or visible `<label>` |
|
||||
| Links that say "click here" or "read more" | Descriptive link text: "Read the accessibility guide" |
|
||||
| Missing form error summary | On submit failure, show summary of all errors at top of form |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Testing a New Component
|
||||
|
||||
Before marking any component as complete, verify:
|
||||
|
||||
1. Can you reach and operate it using only a keyboard?
|
||||
2. Does it have a visible focus indicator?
|
||||
3. Does it announce correctly in a screen reader?
|
||||
4. Does it meet color contrast ratios?
|
||||
5. Does it work at 200% zoom?
|
||||
6. Does it respect `prefers-reduced-motion`?
|
||||
7. Does it pass `jest-axe` / axe-core automated checks?
|
||||
8. Does it have appropriate semantic HTML or ARIA roles?
|
||||
9. Are all images, icons, and media labeled?
|
||||
10. Can it be operated with one hand on mobile (44x44px touch targets)?
|
||||
@@ -0,0 +1,545 @@
|
||||
# Animation Playbook
|
||||
|
||||
Deep-dive reference for animation patterns. The main SKILL.md references these techniques
|
||||
but does not include the full detail needed for implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Easing Curve Library
|
||||
|
||||
The built-in CSS keywords (`ease`, `ease-in`, `ease-out`, `ease-in-out`) produce weak,
|
||||
generic motion. Define custom curves as CSS custom properties so every animation in the
|
||||
project shares the same vocabulary.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Strong ease-out — the default for UI interactions (enter, appear, respond) */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
/* Strong ease-in-out — on-screen movement and morphing transitions */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
|
||||
/* iOS-like drawer curve — slide-up sheets, bottom drawers */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
/* Snappy — fast micro-interactions, toggles, checkboxes */
|
||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
/* Emphasized deceleration — large surface transitions, page-level changes */
|
||||
--ease-decel: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
| Curve | Use case |
|
||||
| ---------------- | ---------------------------------------------- |
|
||||
| `--ease-out` | Elements entering the viewport, appearing |
|
||||
| `--ease-in-out` | Elements morphing shape, moving across screen |
|
||||
| `--ease-drawer` | Sheets, drawers, panels sliding into view |
|
||||
| `--ease-snappy` | Micro-interactions: toggles, checks, switches |
|
||||
| `--ease-decel` | Large page transitions, route changes |
|
||||
| `linear` | Constant-rate motion only: progress bars, spin |
|
||||
|
||||
Never use `ease-in` alone for UI elements — it makes things feel sluggish at the start.
|
||||
Reserve `linear` for continuous motion (loading spinners, progress indicators) where
|
||||
deceleration would look wrong.
|
||||
|
||||
**Resources**: [easing.dev](https://easing.dev), [easings.co](https://easings.co)
|
||||
for visual curve comparison and copying.
|
||||
|
||||
---
|
||||
|
||||
## 2. Spring Animations
|
||||
|
||||
Springs are physics-based. They do not have a fixed duration — they simulate mass,
|
||||
stiffness, and damping. This makes them ideal for anything interactive.
|
||||
|
||||
### When to use springs instead of easing curves
|
||||
|
||||
- Drag interactions (the element should follow the finger naturally)
|
||||
- Elements that feel "alive" (cards, floating actions, avatars)
|
||||
- Gestures that can be interrupted mid-animation
|
||||
- Mouse-tracking interactions (cursor followers, magnetic buttons)
|
||||
|
||||
### Apple-style spring (duration + bounce)
|
||||
|
||||
```js
|
||||
// Framer Motion / Motion One
|
||||
animate(element, { x: 100 }, {
|
||||
type: "spring",
|
||||
duration: 0.5,
|
||||
bounce: 0.2
|
||||
})
|
||||
```
|
||||
|
||||
This is the simpler API. `duration` controls overall timing, `bounce` controls overshoot.
|
||||
|
||||
### Traditional physics spring (mass + stiffness + damping)
|
||||
|
||||
```js
|
||||
animate(element, { x: 100 }, {
|
||||
type: "spring",
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 10
|
||||
})
|
||||
```
|
||||
|
||||
More control, but harder to tune. Start with mass=1 and adjust stiffness/damping.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Keep bounce subtle: **0.1 to 0.3** for most UI. Higher values feel toy-like.
|
||||
- Avoid bounce entirely for actions that need to feel decisive (confirms, deletes).
|
||||
- Springs **maintain velocity when interrupted** — if you change the target mid-animation,
|
||||
the element smoothly redirects. Keyframe animations restart from scratch.
|
||||
- Use `useSpring` (or equivalent) for mouse-tracking: it makes cursor followers feel
|
||||
natural instead of artificial. The lag is intentional and pleasant.
|
||||
- For lists, spring each item separately so they can settle independently.
|
||||
|
||||
---
|
||||
|
||||
## 3. clip-path Animation Patterns
|
||||
|
||||
`clip-path` is one of the most underused animation tools. It lets you reveal, hide,
|
||||
and transition content without layout shifts.
|
||||
|
||||
### Inset shape basics
|
||||
|
||||
```css
|
||||
/* Full visibility */
|
||||
clip-path: inset(0 0 0 0);
|
||||
|
||||
/* Clipped from bottom — only top portion visible */
|
||||
clip-path: inset(0 0 50% 0);
|
||||
|
||||
/* Fully hidden — clipped from all sides */
|
||||
clip-path: inset(50% 50% 50% 50%);
|
||||
|
||||
/* With border-radius */
|
||||
clip-path: inset(10px round 8px);
|
||||
```
|
||||
|
||||
The values are `inset(top right bottom left)` — how far each edge clips inward.
|
||||
|
||||
### Pattern: Tabs with perfect color transitions
|
||||
|
||||
Duplicate the entire tab list. Place one copy on top of the other. The bottom copy has
|
||||
inactive styles; the top copy has active styles. Animate `clip-path: inset(...)` on the
|
||||
top copy to reveal only the active tab region. The color transition is instantaneous and
|
||||
pixel-perfect — no fade needed.
|
||||
|
||||
```css
|
||||
.tabs-active-overlay {
|
||||
clip-path: inset(0 calc(100% - var(--tab-right)) 0 var(--tab-left));
|
||||
transition: clip-path 300ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Hold-to-delete
|
||||
|
||||
Overlay a colored fill on the button. On `:active`, animate `clip-path` from
|
||||
`inset(0 100% 0 0)` to `inset(0 0 0 0)` over 2 seconds with `linear` timing (the user
|
||||
needs to see constant progress). On release, snap back with `200ms ease-out`.
|
||||
|
||||
```css
|
||||
.delete-btn::after {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms var(--ease-out);
|
||||
}
|
||||
.delete-btn:active::after {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Image reveals on scroll
|
||||
|
||||
Start with `clip-path: inset(0 0 100% 0)` (image hidden, clipped from bottom).
|
||||
Use IntersectionObserver to detect viewport entry, then animate to `inset(0 0 0 0)`.
|
||||
|
||||
```js
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.clipPath = 'inset(0 0 0 0)';
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
```
|
||||
|
||||
### Pattern: Comparison sliders
|
||||
|
||||
Overlay two images. Clip the top image by the drag position:
|
||||
`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`. Update `--pos` on pointer move.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gesture Design
|
||||
|
||||
Gestures are the hardest animation category because they involve real-time user input
|
||||
and require physics-aware feedback.
|
||||
|
||||
### Momentum-based dismissal
|
||||
|
||||
Calculate velocity during drag:
|
||||
|
||||
```js
|
||||
const velocity = distance / elapsed; // px per ms
|
||||
if (velocity > 0.11) {
|
||||
dismiss(); // Fast enough — dismiss regardless of distance
|
||||
} else if (Math.abs(offset) > threshold) {
|
||||
dismiss(); // Far enough — dismiss regardless of speed
|
||||
} else {
|
||||
snapBack(); // Neither fast nor far — return to origin
|
||||
}
|
||||
```
|
||||
|
||||
The velocity threshold (0.11 px/ms) matters more than distance. A quick flick should
|
||||
dismiss even from a small offset.
|
||||
|
||||
### Damping at boundaries
|
||||
|
||||
When the user drags past a natural boundary (e.g., top of a scroll view), apply
|
||||
increasing resistance:
|
||||
|
||||
```js
|
||||
function dampedOffset(raw, boundary) {
|
||||
const overflow = raw - boundary;
|
||||
// Logarithmic damping — diminishing returns
|
||||
return boundary + Math.log(1 + Math.abs(overflow)) * 30 * Math.sign(overflow);
|
||||
}
|
||||
```
|
||||
|
||||
This produces the rubber-band effect. The element still moves, but progressively less.
|
||||
|
||||
### Pointer capture
|
||||
|
||||
Once a drag begins, call `element.setPointerCapture(event.pointerId)`. This ensures
|
||||
all subsequent pointer events route to this element even if the pointer leaves its
|
||||
bounds. Release on `pointerup`.
|
||||
|
||||
### Multi-touch protection
|
||||
|
||||
Track only the first pointer. If a second finger touches during a drag, ignore it:
|
||||
|
||||
```js
|
||||
let activePointerId = null;
|
||||
element.addEventListener('pointerdown', (e) => {
|
||||
if (activePointerId !== null) return; // Already tracking
|
||||
activePointerId = e.pointerId;
|
||||
element.setPointerCapture(e.pointerId);
|
||||
});
|
||||
```
|
||||
|
||||
### Friction instead of hard stops
|
||||
|
||||
Never hard-clamp position. Always allow movement with increasing resistance. Hard stops
|
||||
feel broken. Friction feels physical.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stagger Patterns
|
||||
|
||||
Staggering creates a sense of flow by delaying each item slightly.
|
||||
|
||||
### CSS implementation
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: stagger-in 400ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes stagger-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 40ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 80ms; }
|
||||
.stagger-item:nth-child(4) { animation-delay: 120ms; }
|
||||
.stagger-item:nth-child(5) { animation-delay: 160ms; }
|
||||
```
|
||||
|
||||
Or with a custom property:
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
animation-delay: calc(var(--index) * 40ms);
|
||||
}
|
||||
```
|
||||
|
||||
Set `--index` via `style` attribute in markup or JS.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- **30-80ms** per step is the sweet spot. Under 30ms looks simultaneous. Over 80ms
|
||||
feels sluggish.
|
||||
- Break content into **semantic chunks** — stagger cards, not individual lines of text.
|
||||
- **Never block interaction** during stagger animations. All items should be clickable
|
||||
immediately, even if not yet visible.
|
||||
- Cap the total stagger time. For a list of 20 items, stagger the first 5-6 and let the
|
||||
rest appear together.
|
||||
- Stagger on initial load only. Re-renders should not re-stagger.
|
||||
|
||||
---
|
||||
|
||||
## 6. Exit Animation Patterns
|
||||
|
||||
Exits are often neglected. They deserve as much care as entries.
|
||||
|
||||
### Principles
|
||||
|
||||
- **Exits should be faster than enters.** If enter is 400ms, exit should be 200-250ms.
|
||||
- **Use small fixed translateY** (8-12px) instead of full-height slides. Large movements
|
||||
during exit draw too much attention away from what remains.
|
||||
- **Opacity + scale combination** works better than opacity alone for removal. A slight
|
||||
`scale(0.96)` during fade-out makes it feel more physical.
|
||||
- **Asymmetric timing is intentional.** A hold-to-delete might take 2 seconds (deliberate),
|
||||
but the actual removal should be 200ms (snappy). The weight is in the decision, not
|
||||
the consequence.
|
||||
|
||||
### Exit with height collapse
|
||||
|
||||
When removing an item from a list, animate both the content (opacity + translate) and
|
||||
the container height. The content fades first, then the gap closes:
|
||||
|
||||
```css
|
||||
.item-exiting {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: opacity 150ms var(--ease-out),
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
.item-exiting-collapse {
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: height 200ms var(--ease-out) 100ms, /* delayed start */
|
||||
margin 200ms var(--ease-out) 100ms,
|
||||
padding 200ms var(--ease-out) 100ms;
|
||||
}
|
||||
```
|
||||
|
||||
### Tuning
|
||||
|
||||
There is no formula for the right opacity/height/transform combination. Adjust until it
|
||||
feels right. Test by performing the action 10 times quickly — if anything feels off on
|
||||
repetition, it needs work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance Rules
|
||||
|
||||
Animation jank is unacceptable. These rules keep animations at 60fps.
|
||||
|
||||
### The compositing-only rule
|
||||
|
||||
Only animate properties that skip layout and paint:
|
||||
- `transform` (translate, scale, rotate)
|
||||
- `opacity`
|
||||
- `filter` (with caveats — see below)
|
||||
|
||||
Everything else triggers layout recalculation (width, height, margin, padding, top, left)
|
||||
or paint (background-color, box-shadow, border). Both are expensive.
|
||||
|
||||
### CSS vs JavaScript animations
|
||||
|
||||
- **CSS animations and transitions** run off the main thread on the compositor. Use them
|
||||
for predetermined animations (hover effects, enter/exit, state changes).
|
||||
- **Framer Motion `x`/`y` props are NOT hardware-accelerated.** They animate inline
|
||||
styles, which run on the main thread. Use the full transform string or CSS-based
|
||||
approaches for performance-critical animations.
|
||||
- **CSS variables on parent elements** cause expensive style recalculation when updated.
|
||||
If animating a CSS variable, update the `transform` property directly instead.
|
||||
|
||||
### Web Animations API (WAAPI)
|
||||
|
||||
For programmatic animations that need CSS-level performance:
|
||||
|
||||
```js
|
||||
element.animate(
|
||||
[
|
||||
{ transform: 'translateY(20px)', opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
],
|
||||
{ duration: 400, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
|
||||
);
|
||||
```
|
||||
|
||||
WAAPI runs on the compositor like CSS animations but is controlled from JavaScript.
|
||||
|
||||
### Blur and filter performance
|
||||
|
||||
- Keep `blur()` under **20px**, especially on Safari where large blurs are expensive.
|
||||
- `backdrop-filter: blur()` is even more expensive — use sparingly.
|
||||
- Prefer pre-blurred images over real-time blur when possible.
|
||||
|
||||
### will-change
|
||||
|
||||
- Only use `will-change` for `transform`, `opacity`, or `filter`.
|
||||
- **Never** use `will-change: all` — it promotes every property and wastes GPU memory.
|
||||
- Add `will-change` only when you observe first-frame stutter on an animation. It is a
|
||||
last resort, not a default.
|
||||
- Remove `will-change` after the animation completes if the element is long-lived.
|
||||
|
||||
### transition: all is banned
|
||||
|
||||
```css
|
||||
/* Bad — animates every property change, including ones you did not intend */
|
||||
transition: all 200ms ease;
|
||||
|
||||
/* Good — explicit about what animates */
|
||||
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
|
||||
```
|
||||
|
||||
`transition: all` causes unexpected animations when other properties change and makes
|
||||
debugging difficult.
|
||||
|
||||
---
|
||||
|
||||
## 8. The Sonner Principles
|
||||
|
||||
Sonner (the toast library) demonstrates principles that apply broadly to dynamic UI
|
||||
components.
|
||||
|
||||
### Good defaults matter more than options
|
||||
|
||||
If you need 12 configuration props to make a component feel right, the defaults are
|
||||
wrong. The component should feel right out of the box.
|
||||
|
||||
### Use transitions, not keyframes, for dynamic UI
|
||||
|
||||
Toasts are added rapidly and unpredictably. Keyframe animations have fixed timelines
|
||||
that cannot adapt to rapid state changes. CSS transitions respond to the current state
|
||||
and interpolate naturally.
|
||||
|
||||
### Handle edge cases invisibly
|
||||
|
||||
- Pause toast timers when the browser tab is hidden (the user should not miss toasts).
|
||||
- When a toast is dismissed from the middle of a stack, the remaining toasts should
|
||||
fill the gap smoothly.
|
||||
- When multiple toasts arrive simultaneously, batch the visual update.
|
||||
|
||||
### Match motion personality to component personality
|
||||
|
||||
A success toast can be slightly bouncy. An error toast should be direct and firm.
|
||||
A loading toast should feel steady and patient. The animation communicates as much as
|
||||
the content.
|
||||
|
||||
---
|
||||
|
||||
## 9. @starting-style for Modern CSS Enter Animations
|
||||
|
||||
`@starting-style` defines the initial style of an element when it first renders.
|
||||
Combined with transitions, it creates enter animations in pure CSS — no JavaScript
|
||||
`useEffect` + `mounted` state needed.
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When the `.toast` element is inserted into the DOM, the browser starts from the
|
||||
`@starting-style` values and transitions to the normal values.
|
||||
|
||||
### Works with display: none toggling
|
||||
|
||||
```css
|
||||
.dialog {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
transition: opacity 300ms var(--ease-out), display 300ms allow-discrete;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.dialog[hidden] {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
The `allow-discrete` keyword lets `display` participate in the transition timeline.
|
||||
|
||||
### Fallback for older browsers
|
||||
|
||||
When `@starting-style` is not supported, fall back to a `data-mounted` attribute pattern:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
}
|
||||
.toast:not([data-mounted]) {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
```
|
||||
|
||||
Add `data-mounted` via JavaScript after a single `requestAnimationFrame`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Debug Techniques
|
||||
|
||||
### Slow motion testing
|
||||
|
||||
Increase animation duration by 2-5x during development. At normal speed, problems are
|
||||
invisible. At 5x, you see every hitch, wrong easing, and misaligned property.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--debug-speed: 1; /* Change to 5 for slow-mo */
|
||||
}
|
||||
.animated {
|
||||
transition-duration: calc(200ms * var(--debug-speed));
|
||||
}
|
||||
```
|
||||
|
||||
### Chrome DevTools Animations panel
|
||||
|
||||
Open DevTools > More Tools > Animations. This panel shows:
|
||||
- A timeline of all running animations
|
||||
- Frame-by-frame scrubbing
|
||||
- Easing curve visualization
|
||||
- Duration and delay for each animation
|
||||
|
||||
Use the playback speed controls (25%, 10%) for detailed inspection.
|
||||
|
||||
### Real device testing
|
||||
|
||||
Touch interactions feel completely different on a real phone versus a trackpad simulator.
|
||||
Always test gestures, drag interactions, and spring animations on physical devices.
|
||||
|
||||
### Fresh eyes check
|
||||
|
||||
Review animations with fresh eyes the next day. What felt right at 11pm during
|
||||
development often feels too fast, too slow, or too dramatic the next morning.
|
||||
|
||||
### The checklist
|
||||
|
||||
Before shipping any animation, verify:
|
||||
- Smooth color transitions (no banding or flashing)?
|
||||
- Correct easing curve for the interaction type?
|
||||
- Right `transform-origin` (elements scaling/rotating from the expected point)?
|
||||
- All animated properties in sync (opacity and transform finishing together)?
|
||||
- No layout shift during the animation?
|
||||
- Works with `prefers-reduced-motion: reduce`?
|
||||
- Performs at 60fps on a mid-range device?
|
||||
@@ -0,0 +1,604 @@
|
||||
# Component Implementation Patterns
|
||||
|
||||
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
|
||||
|
||||
---
|
||||
|
||||
## 1. shadcn/ui Setup
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init
|
||||
npx shadcn@latest add button input form card dialog select sheet toast
|
||||
```
|
||||
|
||||
Key concepts:
|
||||
|
||||
- **Not an npm package** -- components are copied into your project. You own the code and can modify it freely.
|
||||
- Built on **Radix UI** primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
|
||||
- Styled with **Tailwind CSS** utilities -- no CSS-in-JS runtime.
|
||||
- Required dependencies:
|
||||
- `class-variance-authority` (CVA) -- variant management
|
||||
- `clsx` -- conditional class joining
|
||||
- `tailwind-merge` -- deduplicates conflicting Tailwind classes
|
||||
- `lucide-react` -- icon library
|
||||
- `tailwindcss-animate` -- animation utilities
|
||||
|
||||
The `cn()` utility combines `clsx` and `tailwind-merge`:
|
||||
|
||||
```ts
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. CSS Variables for Theming (HSL Format)
|
||||
|
||||
shadcn uses HSL values without the `hsl()` wrapper so Tailwind can apply opacity modifiers:
|
||||
|
||||
```css
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
/* ... remaining dark overrides */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in `tailwind.config.ts`:
|
||||
|
||||
```ts
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
// ...
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Button Patterns
|
||||
|
||||
Use CVA to define variants declaratively:
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **Press feedback**: `active:scale-[0.97]` gives tactile response without layout shift.
|
||||
- **Focus ring**: Always visible via `focus-visible:ring-2`. Never use `outline: none` without a replacement.
|
||||
- **Loading state**: Disable the button and show a spinner inline.
|
||||
|
||||
```tsx
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, isLoading, children, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Form Patterns (React Hook Form + Zod)
|
||||
|
||||
Schema-first validation keeps validation logic co-located and type-safe:
|
||||
|
||||
```tsx
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
name: z.string().min(2).max(50),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
```
|
||||
|
||||
The shadcn Form components wire React Hook Form to accessible markup:
|
||||
|
||||
```tsx
|
||||
function SignUpForm() {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
mode: "onBlur", // validate on blur, not keystroke
|
||||
})
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
// handle submission
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* ...more fields */}
|
||||
<Button type="submit" isLoading={form.formState.isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Accessibility rules:
|
||||
|
||||
- `FormMessage` renders error text with `aria-describedby` linked to the input.
|
||||
- Inputs get `aria-invalid="true"` when in error state automatically.
|
||||
- Mark required fields with `aria-required="true"`.
|
||||
- Validate on **blur**, not on every keystroke -- reduces noise and respects user flow.
|
||||
- Use **progressive disclosure** for complex forms: show additional fields only when relevant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Card Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription,
|
||||
CardContent, CardFooter,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Project Settings</CardTitle>
|
||||
<CardDescription>Manage your project configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* form fields or content */}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
Design rules:
|
||||
|
||||
- **Concentric border radius**: Outer radius = inner radius + padding. If inner elements have `rounded-md` (6px) and padding is 16px, outer card should be `rounded-xl` (12px) or greater.
|
||||
- **Layered shadows**: Use multiple shadow values for natural depth -- `shadow-sm` at rest, `shadow-lg` on hover.
|
||||
- **Hover lift**: Subtle `translateY(-2px)` on hover, never more than 4px.
|
||||
- Use semantic color tokens (`bg-card`, `text-card-foreground`) so cards adapt to theme changes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dialog (Modal) Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Dialog, DialogTrigger, DialogContent,
|
||||
DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Profile</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your profile here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* form content */}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
Accessibility and interaction rules (handled by Radix):
|
||||
|
||||
- **Focus trap**: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
|
||||
- **ESC to close**: Always. No exceptions.
|
||||
- **Click outside overlay**: Closes the dialog by default.
|
||||
- `aria-modal="true"` is set automatically.
|
||||
- `aria-labelledby` points to `DialogTitle`, `aria-describedby` points to `DialogDescription`.
|
||||
- **Restore focus**: When dialog closes, focus returns to the trigger element.
|
||||
- **Animation origin**: `transform-origin: center` -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Select/Dropdown Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue,
|
||||
SelectContent, SelectItem, SelectGroup, SelectLabel,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Fruits</SelectLabel>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="blueberry">Blueberry</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
Interaction rules:
|
||||
|
||||
- **Keyboard navigation**: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
|
||||
- ARIA: `aria-haspopup="listbox"` on trigger, `aria-expanded` toggles with open state.
|
||||
- **Transform origin**: Popover should animate from the trigger position (origin-aware), not from center.
|
||||
- **Tooltip delay skip**: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sheet (Slide-over) Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Sheet, SheetTrigger, SheetContent,
|
||||
SheetHeader, SheetTitle, SheetDescription,
|
||||
SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Menu</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Browse sections of the app.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-2 py-4">
|
||||
{/* nav links */}
|
||||
</nav>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
Use cases:
|
||||
|
||||
- **Mobile navigation**: Slide from left with full-height overlay.
|
||||
- **Detail panels**: Slide from right to show item details without leaving the list view.
|
||||
- **Filters**: Slide from bottom on mobile for filter controls.
|
||||
|
||||
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
|
||||
|
||||
---
|
||||
|
||||
## 9. Toast/Notification Patterns
|
||||
|
||||
Using the shadcn Toast (or Sonner for a lighter API):
|
||||
|
||||
```tsx
|
||||
// With shadcn toast
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
function SaveButton() {
|
||||
const { toast } = useToast()
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
toast({
|
||||
title: "Changes saved",
|
||||
description: "Your settings have been updated.",
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// With Sonner (simpler API)
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved")
|
||||
toast.error("Something went wrong")
|
||||
toast.promise(saveSettings(), {
|
||||
loading: "Saving...",
|
||||
success: "Settings saved",
|
||||
error: "Could not save",
|
||||
})
|
||||
```
|
||||
|
||||
Design and accessibility rules:
|
||||
|
||||
- **Auto-dismiss**: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
|
||||
- `aria-live="polite"` -- screen readers announce without stealing focus.
|
||||
- **CSS transitions, not keyframes** -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
|
||||
- **Pause timers** when the browser tab is hidden (`document.visibilityState`).
|
||||
- **Swipe to dismiss**: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
|
||||
|
||||
---
|
||||
|
||||
## 10. Table Patterns
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Table, TableHeader, TableBody, TableFooter,
|
||||
TableHead, TableRow, TableCell, TableCaption,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableCaption>A list of recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">{invoice.id}</TableCell>
|
||||
<TableCell>{invoice.status}</TableCell>
|
||||
<TableCell className="text-right">{invoice.amount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Responsive**: Wrap table in `overflow-x-auto` container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
|
||||
- **Sortable columns**: Use `aria-sort="ascending"` or `aria-sort="descending"` on the active `TableHead`. Show a visual indicator (chevron icon).
|
||||
- **Virtualization**: For lists exceeding ~50 items, use `@tanstack/react-virtual` or similar to render only visible rows.
|
||||
- **Row distinction**: Use zebra striping (`even:bg-muted/50`) or subtle borders between rows. Never rely on color alone.
|
||||
|
||||
---
|
||||
|
||||
## 11. Chart Integration
|
||||
|
||||
When integrating charts (Recharts, Chart.js, or similar):
|
||||
|
||||
- **Match chart type to data intent**:
|
||||
- Trend over time: line chart
|
||||
- Comparison across categories: bar chart
|
||||
- Part-of-whole: pie/donut chart
|
||||
- Distribution: histogram
|
||||
- Correlation: scatter plot
|
||||
|
||||
- **Accessible color palettes**: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
|
||||
- **Always include a legend** and provide **tooltips on hover/focus** for precise values.
|
||||
- **Screen reader alternative**: Provide a visually hidden `<table>` with the same data so screen readers can access it.
|
||||
- **Respect `prefers-reduced-motion`**: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
|
||||
|
||||
```tsx
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches
|
||||
|
||||
<LineChart data={data}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
animationDuration={prefersReducedMotion ? 0 : 500}
|
||||
/>
|
||||
</LineChart>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Server Component Wrapping (Next.js)
|
||||
|
||||
Most shadcn/ui components use React state or event handlers and require `"use client"`. Structure your components to keep data fetching in server components:
|
||||
|
||||
```tsx
|
||||
// app/dashboard/page.tsx (Server Component -- no "use client")
|
||||
import { getProjects } from "@/lib/data"
|
||||
import { ProjectList } from "./project-list"
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const projects = await getProjects()
|
||||
return <ProjectList projects={projects} />
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/dashboard/project-list.tsx (Client Component)
|
||||
"use client"
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ProjectListProps {
|
||||
projects: { id: string; name: string; status: string }[]
|
||||
}
|
||||
|
||||
export function ProjectList({ projects }: ProjectListProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{project.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{project.status}</p>
|
||||
<Button variant="outline" size="sm">View</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The pattern: **Server component fetches data, passes to client component as serializable props.** This keeps the client bundle small and data fetching on the server.
|
||||
|
||||
---
|
||||
|
||||
## 13. CVA (class-variance-authority) Deep Dive
|
||||
|
||||
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
|
||||
|
||||
```ts
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
|
||||
- **Compose with `cn()`**: Always wrap CVA output with `cn()` so consumer-passed `className` can override defaults via `tailwind-merge`.
|
||||
- **Type extraction**: `VariantProps<typeof badgeVariants>` generates the TypeScript type for variant props automatically.
|
||||
- **Compound variants**: Handle combinations of variant values that need special styling:
|
||||
|
||||
```ts
|
||||
const inputVariants = cva("...", {
|
||||
variants: {
|
||||
size: { sm: "...", lg: "..." },
|
||||
state: { error: "...", success: "..." },
|
||||
},
|
||||
compoundVariants: [
|
||||
{ size: "sm", state: "error", class: "border-2 border-red-500" },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
- **Use CVA for any component with visual variants** -- buttons, badges, alerts, inputs, cards. It replaces manual `if/else` class concatenation with a declarative, type-safe API.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Pre-Delivery Review Checklist
|
||||
|
||||
Extended 30-item checklist for UI implementation quality. Run through this before marking any UI task as complete.
|
||||
|
||||
## Typography (6 items)
|
||||
|
||||
### 1. Font Smoothing Applied
|
||||
- **Check**: Root layout has `-webkit-font-smoothing: antialiased`
|
||||
- **How**: Inspect `<html>` or `<body>` computed styles
|
||||
- **Failing looks like**: Text appears heavy/blurry on macOS, especially at small sizes
|
||||
|
||||
### 2. Headings Use text-wrap: balance
|
||||
- **Check**: All `<h1>`–`<h4>` elements have `text-wrap: balance`
|
||||
- **How**: Resize viewport to trigger wrapping — headings should break evenly
|
||||
- **Failing looks like**: One long line followed by a single orphan word
|
||||
|
||||
### 3. Body Text Uses text-wrap: pretty
|
||||
- **Check**: Paragraphs and body text use `text-wrap: pretty`
|
||||
- **How**: Check for orphaned words at the end of paragraphs
|
||||
- **Failing looks like**: A single short word sitting alone on the last line
|
||||
|
||||
### 4. Dynamic Numbers Use tabular-nums
|
||||
- **Check**: Counters, prices, timers, and data columns have `font-variant-numeric: tabular-nums`
|
||||
- **How**: Watch numbers update — layout should not shift
|
||||
- **Failing looks like**: Content jumps horizontally as digits change width
|
||||
|
||||
### 5. Line Length Controlled
|
||||
- **Check**: Body text containers are capped at `max-width: 65ch`
|
||||
- **How**: Measure character count on a full-width line
|
||||
- **Failing looks like**: Text stretching edge-to-edge on wide monitors, hard to read
|
||||
|
||||
### 6. Type Scale Consistency
|
||||
- **Check**: All text sizes come from the defined type scale (no arbitrary sizes)
|
||||
- **How**: Inspect font sizes — they should match scale values (12/14/16/18/24/32/48)
|
||||
- **Failing looks like**: Random sizes like 15px, 19px, 22px that aren't in the scale
|
||||
|
||||
## Color & Theme (5 items)
|
||||
|
||||
### 7. Semantic Color Tokens Only
|
||||
- **Check**: No hardcoded hex/rgb values in component code
|
||||
- **How**: Search for `#[0-9a-f]` or `rgb(` in component files
|
||||
- **Failing looks like**: `background: #3b82f6` instead of `bg-primary` or `var(--primary)`
|
||||
|
||||
### 8. WCAG AA Contrast Met
|
||||
- **Check**: Normal text ≥ 4.5:1, large text ≥ 3:1, UI components ≥ 3:1
|
||||
- **How**: Run axe-core or Chrome DevTools contrast checker
|
||||
- **Failing looks like**: Light gray text on white background, low-contrast placeholders
|
||||
|
||||
### 9. Dark Mode Contrast Verified
|
||||
- **Check**: Contrast ratios pass in dark mode separately
|
||||
- **How**: Toggle dark mode, re-run contrast checks
|
||||
- **Failing looks like**: Passing in light mode but failing in dark (common with desaturated variants)
|
||||
|
||||
### 10. Color Not Sole Information Channel
|
||||
- **Check**: Error, success, warning states use icon + text alongside color
|
||||
- **How**: View the page in grayscale (browser DevTools → Rendering → Emulate vision deficiency)
|
||||
- **Failing looks like**: Red border on error field with no icon or text explanation
|
||||
|
||||
### 11. Dark Mode Visual Review
|
||||
- **Check**: All surfaces, borders, shadows, and text are legible in dark mode
|
||||
- **How**: Toggle dark mode and visually scan every component
|
||||
- **Failing looks like**: Invisible borders, washed-out shadows, or text-on-background collision
|
||||
|
||||
## Layout & Spatial (5 items)
|
||||
|
||||
### 12. Concentric Border Radius
|
||||
- **Check**: Outer radius = inner radius + padding on all nested rounded elements
|
||||
- **How**: Inspect nested cards, buttons-in-containers, input groups
|
||||
- **Failing looks like**: Inner and outer corners don't follow the same curvature — looks "off"
|
||||
|
||||
| Before | After | Why |
|
||||
|--------|-------|-----|
|
||||
| Parent `rounded-lg` (12px), child `rounded-lg` (12px), padding 8px | Parent `rounded-xl` (16px), child `rounded-md` (8px), padding 8px | 8 + 8 = 16 — radii now concentric |
|
||||
|
||||
### 13. Spacing Follows Scale
|
||||
- **Check**: All padding, margin, and gap values are multiples of 4px
|
||||
- **How**: Inspect spacing values — no 5px, 7px, 13px, 19px etc.
|
||||
- **Failing looks like**: Inconsistent spacing that makes the layout feel uneven
|
||||
|
||||
### 14. Hit Areas Meet Minimum
|
||||
- **Check**: All interactive elements have at least 44×44px clickable area
|
||||
- **How**: Use browser DevTools to measure element + padding dimensions
|
||||
- **Failing looks like**: Tiny icon buttons, close buttons, or links that are hard to tap on mobile
|
||||
|
||||
### 15. Shadows Over Borders
|
||||
- **Check**: Depth is created with layered box-shadows, not solid borders between sections
|
||||
- **How**: Look for `border: 1px solid` between content sections
|
||||
- **Failing looks like**: Hard dividing lines instead of natural depth transitions
|
||||
|
||||
### 16. Optical Alignment Verified
|
||||
- **Check**: Icons in buttons, play triangles, and asymmetric elements are optically centered
|
||||
- **How**: Squint at the element — does it look centered to the eye?
|
||||
- **Failing looks like**: A play triangle that's geometrically centered but looks shifted left
|
||||
|
||||
## Motion & Interaction (7 items)
|
||||
|
||||
### 17. Animation Frequency Appropriate
|
||||
- **Check**: High-frequency actions (keyboard shortcuts, command palette) have NO animation
|
||||
- **How**: Review the frequency table — occasional actions get standard animation, frequent actions get none
|
||||
- **Failing looks like**: A command palette with a 300ms open animation that feels sluggish after the 50th use
|
||||
|
||||
### 18. No `transition: all`
|
||||
- **Check**: Every transition specifies exact properties
|
||||
- **How**: Search for `transition: all` or `transition-property: all`
|
||||
- **Failing looks like**: Unintended properties animating (color, padding, border) causing jank
|
||||
|
||||
### 19. Custom Easing Curves Used
|
||||
- **Check**: UI animations use custom bezier curves, not built-in `ease`, `ease-in`, `ease-out`
|
||||
- **How**: Inspect transition/animation easing values
|
||||
- **Failing looks like**: Animations feel generic and lack punch
|
||||
|
||||
### 20. Enter Animations Split and Staggered
|
||||
- **Check**: Multi-element entrances use 30-80ms stagger between items
|
||||
- **How**: Watch page load or section reveal — elements should cascade, not appear all at once
|
||||
- **Failing looks like**: An entire section popping in as one block
|
||||
|
||||
### 21. Press Feedback on Buttons
|
||||
- **Check**: All pressable elements have subtle `scale(0.96-0.97)` on `:active`
|
||||
- **How**: Click and hold buttons — they should compress slightly
|
||||
- **Failing looks like**: Clicking a button with zero visual feedback
|
||||
|
||||
### 22. prefers-reduced-motion Respected
|
||||
- **Check**: Animations reduce/simplify when the user has reduced motion enabled
|
||||
- **How**: Enable reduced motion in OS settings, reload, check all animations
|
||||
- **Failing looks like**: Full animations playing for users who opted out
|
||||
|
||||
### 23. Hover States Gated
|
||||
- **Check**: Hover animations are behind `@media (hover: hover) and (pointer: fine)`
|
||||
- **How**: Test on touch device or emulate touch in DevTools
|
||||
- **Failing looks like**: Hover states triggering on tap on mobile, causing sticky hover effects
|
||||
|
||||
## Accessibility (7 items)
|
||||
|
||||
### 24. Keyboard Navigation Complete
|
||||
- **Check**: Every interactive element is reachable and operable with keyboard only
|
||||
- **How**: Unplug mouse, Tab through entire page, operate every control
|
||||
- **Failing looks like**: Unreachable buttons, inoperable dropdowns, trapped focus
|
||||
|
||||
### 25. Focus Rings Visible
|
||||
- **Check**: Every focusable element has a visible focus indicator
|
||||
- **How**: Tab through the page and verify each element shows focus
|
||||
- **Failing looks like**: `outline: none` with no replacement, invisible focus state
|
||||
|
||||
### 26. Semantic HTML Used
|
||||
- **Check**: `<button>` for actions, `<a>` for links, `<nav>` for navigation, proper heading hierarchy
|
||||
- **How**: Inspect the DOM — look for `<div onclick>` or `<span>` where buttons should be
|
||||
- **Failing looks like**: Divs with click handlers instead of buttons, missing landmarks
|
||||
|
||||
### 27. ARIA Labels on Icon Buttons
|
||||
- **Check**: Every icon-only button has `aria-label` describing its action
|
||||
- **How**: Inspect icon buttons in DevTools or run axe-core
|
||||
- **Failing looks like**: Screen reader announcing "button" with no context
|
||||
|
||||
### 28. Form Errors Accessible
|
||||
- **Check**: Error messages use `aria-live` or `role="alert"`, linked via `aria-describedby`
|
||||
- **How**: Submit an invalid form, check screen reader announces errors
|
||||
- **Failing looks like**: Visual error message that screen reader users never hear
|
||||
|
||||
### 29. Images Have Alt Text
|
||||
- **Check**: Meaningful images have descriptive `alt`, decorative images have `alt=""`
|
||||
- **How**: Search for `<img>` without `alt` attribute
|
||||
- **Failing looks like**: Screen reader announcing file names or nothing for important images
|
||||
|
||||
### 30. Skip Link Present
|
||||
- **Check**: First focusable element is "Skip to main content" link
|
||||
- **How**: Tab once on page load — skip link should appear
|
||||
- **Failing looks like**: Keyboard users forced to Tab through entire header/nav on every page
|
||||
|
||||
## Quick Pass/Fail Summary
|
||||
|
||||
Use this table to record results:
|
||||
|
||||
| # | Item | Pass | Notes |
|
||||
|---|------|------|-------|
|
||||
| 1 | Font smoothing | | |
|
||||
| 2 | text-wrap: balance | | |
|
||||
| 3 | text-wrap: pretty | | |
|
||||
| 4 | tabular-nums | | |
|
||||
| 5 | Line length | | |
|
||||
| 6 | Type scale | | |
|
||||
| 7 | Semantic tokens | | |
|
||||
| 8 | WCAG contrast | | |
|
||||
| 9 | Dark mode contrast | | |
|
||||
| 10 | Color not sole channel | | |
|
||||
| 11 | Dark mode visual | | |
|
||||
| 12 | Concentric radius | | |
|
||||
| 13 | Spacing scale | | |
|
||||
| 14 | Hit areas | | |
|
||||
| 15 | Shadows over borders | | |
|
||||
| 16 | Optical alignment | | |
|
||||
| 17 | Animation frequency | | |
|
||||
| 18 | No transition: all | | |
|
||||
| 19 | Custom easing | | |
|
||||
| 20 | Staggered enter | | |
|
||||
| 21 | Press feedback | | |
|
||||
| 22 | Reduced motion | | |
|
||||
| 23 | Hover gated | | |
|
||||
| 24 | Keyboard nav | | |
|
||||
| 25 | Focus rings | | |
|
||||
| 26 | Semantic HTML | | |
|
||||
| 27 | ARIA labels | | |
|
||||
| 28 | Form errors | | |
|
||||
| 29 | Alt text | | |
|
||||
| 30 | Skip link | | |
|
||||
@@ -10,3 +10,7 @@ reference/
|
||||
# Scratch
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# Test run artifacts
|
||||
WhiteMagicTest/TestResults/
|
||||
**/TestResults/
|
||||
|
||||
@@ -1,3 +1,190 @@
|
||||
# WhiteMagic
|
||||
|
||||
Wite Magic is a C# library to read, write and execute remote code into a target process for analysis, debuging and mod creation.
|
||||
WhiteMagic is a .NET 8 process-introspection library for Windows that provides managed wrappers over Win32 debugging APIs. It enables reading/writing memory, executing code remotely, hooking functions, and injecting DLLs into target processes.
|
||||
|
||||
## Overview
|
||||
|
||||
WhiteMagic is designed for diagnostic tools, debuggers, profilers, and automation clients that need to attach to and manipulate desktop applications. It unifies the best features from four legacy libraries (BlackMagic, MemorySharp, GreyMagic, and BlackMagic-old) into a modern, bitness-agnostic .NET 8 library with a crash-safe execution model.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Dual memory access**: External (ReadProcessMemory/WriteProcessMemory) and in-process readers over an abstract `MemoryBase`
|
||||
- **Three-tier execution model**: Remote thread, crash-safe main-thread pump, and in-process delegates
|
||||
- **Function hooking**: Reversible inline detours (`DetourManager`) and named byte patches (`PatchManager`) with auto-restore on dispose
|
||||
- **Pattern scanning**: Memory pattern discovery with caching
|
||||
- **Assembly seam**: `IAssembler` abstraction with hand-emitted stubs (default) and optional Iced backend
|
||||
- **DLL injection**: CreateThread and thread-hijack strategies for x86 and x64
|
||||
- **High-level API**: Ergonomic `RemotePointer` indexer, module/function access, PEB/TEB, window mutation, and input simulation
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
dotnet add package WhiteMagic
|
||||
```
|
||||
|
||||
### Basic Memory Read/Write
|
||||
|
||||
```csharp
|
||||
using WhiteMagic;
|
||||
using var magic = Magic.Open(Process.GetProcessById(1234));
|
||||
|
||||
// Read an integer at an address
|
||||
int health = magic.Memory.Read<int>(0x12345678);
|
||||
|
||||
// Write using the RemotePointer indexer
|
||||
magic[0x12345678].Write(999);
|
||||
|
||||
// Read a string
|
||||
string name = magic.Memory.ReadString(0x12345680, Encoding.UTF8);
|
||||
```
|
||||
|
||||
### Remote Function Execution
|
||||
|
||||
```csharp
|
||||
using WhiteMagic.Assembly;
|
||||
|
||||
// Resolve a function by module and export name
|
||||
var msgBox = magic["user32"]["MessageBoxA"];
|
||||
|
||||
// Execute with calling convention and arguments
|
||||
int result = msgBox.Execute<int>(
|
||||
CallConvention.Stdcall,
|
||||
IntPtr.Zero, // hWnd
|
||||
"Hello World", // Text
|
||||
"Caption", // Caption
|
||||
0 // Type
|
||||
);
|
||||
```
|
||||
|
||||
### Crash-Safe Execution (Main-Thread Pump)
|
||||
|
||||
```csharp
|
||||
// Create a pump that hooks a per-frame function
|
||||
var pump = magic.CreateMainThreadPump(frameAddress);
|
||||
|
||||
// Enqueue work that runs on the target's main thread
|
||||
int result = await pump.ExecuteAsync(() =>
|
||||
{
|
||||
// Safe to touch target's single-threaded state here
|
||||
return magic.Memory.Read<int>(stateAddress);
|
||||
});
|
||||
```
|
||||
|
||||
### Function Hooking
|
||||
|
||||
```csharp
|
||||
// Apply an inline detour (in-process only)
|
||||
var detour = magic.DetourManager.Create(
|
||||
"my_hook",
|
||||
targetFunctionAddress,
|
||||
myHookDelegate
|
||||
);
|
||||
|
||||
detour.Apply();
|
||||
// ... use the hook
|
||||
detour.Remove(); // Automatically restored on dispose
|
||||
```
|
||||
|
||||
### Pattern Scanning
|
||||
|
||||
```csharp
|
||||
// Scan for a byte pattern in the target's memory
|
||||
byte[] pattern = { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 };
|
||||
string mask = "xxx????";
|
||||
IntPtr found = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
pattern,
|
||||
mask,
|
||||
process.MainModule
|
||||
);
|
||||
```
|
||||
## Documentation
|
||||
|
||||
- **API Reference**: See inline XML documentation in your IDE; generate with DocFX
|
||||
- **Conceptual Guides**: [docs/](./docs/) directory
|
||||
- **Examples**: [WhiteMagic.Examples](./WhiteMagic.Examples/) with 5 comprehensive example files
|
||||
- **Architecture**: [openspec/changes/whitemagic-foundation/](./openspec/changes/whitemagic-foundation/)
|
||||
|
||||
## Execution Models
|
||||
|
||||
WhiteMagic provides three execution strategies, each designed for specific payload safety requirements:
|
||||
|
||||
### 1. RemoteThreadExecutor (CreateRemoteThread)
|
||||
Use for **thread-agnostic payloads only**:
|
||||
- Pure WinAPI calls
|
||||
- Self-contained computations
|
||||
- DLL injection (`LoadLibrary`)
|
||||
|
||||
**Avoid** for single-threaded target state (scripting engines, render operations, object traversal).
|
||||
|
||||
### 2. MainThreadPump (Crash-Safe)
|
||||
Use for **state-sensitive calls** that touch the target's main-thread-affinity data:
|
||||
- Game state modifications
|
||||
- UI interactions
|
||||
- Script engine calls
|
||||
|
||||
### 3. InProcessInvoker (Direct Delegates)
|
||||
Use when **injected** into the target process:
|
||||
- Direct native-delegate calls via `CreateFunction<T>`
|
||||
- Zero thread crossing overhead
|
||||
|
||||
## Architecture
|
||||
|
||||
WhiteMagic is built in layers:
|
||||
|
||||
```
|
||||
Magic (high-level facade)
|
||||
├── Core: MemoryBase (abstract reader/writer)
|
||||
│ ├── ExternalReader (RPM/WPM on target)
|
||||
│ └── InProcessReader (in-process delegate access)
|
||||
├── Discovery: PatternScanner, PeHeaderParser
|
||||
├── Assembly: IAssembler → {StubAssembler | IcedAssembler}
|
||||
├── Execution: RemoteThreadExecutor, MainThreadPump, InProcessInvoker
|
||||
├── Hooking: DetourManager, PatchManager
|
||||
└── High-level: RemotePointer, RemoteModule, RemoteWindow
|
||||
```
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **Target Framework**: .NET 8.0-windows
|
||||
- **Architecture**: x86 and x64 (host and target)
|
||||
- **Operating System**: Windows 10+
|
||||
- **Dependencies**: None (default); optional Iced NuGet package for arbitrary assembly
|
||||
|
||||
## Safety
|
||||
|
||||
- All operations use `SafeMemoryHandle` for proper handle cleanup
|
||||
- Detours and patches auto-restore on `Dispose`
|
||||
- MainThreadPump prevents crashes from thread-affinity violations
|
||||
- Prologue validation before detour splicing (optional Iced backend)
|
||||
|
||||
## Comparison to Reference Libraries
|
||||
|
||||
| Library | Platform | Execution | Hooking | Assembler |
|
||||
|---------|----------|-----------|---------|-----------|
|
||||
| **WhiteMagic** | .NET 8, x86+x64 | 3-tier | ✅ | IAssembler (Iced opt.) |
|
||||
| BlackMagic | .NET 8, x86+x64 | Remote thread | ❌ | Hand stubs |
|
||||
| MemorySharp | .NET FW, x86 | Remote thread | ❌ | FASM (required) |
|
||||
| GreyMagic | .NET FW, x86 | In-process | ✅ | FASM |
|
||||
|
||||
WhiteMagic is **not a drop-in replacement** for these libraries—it's a modern synthesis with a different API design and a crash-safe execution model.
|
||||
|
||||
## License
|
||||
|
||||
[Specify your license here]
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read our contributing guidelines (coming soon).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
WhiteMagic builds upon concepts and techniques from:
|
||||
- **BlackMagic** (current) — Modern .NET 8 base, x64, pattern scanning, DLL injection
|
||||
- **MemorySharp** — High-level ergonomics, PEB/TEB, window/input simulation
|
||||
- **GreyMagic** — Dual memory model, detours/patches, marshal cache, in-process delegates
|
||||
- **Iced** — Modern x86/x64 assembler (optional backend)
|
||||
|
||||
See [docs/memory-library-comparison.md](./docs/memory-library-comparison.md) for a detailed analysis.
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Execution;
|
||||
|
||||
namespace WhiteMagic.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Sample struct for demonstrating struct marshalling
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct PlayerInfo
|
||||
{
|
||||
public int Health;
|
||||
public float PositionX;
|
||||
public float PositionY;
|
||||
public int Level;
|
||||
public uint Experience;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1: Basic Memory Operations
|
||||
/// Demonstrates reading and writing memory of various types
|
||||
/// </summary>
|
||||
public class BasicMemoryOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Target process for examples (Notepad is safe and always available)
|
||||
/// </summary>
|
||||
private static Process? TargetProcess()
|
||||
{
|
||||
// Try to find Notepad
|
||||
var processes = Process.GetProcessesByName("notepad");
|
||||
if (processes.Length > 0)
|
||||
return processes[0];
|
||||
|
||||
Console.WriteLine("No Notepad process found. Please launch Notepad first.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.1: Reading and writing primitive types
|
||||
/// </summary>
|
||||
public static void ReadWritePrimitives()
|
||||
{
|
||||
Console.WriteLine("=== Example 1.1: Read/Write Primitives ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Example: Read an integer from a hypothetical address
|
||||
// (In real use, you'd find the actual address via pattern scanning or offsets)
|
||||
IntPtr testAddress = magic.Memory.ImageBase + 0x1000;
|
||||
|
||||
// Read different primitive types
|
||||
try
|
||||
{
|
||||
int intValue = magic.Memory.Read<int>(testAddress);
|
||||
Console.WriteLine($"Read int from 0x{testAddress:X}: {intValue}");
|
||||
|
||||
float floatValue = magic.Memory.Read<float>(testAddress);
|
||||
Console.WriteLine($"Read float from 0x{testAddress:X}: {floatValue}");
|
||||
|
||||
double doubleValue = magic.Memory.Read<double>(testAddress);
|
||||
Console.WriteLine($"Read double from 0x{testAddress:X}: {doubleValue}");
|
||||
|
||||
bool boolValue = magic.Memory.Read<bool>(testAddress);
|
||||
Console.WriteLine($"Read bool from 0x{testAddress:X}: {boolValue}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Note: Read failed (address may be invalid): {ex.Message}");
|
||||
}
|
||||
|
||||
// Write example
|
||||
bool writeSuccess = magic.Memory.Write(testAddress, 42);
|
||||
Console.WriteLine($"Write to 0x{testAddress:X}: {(writeSuccess ? "Success" : "Failed")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.2: Reading and writing arrays
|
||||
/// </summary>
|
||||
public static void ReadWriteArrays()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.2: Read/Write Arrays ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
IntPtr arrayAddress = magic.Memory.ImageBase + 0x2000;
|
||||
|
||||
// Read array of integers
|
||||
try
|
||||
{
|
||||
int[] intArray = magic.Memory.Read<int>(arrayAddress, 10);
|
||||
Console.WriteLine($"Read {intArray.Length} integers from 0x{arrayAddress:X}");
|
||||
Console.WriteLine($"First few values: {string.Join(", ", intArray[..Math.Min(5, intArray.Length)])}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Note: Array read failed (address may be invalid): {ex.Message}");
|
||||
}
|
||||
|
||||
// Write array of integers
|
||||
int[] writeArray = { 1, 2, 3, 4, 5 };
|
||||
bool writeSuccess = magic.Memory.Write(arrayAddress, writeArray);
|
||||
Console.WriteLine($"Write {writeArray.Length} integers to 0x{arrayAddress:X}: {(writeSuccess ? "Success" : "Failed")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.3: Reading and writing strings
|
||||
/// </summary>
|
||||
public static void ReadWriteStrings()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.3: Read/Write Strings ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
IntPtr stringAddress = magic.Memory.ImageBase + 0x3000;
|
||||
|
||||
// Read ANSI string
|
||||
string ansiString = magic.Memory.ReadString(stringAddress, Encoding.ASCII, maxLength: 256);
|
||||
Console.WriteLine($"Read ANSI string from 0x{stringAddress:X}: \"{ansiString}\"");
|
||||
|
||||
// Read Unicode string
|
||||
string unicodeString = magic.Memory.ReadString(stringAddress, Encoding.Unicode, maxLength: 256);
|
||||
Console.WriteLine($"Read Unicode string from 0x{stringAddress:X}: \"{unicodeString}\"");
|
||||
|
||||
// Write string
|
||||
bool writeSuccess = magic.Memory.WriteString(stringAddress, "Hello, WhiteMagic!", Encoding.ASCII);
|
||||
Console.WriteLine($"Write string to 0x{stringAddress:X}: {(writeSuccess ? "Success" : "Failed")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.4: Reading and writing raw bytes
|
||||
/// </summary>
|
||||
public static void ReadWriteBytes()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.4: Read/Write Bytes ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
IntPtr address = magic.Memory.ImageBase + 0x4000;
|
||||
|
||||
// Read bytes
|
||||
byte[] buffer = magic.Memory.ReadBytes(address, 16);
|
||||
Console.WriteLine($"Read {buffer.Length} bytes from 0x{address:X}");
|
||||
Console.WriteLine($"Hex: {BitConverter.ToString(buffer)}");
|
||||
|
||||
// Write bytes
|
||||
byte[] patchBytes = { 0x90, 0x90, 0x90 }; // NOP x3
|
||||
int bytesWritten = magic.Memory.WriteBytes(address, patchBytes);
|
||||
Console.WriteLine($"Wrote {bytesWritten} bytes to 0x{address:X}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.5: Using RemotePointer for fluent addressing
|
||||
/// </summary>
|
||||
public static void RemotePointerUsage()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.5: RemotePointer Fluent Addressing ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Create a pointer to module base
|
||||
var modulePtr = magic[magic.Memory.ImageBase];
|
||||
|
||||
// Read offsets from module base
|
||||
try
|
||||
{
|
||||
int offset1 = modulePtr.Read<int>(0x1000);
|
||||
Console.WriteLine($"Read int at module_base + 0x1000: {offset1}");
|
||||
|
||||
// Chained reads (pointer -> value -> offset)
|
||||
IntPtr nestedPtr = modulePtr.Read<IntPtr>(0x2000);
|
||||
if (nestedPtr != IntPtr.Zero)
|
||||
{
|
||||
var nestedPtrObj = magic[nestedPtr];
|
||||
int nestedValue = nestedPtrObj.Read<int>(0x50);
|
||||
Console.WriteLine($"Read nested pointer value: {nestedValue}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Note: Read failed (offset may be invalid): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.6: Relative addressing
|
||||
/// </summary>
|
||||
public static void RelativeAddressing()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.6: Relative Addressing ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
IntPtr moduleBase = magic.Memory.ImageBase;
|
||||
|
||||
// Absolute addressing (default)
|
||||
int absValue = magic.Memory.Read<int>(moduleBase + 0x1000);
|
||||
Console.WriteLine($"Absolute read at 0x{moduleBase + 0x1000:X}: {absValue}");
|
||||
|
||||
// Relative addressing (relative to module base)
|
||||
int relValue = magic.Memory.Read<int>(0x1000, isRelative: true);
|
||||
Console.WriteLine($"Relative read at offset 0x1000: {relValue}");
|
||||
|
||||
// They should be the same
|
||||
Console.WriteLine($"Values match: {absValue == relValue}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.7: Error handling
|
||||
/// </summary>
|
||||
public static void ErrorHandling()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.7: Error Handling ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Try to read from invalid address
|
||||
try
|
||||
{
|
||||
int invalidRead = magic.Memory.Read<int>(unchecked((IntPtr)0xdeadbeef));
|
||||
Console.WriteLine($"Read from invalid address (shouldn't reach here): {invalidRead}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✓ Caught expected exception: {ex.GetType().Name}");
|
||||
}
|
||||
|
||||
// Try to write to read-only memory (will return false)
|
||||
bool writeSuccess = magic.Memory.Write(magic.Memory.ImageBase, 42);
|
||||
Console.WriteLine($"Write to read-only memory: {(writeSuccess ? "Unexpected success" : "✓ Expected failure")}");
|
||||
|
||||
// Try to read string from invalid address
|
||||
string invalidString = magic.Memory.ReadString(unchecked((IntPtr)0xdeadbeef), Encoding.ASCII);
|
||||
Console.WriteLine($"Read string from invalid address: \"{invalidString}\" (empty = graceful failure)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 1.8: Working with custom structs
|
||||
/// </summary>
|
||||
public static void CustomStructs()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 1.8: Custom Structs ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
IntPtr structAddress = magic.Memory.ImageBase + 0x5000;
|
||||
|
||||
try
|
||||
{
|
||||
// Read struct
|
||||
PlayerInfo player = magic.Memory.Read<PlayerInfo>(structAddress);
|
||||
Console.WriteLine($"Read PlayerInfo:");
|
||||
Console.WriteLine($" Health: {player.Health}");
|
||||
Console.WriteLine($" Position: ({player.PositionX}, {player.PositionY})");
|
||||
Console.WriteLine($" Level: {player.Level}");
|
||||
Console.WriteLine($" Experience: {player.Experience}");
|
||||
|
||||
// Write struct
|
||||
PlayerInfo updatedPlayer = player with
|
||||
{
|
||||
Health = 100,
|
||||
Level = player.Level + 1
|
||||
};
|
||||
bool writeSuccess = magic.Memory.Write(structAddress, updatedPlayer);
|
||||
Console.WriteLine($"Write updated PlayerInfo: {(writeSuccess ? "Success" : "Failed")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Note: Struct read/write failed (address may be invalid): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all basic memory operation examples
|
||||
/// </summary>
|
||||
public static void RunAll()
|
||||
{
|
||||
Console.WriteLine("╔════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ WhiteMagic Example 1: Basic Memory Operations ║");
|
||||
Console.WriteLine("╚════════════════════════════════════════════════════════════╝");
|
||||
|
||||
ReadWritePrimitives();
|
||||
ReadWriteArrays();
|
||||
ReadWriteStrings();
|
||||
ReadWriteBytes();
|
||||
RemotePointerUsage();
|
||||
RelativeAddressing();
|
||||
ErrorHandling();
|
||||
CustomStructs();
|
||||
|
||||
Console.WriteLine("\n✓ All basic memory operation examples completed!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Discovery;
|
||||
|
||||
namespace WhiteMagic.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Example 2: Pattern Scanning
|
||||
/// Demonstrates finding patterns in target process memory
|
||||
/// </summary>
|
||||
public class PatternScanning
|
||||
{
|
||||
/// <summary>
|
||||
/// Target process for examples
|
||||
/// </summary>
|
||||
private static Process? TargetProcess()
|
||||
{
|
||||
var processes = Process.GetProcessesByName("notepad");
|
||||
if (processes.Length > 0)
|
||||
return processes[0];
|
||||
|
||||
Console.WriteLine("No Notepad process found. Please launch Notepad first.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.1: Simple pattern scan
|
||||
/// </summary>
|
||||
public static void SimplePatternScan()
|
||||
{
|
||||
Console.WriteLine("=== Example 2.1: Simple Pattern Scan ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Define a pattern to search for
|
||||
// mov rax, [rip+disp] (common in x64)
|
||||
byte[] pattern = { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 };
|
||||
string mask = "xxx????"; // 'x' = exact match, '?' = wildcard
|
||||
|
||||
Console.WriteLine($"Scanning for pattern in module: {process.MainModule.ModuleName}");
|
||||
Console.WriteLine($"Pattern bytes: {BitConverter.ToString(pattern)}");
|
||||
Console.WriteLine($"Mask: {mask}");
|
||||
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
pattern,
|
||||
mask,
|
||||
process.MainModule
|
||||
);
|
||||
|
||||
if (result != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ Pattern found at: 0x{result:X}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Pattern not found");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.2: Pattern scan with multiple results
|
||||
/// </summary>
|
||||
public static void MultiplePatternScans()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.2: Multiple Pattern Scans ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Multiple patterns to scan
|
||||
byte[][] patterns =
|
||||
{
|
||||
new byte[] { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 }, // mov rax, [rip+disp]
|
||||
new byte[] { 0xE8, 0x00, 0x00, 0x00, 0x00 }, // call rel32
|
||||
new byte[] { 0xB8, 0x00, 0x00, 0x00, 0x00 } // mov eax, imm32
|
||||
};
|
||||
|
||||
string[] masks =
|
||||
{
|
||||
"xxx????",
|
||||
"x????",
|
||||
"x????"
|
||||
};
|
||||
|
||||
string[] descriptions =
|
||||
{
|
||||
"mov rax, [rip+disp]",
|
||||
"call rel32",
|
||||
"mov eax, imm32"
|
||||
};
|
||||
|
||||
Console.WriteLine($"Scanning for {patterns.Length} patterns in {process.MainModule.ModuleName}...");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
|
||||
for (int i = 0; i < patterns.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
patterns[i],
|
||||
masks[i],
|
||||
process.MainModule
|
||||
);
|
||||
|
||||
Console.WriteLine($"{descriptions[i],-25} {(result != IntPtr.Zero ? $"✓ 0x{result:X}" : "✗ Not found")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"{descriptions[i],-25} ✗ Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.3: Pattern scanning in specific region
|
||||
/// </summary>
|
||||
public static void RegionSpecificScan()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.3: Region-Specific Scan ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Scan specific region: .text section (first 64KB of main module)
|
||||
IntPtr startAddress = process.MainModule.BaseAddress;
|
||||
IntPtr endAddress = startAddress + 0x10000; // 64KB
|
||||
|
||||
byte[] pattern = { 0x48, 0x8B };
|
||||
string mask = "xx"; // Exact match for first 2 bytes
|
||||
|
||||
Console.WriteLine($"Scanning region: 0x{startAddress:X} - 0x{endAddress:X}");
|
||||
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.Find(
|
||||
magic.Memory,
|
||||
pattern,
|
||||
mask,
|
||||
startAddress,
|
||||
endAddress
|
||||
);
|
||||
|
||||
if (result != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ Pattern found at: 0x{result:X}");
|
||||
Console.WriteLine($" Offset from module base: 0x{(result - startAddress):X}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Pattern not found in region");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.4: Finding a function signature
|
||||
/// </summary>
|
||||
public static void FindFunctionSignature()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.4: Finding Function Signature ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Common function prologue patterns
|
||||
byte[][] prologues =
|
||||
{
|
||||
// x64 prologue: push rbp; mov rbp, rsp
|
||||
new byte[] { 0x55, 0x48, 0x89, 0xE5 },
|
||||
|
||||
// x64 prologue: push rbp
|
||||
new byte[] { 0x55 },
|
||||
|
||||
// x64 prologue: sub rsp, XX (stack allocation)
|
||||
new byte[] { 0x48, 0x83, 0xEC, 0x00 } // last byte varies
|
||||
};
|
||||
|
||||
string[] prologueMasks =
|
||||
{
|
||||
"xxxx", // Exact match
|
||||
"x", // Exact match
|
||||
"xxx?" // Last byte wildcard
|
||||
};
|
||||
|
||||
Console.WriteLine($"Scanning for function prologues in {process.MainModule.ModuleName}...");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
|
||||
for (int i = 0; i < prologues.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
prologues[i],
|
||||
prologueMasks[i],
|
||||
process.MainModule
|
||||
);
|
||||
|
||||
Console.WriteLine($"Prologue {i + 1}: {(result != IntPtr.Zero ? $"✓ Found at 0x{result:X}" : "✗ Not found")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Prologue {i + 1}: ✗ Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.5: Pattern scanning with caching
|
||||
/// </summary>
|
||||
public static void CachedPatternScan()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.5: Cached Pattern Scanning ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Create cache
|
||||
var cache = new PatternScannerCache(magic.Memory);
|
||||
|
||||
byte[] pattern = { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 };
|
||||
string mask = "xxx????";
|
||||
|
||||
Console.WriteLine("Demonstrating cache performance...");
|
||||
|
||||
// First scan (uncached - reads memory)
|
||||
Console.Write(" First scan (uncached): ");
|
||||
var watch = System.Diagnostics.Stopwatch.StartNew();
|
||||
IntPtr result1 = cache.FindInModuleCached(pattern, mask, process.MainModule);
|
||||
watch.Stop();
|
||||
Console.WriteLine($"{(result1 != IntPtr.Zero ? $"✓ 0x{result1:X}" : "✗ Not found")} ({watch.ElapsedMilliseconds}ms)");
|
||||
|
||||
// Second scan (cached - no memory read)
|
||||
Console.Write(" Second scan (cached): ");
|
||||
watch.Restart();
|
||||
IntPtr result2 = cache.FindInModuleCached(pattern, mask, process.MainModule);
|
||||
watch.Stop();
|
||||
Console.WriteLine($"{(result2 != IntPtr.Zero ? $"✓ 0x{result2:X}" : "✗ Not found")} ({watch.ElapsedMilliseconds}ms)");
|
||||
|
||||
if (result1 == result2)
|
||||
{
|
||||
Console.WriteLine(" ✓ Results match and cache is working");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.6: Pattern scanning with wildcard flexibility
|
||||
/// </summary>
|
||||
public static void FlexibleWildcardPatterns()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.6: Flexible Wildcard Patterns ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Same pattern, different wildcard masks
|
||||
byte[] pattern = { 0x48, 0x8B, 0x05, 0x12, 0x34, 0x56, 0x78 };
|
||||
|
||||
string[] masks =
|
||||
{
|
||||
"xxx????", // Last 4 bytes wildcard
|
||||
"xxxx???", // Last 3 bytes wildcard
|
||||
"xxxxxxx", // Exact match
|
||||
"x?x?x?x" // Alternating wildcard
|
||||
};
|
||||
|
||||
Console.WriteLine($"Testing same pattern with different masks...");
|
||||
Console.WriteLine($"Pattern: {BitConverter.ToString(pattern)}");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
|
||||
for (int i = 0; i < masks.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
pattern,
|
||||
masks[i],
|
||||
process.MainModule
|
||||
);
|
||||
|
||||
Console.WriteLine($"Mask \"{masks[i],-10}\" {(result != IntPtr.Zero ? $"✓ Found at 0x{result:X}" : "✗ Not found")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Mask \"{masks[i],-10}\" ✗ Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.7: Combining pattern scan with validation
|
||||
/// </summary>
|
||||
public static void SignatureBasedScanning()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.7: Pattern-Based Function Scanning ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Find MessageBoxA pattern in user32.dll (if loaded)
|
||||
var user32Module = process.Modules.Cast<System.Diagnostics.ProcessModule>()
|
||||
.FirstOrDefault(m => m.ModuleName.Equals("user32.dll", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (user32Module == null)
|
||||
{
|
||||
Console.WriteLine("✗ user32.dll not loaded in target process");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Scanning {user32Module.ModuleName} for function signatures...");
|
||||
|
||||
// Try to find common export patterns
|
||||
byte[] testPattern = { 0x48, 0x8B };
|
||||
string mask = "xx";
|
||||
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
testPattern,
|
||||
mask,
|
||||
user32Module
|
||||
);
|
||||
|
||||
if (result != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ Found pattern at 0x{result:X}");
|
||||
Console.WriteLine($" Module base: 0x{user32Module.BaseAddress:X}");
|
||||
Console.WriteLine($" Offset: 0x{(result - user32Module.BaseAddress):X}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ Pattern not found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 2.8: Pattern validation and error handling
|
||||
/// </summary>
|
||||
public static void PatternValidation()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 2.8: Pattern Validation ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
if (process.MainModule == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Test various invalid/edge case patterns
|
||||
Console.WriteLine("Testing edge cases and validation...");
|
||||
|
||||
// Empty pattern
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
Array.Empty<byte>(),
|
||||
null,
|
||||
process.MainModule
|
||||
);
|
||||
Console.WriteLine("✗ Empty pattern should throw exception");
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
Console.WriteLine("✓ Empty pattern correctly rejected");
|
||||
}
|
||||
|
||||
// Mismatched pattern and mask length
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
new byte[] { 0x48, 0x8B },
|
||||
"x", // Mask too short
|
||||
process.MainModule
|
||||
);
|
||||
Console.WriteLine("✗ Mismatched mask length should throw exception");
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
Console.WriteLine("✓ Mismatched mask length correctly rejected");
|
||||
}
|
||||
|
||||
// Invalid mask characters
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
new byte[] { 0x48, 0x8B },
|
||||
"ab", // Invalid mask characters
|
||||
process.MainModule
|
||||
);
|
||||
Console.WriteLine("✗ Invalid mask characters should throw exception");
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
Console.WriteLine("✓ Invalid mask characters correctly rejected");
|
||||
}
|
||||
|
||||
Console.WriteLine("✓ All validation tests passed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all pattern scanning examples
|
||||
/// </summary>
|
||||
public static void RunAll()
|
||||
{
|
||||
Console.WriteLine("╔════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ WhiteMagic Example 2: Pattern Scanning ║");
|
||||
Console.WriteLine("╚════════════════════════════════════════════════════════════╝");
|
||||
|
||||
SimplePatternScan();
|
||||
MultiplePatternScans();
|
||||
RegionSpecificScan();
|
||||
FindFunctionSignature();
|
||||
CachedPatternScan();
|
||||
FlexibleWildcardPatterns();
|
||||
SignatureBasedScanning();
|
||||
PatternValidation();
|
||||
|
||||
Console.WriteLine("\n✓ All pattern scanning examples completed!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Execution;
|
||||
namespace WhiteMagic.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Example 3: Execution Models
|
||||
/// Demonstrates the three execution strategies: RemoteThreadExecutor, MainThreadPump, and InProcessInvoker
|
||||
/// </summary>
|
||||
public class ExecutionModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Target process for examples
|
||||
/// </summary>
|
||||
private static Process? TargetProcess()
|
||||
{
|
||||
var processes = Process.GetProcessesByName("notepad");
|
||||
if (processes.Length > 0)
|
||||
return processes[0];
|
||||
|
||||
Console.WriteLine("No Notepad process found. Please launch Notepad first.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 3.1: RemoteThreadExecutor (CreateRemoteThread)
|
||||
/// Use for: Thread-agnostic payloads (WinAPI calls, DLL injection, self-contained code)
|
||||
/// </summary>
|
||||
public static void RemoteThreadExecutorExample()
|
||||
{
|
||||
Console.WriteLine("=== Example 3.1: RemoteThreadExecutor (CreateRemoteThread) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Example: Call GetTickCount (thread-safe WinAPI function)
|
||||
var getTickCount = magic["kernel32.dll"]["GetTickCount"];
|
||||
if (getTickCount.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"Calling GetTickCount via RemoteThreadExecutor...");
|
||||
uint ticks = getTickCount.Execute<uint>(CallConvention.Stdcall);
|
||||
Console.WriteLine($"✓ Result: {ticks} ticks ({TimeSpan.FromMilliseconds(ticks):hh\\:mm\\:ss})");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ GetTickCount not found in kernel32.dll");
|
||||
}
|
||||
|
||||
// Example: Call GetCurrentProcessId
|
||||
var getCurrentProcessId = magic["kernel32.dll"]["GetCurrentProcessId"];
|
||||
if (getCurrentProcessId.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"Calling GetCurrentProcessId...");
|
||||
uint processId = getCurrentProcessId.Execute<uint>(CallConvention.Stdcall);
|
||||
Console.WriteLine($"✓ Result: Process ID = {processId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ GetCurrentProcessId not found in kernel32.dll");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Execution failed: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n⚠️ NOTE: RemoteThreadExecutor is ONLY for thread-agnostic functions!");
|
||||
Console.WriteLine(" Do NOT use for game state, scripting engines, or render operations.");
|
||||
Console.WriteLine(" Use MainThreadPump for state-sensitive calls instead.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 3.2: MainThreadPump (Crash-Safe Execution)
|
||||
/// Use for: State-sensitive calls (game state, scripting, UI, render operations)
|
||||
/// </summary>
|
||||
public static async Task MainThreadPumpExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 3.2: MainThreadPump (Crash-Safe Execution) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Find a per-frame function (e.g., D3D EndScene, or any function called every frame)
|
||||
// For this example, we'll use a hypothetical frame function address
|
||||
// In real use, you'd find the actual frame function via pattern scanning
|
||||
IntPtr frameFunctionAddress = FindFrameFunction(magic);
|
||||
|
||||
if (frameFunctionAddress == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine("✗ Could not find frame function (expected in this demo environment)");
|
||||
Console.WriteLine(" In a real game, you'd use pattern scanning to find the frame function.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Found frame function at: 0x{frameFunctionAddress:X}");
|
||||
|
||||
// Create MainThreadPump
|
||||
Console.WriteLine("Creating MainThreadPump...");
|
||||
var pump = magic.CreateMainThreadPump(frameFunctionAddress);
|
||||
|
||||
// Example 1: Safe memory read from game state
|
||||
Console.WriteLine("Enqueueing safe memory read...");
|
||||
try
|
||||
{
|
||||
int value = await pump.ExecuteAsync(() =>
|
||||
{
|
||||
// Safe to read game state here (running on target's main thread)
|
||||
return magic.Memory.Read<int>(magic.Memory.ImageBase + 0x1000);
|
||||
});
|
||||
|
||||
Console.WriteLine($"✓ Safe read result: {value}");
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Console.WriteLine("✗ Pump operation timed out (work item wedged or target not running)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Pump operation failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Example 2: Safe memory write to game state
|
||||
Console.WriteLine("Enqueueing safe memory write...");
|
||||
try
|
||||
{
|
||||
await pump.ExecuteAsync(() =>
|
||||
{
|
||||
// Safe to write game state here
|
||||
magic.Memory.Write(magic.Memory.ImageBase + 0x2000, 42);
|
||||
return true;
|
||||
});
|
||||
Console.WriteLine("✓ Safe write completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Safe write failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Example 3: Complex state manipulation
|
||||
Console.WriteLine("Enqueueing complex state manipulation...");
|
||||
try
|
||||
{
|
||||
string result = await pump.ExecuteAsync(() =>
|
||||
{
|
||||
// Safe to perform complex multi-step operations
|
||||
IntPtr basePtr = magic.Memory.Read<IntPtr>(magic.Memory.ImageBase + 0x3000);
|
||||
if (basePtr != IntPtr.Zero)
|
||||
{
|
||||
int health = magic.Memory.Read<int>(basePtr + 0x10);
|
||||
int maxHealth = magic.Memory.Read<int>(basePtr + 0x14);
|
||||
return $"Health: {health}/{maxHealth}";
|
||||
}
|
||||
return "Unknown";
|
||||
});
|
||||
Console.WriteLine($"✓ Complex operation result: {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Complex operation failed: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n✓ MainThreadPump examples completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ MainThreadPump setup failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper: Find a per-frame function
|
||||
/// In real use, you'd use pattern scanning to find D3D EndScene or similar
|
||||
/// </summary>
|
||||
private static IntPtr FindFrameFunction(Magic magic)
|
||||
{
|
||||
// For demo purposes, return zero (not found)
|
||||
// In real use, you'd scan for patterns like:
|
||||
// - D3D9 EndScene: device + 0x44 vtable entry
|
||||
// - D3D11 Present callbacks
|
||||
// - Game-specific Update/Render functions
|
||||
|
||||
// Example pattern scan (commented out for demo):
|
||||
// var module = magic["d3d9.dll"];
|
||||
// if (module != null)
|
||||
// {
|
||||
// IntPtr endScene = magic.Memory.FindPattern("?? ?? ?? ??", module.BaseAddress, module.ModuleMemorySize);
|
||||
// return endScene;
|
||||
// }
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 3.3: InProcessInvoker (Direct Delegates)
|
||||
/// Use for: In-process calls after DLL injection (max performance)
|
||||
/// </summary>
|
||||
public static void InProcessInvokerExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 3.3: InProcessInvoker (Direct Delegates) ===");
|
||||
|
||||
Console.WriteLine("⚠️ NOTE: This example only works when injected in-process!");
|
||||
Console.WriteLine(" For demo purposes, we'll show the syntax but it won't execute.");
|
||||
|
||||
// Example syntax (would work if injected):
|
||||
Console.WriteLine("\nExample code (requires in-process injection):");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("// Only works when injected in-process");
|
||||
Console.WriteLine("using var magic = Magic.OpenInProcess();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Define delegate signature");
|
||||
Console.WriteLine("[UnmanagedFunctionPointer(CallingConvention.Cdecl)]");
|
||||
Console.WriteLine("public delegate int AddNumbersDelegate(int a, int b);");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Create delegate from function address");
|
||||
Console.WriteLine("var addNumbers = inProcess.CreateFunction<AddNumbersDelegate>(functionAddress);");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Call directly as a delegate (max performance, <1μs)");
|
||||
Console.WriteLine("int result = addNumbers(10, 20);");
|
||||
Console.WriteLine("Console.WriteLine($\"Result: {result}\");");
|
||||
Console.WriteLine("```");
|
||||
|
||||
Console.WriteLine("\n✓ InProcessInvoker is the fastest but requires injection.");
|
||||
Console.WriteLine(" Use RemoteThreadExecutor for initial injection, then switch to InProcessInvoker.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 3.4: Choosing the Right Execution Model
|
||||
/// </summary>
|
||||
public static void ChooseExecutionModel()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 3.4: Choosing the Right Execution Model ===");
|
||||
|
||||
Console.WriteLine("Decision Flowchart:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Are you injected in-process?");
|
||||
Console.WriteLine("├─ Yes → Use InProcessInvoker (direct delegates, <1μs)");
|
||||
Console.WriteLine("└─ No → Does the call touch single-threaded state?");
|
||||
Console.WriteLine(" ├─ Yes → Use MainThreadPump (crash-safe, ~1 frame latency)");
|
||||
Console.WriteLine(" └─ No → Use RemoteThreadExecutor (CreateRemoteThread, ~1-2ms)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Examples by Use Case:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("1. DLL Injection: RemoteThreadExecutor");
|
||||
Console.WriteLine(" → LoadLibrary is thread-safe");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("2. Read Health Bar: MainThreadPump");
|
||||
Console.WriteLine(" → Game state is main-thread-affine");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("3. Call GetTickCount: RemoteThreadExecutor");
|
||||
Console.WriteLine(" → WinAPI, no thread affinity");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("4. Script Engine Call: MainThreadPump");
|
||||
Console.WriteLine(" → Script VM is main-thread-only");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("5. Injected Profiling: InProcessInvoker");
|
||||
Console.WriteLine(" → Already in-process, max performance");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("6. Window Mutation: RemoteThreadExecutor");
|
||||
Console.WriteLine(" → WinAPI SetWindowPos is thread-safe");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Performance Comparison:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("┌─────────────────────┬──────────────┬──────────┬──────────────┐");
|
||||
Console.WriteLine("│ Model │ Latency │ Safety │ Use Case │");
|
||||
Console.WriteLine("├─────────────────────┼──────────────┼──────────┼──────────────┤");
|
||||
Console.WriteLine("│ RemoteThreadExec │ ~1-2ms │ Thread- │ One-shot, │");
|
||||
Console.WriteLine("│ │ │ agnostic │ DLL inject │");
|
||||
Console.WriteLine("├─────────────────────┼──────────────┼──────────┼──────────────┤");
|
||||
Console.WriteLine("│ MainThreadPump │ ~1 frame │ Crash- │ State- │");
|
||||
Console.WriteLine("│ │ (16-33ms) │ safe │ sensitive │");
|
||||
Console.WriteLine("├─────────────────────┼──────────────┼──────────┼──────────────┤");
|
||||
Console.WriteLine("│ InProcessInvoker │ <1μs │ In- │ In-process │");
|
||||
Console.WriteLine("│ │ │ process │ tools │");
|
||||
Console.WriteLine("└─────────────────────┴──────────────┴──────────┴──────────────┘");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 3.5: Combining Execution Models
|
||||
/// </summary>
|
||||
public static void CombinedExecutionModels()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 3.5: Combining Execution Models ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
Console.WriteLine("Strategy: Inject DLL, then switch to InProcessInvoker");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Step 1: Use RemoteThreadExecutor to inject DLL");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("var loadLibrary = magic[\"kernel32.dll\"][\"LoadLibraryA\"];");
|
||||
Console.WriteLine("IntPtr dllHandle = loadLibrary.Execute<IntPtr>(CallConvention.Stdcall, dllPathPtr);");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Step 2: Injected DLL initializes, now in-process");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("// Inside injected DLL");
|
||||
Console.WriteLine("using var inProcess = Magic.OpenInProcess();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Use InProcessInvoker for max performance");
|
||||
Console.WriteLine("var fn = inProcess.CreateFunction<MyDelegate>(address);");
|
||||
Console.WriteLine("int result = fn(arg1, arg2); // <1μs latency");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ DLL injection → Fast in-process calls");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 3.6: Error Handling in Execution Models
|
||||
/// </summary>
|
||||
public static void ExecutionModelErrorHandling()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 3.6: Error Handling ===");
|
||||
|
||||
Console.WriteLine("RemoteThreadExecutor:");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("try");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" uint result = fn.Execute<uint>(CallConvention.Stdcall, args...);");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("catch (Win32Exception ex)");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" // CreateRemoteThread failed (access denied, process exited, etc.)");
|
||||
Console.WriteLine(" Console.WriteLine($\"Execution failed: {ex.Message}\");");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("MainThreadPump:");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("try");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" int result = await pump.ExecuteAsync(() => magic.Memory.Read<int>(addr));");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("catch (TimeoutException)");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" // Work item wedged (stuck the frame) or target not running");
|
||||
Console.WriteLine(" Console.WriteLine(\"Operation timed out\");");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("catch (Exception ex)");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" // Other exception from work item");
|
||||
Console.WriteLine(" Console.WriteLine($\"Operation failed: {ex.Message}\");");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("InProcessInvoker:");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("try");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" int result = delegate(arg1, arg2);");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("catch (AccessViolationException)");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" // Invalid function address or bad call convention");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("```");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all execution model examples
|
||||
/// </summary>
|
||||
public static async Task RunAll()
|
||||
{
|
||||
Console.WriteLine("╔════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ WhiteMagic Example 3: Execution Models ║");
|
||||
Console.WriteLine("╚════════════════════════════════════════════════════════════╝");
|
||||
|
||||
RemoteThreadExecutorExample();
|
||||
await MainThreadPumpExample();
|
||||
InProcessInvokerExample();
|
||||
ChooseExecutionModel();
|
||||
CombinedExecutionModels();
|
||||
ExecutionModelErrorHandling();
|
||||
|
||||
Console.WriteLine("\n✓ All execution model examples completed!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("⚠️ CRITICAL REMINDER:");
|
||||
Console.WriteLine(" - RemoteThreadExecutor: ONLY for thread-agnostic functions");
|
||||
Console.WriteLine(" - MainThreadPump: For state-sensitive calls (crash-safe)");
|
||||
Console.WriteLine(" - InProcessInvoker: Only after DLL injection");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Using the wrong model crashes the target application!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Hooking;
|
||||
|
||||
namespace WhiteMagic.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Example 4: Function Hooking
|
||||
/// Demonstrates inline detours and byte patches
|
||||
/// </summary>
|
||||
public class FunctionHooking
|
||||
{
|
||||
/// <summary>
|
||||
/// Target process for examples
|
||||
/// </summary>
|
||||
private static Process? TargetProcess()
|
||||
{
|
||||
var processes = Process.GetProcessesByName("notepad");
|
||||
if (processes.Length > 0)
|
||||
return processes[0];
|
||||
|
||||
Console.WriteLine("No Notepad process found. Please launch Notepad first.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.1: Simple Inline Detour
|
||||
/// Note: Detours only work in-process (requires injection)
|
||||
/// </summary>
|
||||
public static void SimpleInlineDetour()
|
||||
{
|
||||
Console.WriteLine("=== Example 4.1: Simple Inline Detour ===");
|
||||
|
||||
Console.WriteLine("⚠️ NOTE: Detours only work when injected in-process!");
|
||||
Console.WriteLine(" For demo purposes, we'll show the syntax but it won't execute.");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Example code (requires in-process injection):");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("using var magic = Magic.OpenInProcess();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Define your hook delegate");
|
||||
Console.WriteLine("[UnmanagedFunctionPointer(CallingConvention.Stdcall)]");
|
||||
Console.WriteLine("public delegate uint GetTickCountDelegate();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Original function pointer (for calling original)");
|
||||
Console.WriteLine("static GetTickCountDelegate OriginalGetTickCount = null!;");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Your hook implementation");
|
||||
Console.WriteLine("static uint MyGetTickCount()");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" Console.WriteLine(\"GetTickCount called!\");");
|
||||
Console.WriteLine(" return OriginalGetTickCount(); // Call original");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Apply the detour");
|
||||
Console.WriteLine("var getTickCount = magic[\"kernel32.dll\"][\"GetTickCount\"];");
|
||||
Console.WriteLine("var detour = magic.DetourManager.Create(");
|
||||
Console.WriteLine(" \"my_gettickcount\",");
|
||||
Console.WriteLine(" getTickCount.Address,");
|
||||
Console.WriteLine(" (GetTickCountDelegate)MyGetTickCount");
|
||||
Console.WriteLine(");");
|
||||
Console.WriteLine("OriginalGetTickCount = original;");
|
||||
Console.WriteLine("detour.Apply();");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Detour applied: Every GetTickCount call now goes through MyGetTickCount");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.2: Detour with Parameter Modification
|
||||
/// </summary>
|
||||
public static void DetourWithParameterModification()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.2: Detour with Parameter Modification ===");
|
||||
|
||||
Console.WriteLine("Example: Hook MessageBoxW to change the caption");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("// Original function signature");
|
||||
Console.WriteLine("[UnmanagedFunctionPointer(CallingConvention.Stdcall)]");
|
||||
Console.WriteLine("public delegate int MessageBoxDelegate(");
|
||||
Console.WriteLine(" IntPtr hWnd,");
|
||||
Console.WriteLine(" IntPtr lpText,");
|
||||
Console.WriteLine(" IntPtr lpCaption,");
|
||||
Console.WriteLine(" uint type");
|
||||
Console.WriteLine(");");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("static MessageBoxDelegate OriginalMessageBox = null!;");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("static int MyMessageBox(");
|
||||
Console.WriteLine(" IntPtr hWnd,");
|
||||
Console.WriteLine(" IntPtr lpText,");
|
||||
Console.WriteLine(" IntPtr lpCaption,");
|
||||
Console.WriteLine(" uint type)");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" // Read original strings");
|
||||
Console.WriteLine(" string text = Marshal.PtrToStringUni(lpText);");
|
||||
Console.WriteLine(" string caption = Marshal.PtrToStringUni(lpCaption);");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Console.WriteLine($\"MessageBox: {caption} - {text}\");");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" // Modify the caption");
|
||||
Console.WriteLine(" string newCaption = \"[Hooked] \" + caption;");
|
||||
Console.WriteLine(" IntPtr newCaptionPtr = Marshal.StringToHGlobalUni(newCaption);");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" // Call original with modified caption");
|
||||
Console.WriteLine(" int result = OriginalMessageBox(hWnd, lpText, newCaptionPtr, type);");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Marshal.FreeHGlobal(newCaptionPtr);");
|
||||
Console.WriteLine(" return result;");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Apply hook");
|
||||
Console.WriteLine("var messageBox = magic[\"user32.dll\"][\"MessageBoxW\"];");
|
||||
Console.WriteLine("var detour = magic.DetourManager.Create(");
|
||||
Console.WriteLine(" \"my_messagebox\",");
|
||||
Console.WriteLine(" messageBox.Address,");
|
||||
Console.WriteLine(" (MessageBoxDelegate)MyMessageBox");
|
||||
Console.WriteLine(");");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("OriginalMessageBox = original;");
|
||||
Console.WriteLine("detour.Apply();");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Every MessageBoxW call now has \"[Hooked]\" prefix in caption");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.3: Detour with Return Value Modification
|
||||
/// </summary>
|
||||
public static void DetourWithReturnValueModification()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.3: Detour with Return Value Modification ===");
|
||||
|
||||
Console.WriteLine("Example: Always return success from a function");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("[UnmanagedFunctionPointer(CallingConvention.Stdcall)]");
|
||||
Console.WriteLine("public delegate bool CheckLicenseDelegate();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("static bool MyCheckLicense()");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" Console.WriteLine(\"License check bypassed!\");");
|
||||
Console.WriteLine(" return true; // Always return true (bypass check)");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Apply hook");
|
||||
Console.WriteLine("var checkLicense = magic[\"target.dll\"][\"CheckLicense\"];");
|
||||
Console.WriteLine("var detour = magic.DetourManager.Create(");
|
||||
Console.WriteLine(" \"my_checklicense\",");
|
||||
Console.WriteLine(" checkLicense.Address,");
|
||||
Console.WriteLine(" (CheckLicenseDelegate)MyCheckLicense");
|
||||
Console.WriteLine(");");
|
||||
Console.WriteLine("detour.Apply();");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ License check always returns true (bypassed)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.4: Multiple Detours (Chain Hooking)
|
||||
/// </summary>
|
||||
public static void MultipleDetours()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.4: Multiple Detours (Chain Hooking) ===");
|
||||
|
||||
Console.WriteLine("Example: Install multiple hooks on the same function");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("// First hook");
|
||||
Console.WriteLine("var detour1 = magic.DetourManager.Create(\"hook1\", fnAddr, Hook1);");
|
||||
Console.WriteLine("detour1.Apply();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Second hook (hooks the trampoline from first)");
|
||||
Console.WriteLine("var detour2 = magic.DetourManager.Create(");
|
||||
Console.WriteLine(" \"hook2\",");
|
||||
Console.WriteLine(" detour1.Trampoline,");
|
||||
Console.WriteLine(" Hook2");
|
||||
Console.WriteLine(");");
|
||||
Console.WriteLine("detour2.Apply();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Execution flow:");
|
||||
Console.WriteLine("// Original function → Hook2 → Hook1 → Trampoline1 → Original+5");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Chain hooking: Hooks execute in reverse order of installation");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.5: Byte Patching
|
||||
/// </summary>
|
||||
public static void BytePatching()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.5: Byte Patching ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Example: Patch a constant value
|
||||
IntPtr patchAddress = magic.Memory.ImageBase + 0x5000;
|
||||
byte[] originalBytes = magic.Memory.ReadBytes(patchAddress, 4);
|
||||
byte[] patchBytes = { 0x00, 0x00, 0x00, 0x00 }; // Patch to 0
|
||||
|
||||
Console.WriteLine($"Creating patch at 0x{patchAddress:X}");
|
||||
Console.WriteLine($"Original bytes: {BitConverter.ToString(originalBytes)}");
|
||||
Console.WriteLine($"Patch bytes: {BitConverter.ToString(patchBytes)}");
|
||||
|
||||
var patch = magic.PatchManager.Create("MaxHealthPatch", patchAddress, patchBytes);
|
||||
patch.Apply();
|
||||
|
||||
Console.WriteLine($"✓ Patch applied");
|
||||
|
||||
// Verify patch
|
||||
byte[] currentBytes = magic.Memory.ReadBytes(patchAddress, 4);
|
||||
Console.WriteLine($"Current bytes: {BitConverter.ToString(currentBytes)}");
|
||||
|
||||
// Remove patch
|
||||
patch.Remove();
|
||||
Console.WriteLine($"✓ Patch removed (original bytes restored)");
|
||||
|
||||
byte[] restoredBytes = magic.Memory.ReadBytes(patchAddress, 4);
|
||||
Console.WriteLine($"Restored bytes: {BitConverter.ToString(restoredBytes)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Patch example failed (address may be invalid): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.6: NOP Patching (Removing Instructions)
|
||||
/// </summary>
|
||||
public static void NopPatching()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.6: NOP Patching (Removing Instructions) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Example: NOP out a conditional jump (5 bytes on x86)
|
||||
IntPtr patchAddress = magic.Memory.ImageBase + 0x6000;
|
||||
byte[] originalBytes = magic.Memory.ReadBytes(patchAddress, 5);
|
||||
byte[] nopPatch = { 0x90, 0x90, 0x90, 0x90, 0x90 }; // NOP x5
|
||||
|
||||
Console.WriteLine($"Creating NOP patch at 0x{patchAddress:X}");
|
||||
Console.WriteLine($"Original bytes: {BitConverter.ToString(originalBytes)}");
|
||||
Console.WriteLine($"NOP patch: {BitConverter.ToString(nopPatch)}");
|
||||
|
||||
var patch = magic.PatchManager.Create("ConditionalJumpPatch", patchAddress, nopPatch);
|
||||
patch.Apply();
|
||||
|
||||
Console.WriteLine($"✓ NOP patch applied (conditional jump removed)");
|
||||
|
||||
// Restore
|
||||
patch.Remove();
|
||||
Console.WriteLine($"✓ Patch removed (original jump restored)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ NOP patch example failed (address may be invalid): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.7: Conditional Patching
|
||||
/// </summary>
|
||||
public static void ConditionalPatching()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.7: Conditional Patching ===");
|
||||
|
||||
Console.WriteLine("Example: Toggle patch on/off");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("public class ToggleablePatch");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" private readonly Patch _patch;");
|
||||
Console.WriteLine(" private bool _enabled;");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" public bool Enabled");
|
||||
Console.WriteLine(" {");
|
||||
Console.WriteLine(" get => _enabled;");
|
||||
Console.WriteLine(" set");
|
||||
Console.WriteLine(" {");
|
||||
Console.WriteLine(" if (_enabled == value) return;");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" if (value)");
|
||||
Console.WriteLine(" _patch.Apply();");
|
||||
Console.WriteLine(" else");
|
||||
Console.WriteLine(" _patch.Remove();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" _enabled = value;");
|
||||
Console.WriteLine(" }");
|
||||
Console.WriteLine(" }");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("// Usage");
|
||||
Console.WriteLine("var patch = magic.PatchManager.Create(\"Patch\", addr, bytes);");
|
||||
Console.WriteLine("var toggleable = new ToggleablePatch(patch);");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("toggleable.Enabled = true; // Apply patch");
|
||||
Console.WriteLine("toggleable.Enabled = false; // Remove patch");
|
||||
Console.WriteLine("```");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.8: Detour Safety and Prologue Validation
|
||||
/// </summary>
|
||||
public static void DetourSafety()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.8: Detour Safety and Prologue Validation ===");
|
||||
|
||||
Console.WriteLine("Prologue Validation:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine("Before splicing a detour, WhiteMagic validates the prologue:");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Common x86/x64 prologues:");
|
||||
Console.WriteLine(" - push ebp; mov ebp, esp");
|
||||
Console.WriteLine(" - mov edi, edi (hot-patch padding)");
|
||||
Console.WriteLine(" - sub rsp, XX (x64 stack allocation)");
|
||||
Console.WriteLine(" - Single-byte instructions (nop, int3)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Optional Iced backend for full disassembly validation");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Error: \"Prologue too short\"");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine("Cause: Function prologue is shorter than minimum (5 bytes for jmp)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Solutions:");
|
||||
Console.WriteLine(" 1. Hook a different function");
|
||||
Console.WriteLine(" 2. Patch deeper into the function (after prologue)");
|
||||
Console.WriteLine(" 3. For WinAPI, use hot-patch area (2-byte jmp at [address-2])");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Thread Safety:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine("⚠️ DetourManager is NOT thread-safe!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("WRONG:");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("Task.Run(() => detour1.Apply());");
|
||||
Console.WriteLine("Task.Run(() => detour2.Apply()); // Race condition!");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("RIGHT:");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("lock (magic.DetourManager)");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" detour1.Apply();");
|
||||
Console.WriteLine(" detour2.Apply();");
|
||||
Console.WriteLine("}");
|
||||
Console.WriteLine("```");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 4.9: Automatic Restoration on Disposal
|
||||
/// </summary>
|
||||
public static void AutomaticRestoration()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 4.9: Automatic Restoration on Disposal ===");
|
||||
|
||||
Console.WriteLine("All hooks and patches auto-restore on disposal:");
|
||||
Console.WriteLine("```csharp");
|
||||
Console.WriteLine("using (var magic = Magic.OpenInProcess())");
|
||||
Console.WriteLine("{");
|
||||
Console.WriteLine(" var detour = magic.DetourManager.Detour(addr, hook);");
|
||||
Console.WriteLine(" detour.Apply();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" var patch = magic.PatchManager.Create(\"Patch\", addr, bytes);");
|
||||
Console.WriteLine(" patch.Apply();");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" // ... use hooked functions");
|
||||
Console.WriteLine("} // End of using: detour.Remove() and patch.Remove() called automatically");
|
||||
Console.WriteLine("```");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Original bytes automatically restored on disposal");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all function hooking examples
|
||||
/// </summary>
|
||||
public static void RunAll()
|
||||
{
|
||||
Console.WriteLine("╔════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ WhiteMagic Example 4: Function Hooking ║");
|
||||
Console.WriteLine("╚════════════════════════════════════════════════════════════╝");
|
||||
|
||||
SimpleInlineDetour();
|
||||
DetourWithParameterModification();
|
||||
DetourWithReturnValueModification();
|
||||
MultipleDetours();
|
||||
BytePatching();
|
||||
NopPatching();
|
||||
ConditionalPatching();
|
||||
DetourSafety();
|
||||
AutomaticRestoration();
|
||||
|
||||
Console.WriteLine("\n✓ All function hooking examples completed!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("⚠️ CRITICAL REMINDERS:");
|
||||
Console.WriteLine(" - Detours ONLY work in-process (requires injection)");
|
||||
Console.WriteLine(" - Patches work externally (no injection required)");
|
||||
Console.WriteLine(" - DetourManager/PatchManager are NOT thread-safe");
|
||||
Console.WriteLine(" - All hooks/patches auto-restore on disposal");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Linq;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Windows;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Discovery;
|
||||
|
||||
namespace WhiteMagic.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Example 5: High-Level API
|
||||
/// Demonstrates RemotePointer, RemoteModule, RemoteFunction, and other high-level APIs
|
||||
/// </summary>
|
||||
public class HighLevelAPI
|
||||
{
|
||||
/// <summary>
|
||||
/// Target process for examples
|
||||
/// </summary>
|
||||
private static Process? TargetProcess()
|
||||
{
|
||||
var processes = Process.GetProcessesByName("notepad");
|
||||
if (processes.Length > 0)
|
||||
return processes[0];
|
||||
|
||||
Console.WriteLine("No Notepad process found. Please launch Notepad first.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.1: RemotePointer (Fluent Pointer Arithmetic)
|
||||
/// </summary>
|
||||
public static void RemotePointerExample()
|
||||
{
|
||||
Console.WriteLine("=== Example 5.1: RemotePointer (Fluent Pointer Arithmetic) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
IntPtr baseAddress = magic.Memory.ImageBase;
|
||||
|
||||
// Create a RemotePointer to base address
|
||||
var ptr = magic[baseAddress];
|
||||
Console.WriteLine($"Created RemotePointer to: 0x{baseAddress:X}");
|
||||
|
||||
try
|
||||
{
|
||||
// Read different offsets from the same base
|
||||
int offset1 = ptr.Read<int>(0x1000);
|
||||
Console.WriteLine($"Read int at offset 0x1000: {offset1}");
|
||||
|
||||
float offset2 = ptr.Read<float>(0x2000);
|
||||
Console.WriteLine($"Read float at offset 0x2000: {offset2}");
|
||||
|
||||
// Write to offset
|
||||
bool writeSuccess = ptr.Write(42, 0x3000);
|
||||
Console.WriteLine($"Write int to offset 0x3000: {(writeSuccess ? "Success" : "Failed")}");
|
||||
|
||||
// Chained pointer reads (pointer → pointer → value)
|
||||
IntPtr ptr1 = ptr.Read<IntPtr>(0x4000);
|
||||
if (ptr1 != IntPtr.Zero)
|
||||
{
|
||||
var ptr2 = magic[ptr1];
|
||||
int nestedValue = ptr2.Read<int>(0x50);
|
||||
Console.WriteLine($"Nested pointer read: 0x{baseAddress:X} → 0x{ptr1:X} → {nestedValue}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Operation failed (offset may be invalid): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.2: RemoteModule (Module Enumeration)
|
||||
/// </summary>
|
||||
public static void RemoteModuleExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.2: RemoteModule (Module Enumeration) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// List all loaded modules using Process.Modules
|
||||
Console.WriteLine("Loaded modules:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine(string.Format("{0,-25} {1,-15} {2,-15}", "Name", "Base Address", "Size"));
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
|
||||
foreach (System.Diagnostics.ProcessModule module in process.Modules)
|
||||
{
|
||||
Console.WriteLine(string.Format("{0,-25} 0x{1:X} 0x{2:X}",
|
||||
module.ModuleName, module.BaseAddress, module.ModuleMemorySize));
|
||||
}
|
||||
|
||||
// Access specific module using WhiteMagic's RemoteModule
|
||||
var kernel32 = magic["kernel32.dll"];
|
||||
if (kernel32 != null && kernel32.BaseAddress != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"✓ Found {kernel32.Name} at 0x{kernel32.BaseAddress:X}");
|
||||
|
||||
// Resolve specific exports by name
|
||||
Console.WriteLine($"\nResolved exports from {kernel32.Name}:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
|
||||
var getTickCount = kernel32["GetTickCount"];
|
||||
if (getTickCount.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ GetTickCount at 0x{getTickCount.Address:X}");
|
||||
}
|
||||
|
||||
var getCurrentProcessId = kernel32["GetCurrentProcessId"];
|
||||
if (getCurrentProcessId.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ GetCurrentProcessId at 0x{getCurrentProcessId.Address:X}");
|
||||
}
|
||||
|
||||
var messageBoxA = kernel32["MessageBoxA"];
|
||||
if (messageBoxA.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ MessageBoxA at 0x{messageBoxA.Address:X}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ kernel32.dll not found");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.3: RemoteFunction (Function Resolution and Execution)
|
||||
/// </summary>
|
||||
public static void RemoteFunctionExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.3: RemoteFunction (Function Resolution) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve function by name
|
||||
var getTickCount = magic["kernel32.dll"]["GetTickCount"];
|
||||
if (getTickCount.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ Resolved GetTickCount at: 0x{getTickCount.Address:X}");
|
||||
|
||||
// Execute the function
|
||||
uint ticks = getTickCount.Execute<uint>(CallConvention.Stdcall);
|
||||
Console.WriteLine($" Result: {ticks} ticks ({TimeSpan.FromMilliseconds(ticks):hh\\:mm\\:ss})");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ GetTickCount not found");
|
||||
}
|
||||
|
||||
// Resolve another function
|
||||
var getCurrentProcessId = magic["kernel32.dll"]["GetCurrentProcessId"];
|
||||
if (getCurrentProcessId.Address != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ Resolved GetCurrentProcessId at: 0x{getCurrentProcessId.Address:X}");
|
||||
|
||||
uint processId = getCurrentProcessId.Execute<uint>(CallConvention.Stdcall);
|
||||
Console.WriteLine($" Result: Process ID = {processId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ GetCurrentProcessId not found");
|
||||
}
|
||||
|
||||
// Try to resolve non-existent function
|
||||
var nonExistent = magic["kernel32.dll"]["NonExistentFunction123"];
|
||||
if (nonExistent.Address == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ NonExistentFunction123 correctly not found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Function resolution/execution failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.4: ProcessInfo and Module Details
|
||||
/// Demonstrates accessing process and module metadata through WhiteMagic
|
||||
/// </summary>
|
||||
public static void ProcessInfoExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.4: ProcessInfo and Module Details ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Access process metadata
|
||||
Console.WriteLine("Process Information:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine($"Process ID: {process.Id}");
|
||||
Console.WriteLine($"Process Name: {process.ProcessName}");
|
||||
Console.WriteLine($"Main Window Title: {process.MainWindowTitle}");
|
||||
if (process.MainModule != null)
|
||||
{
|
||||
Console.WriteLine($"Main Module Base: 0x{process.MainModule.BaseAddress:X}");
|
||||
Console.WriteLine($"Main Module Size: 0x{process.MainModule.ModuleMemorySize:X} bytes");
|
||||
Console.WriteLine($"Main Module Path: {process.MainModule.FileName}");
|
||||
}
|
||||
|
||||
// Enumerate loaded modules
|
||||
Console.WriteLine("\nLoaded Modules:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
int moduleCount = 0;
|
||||
foreach (System.Diagnostics.ProcessModule module in process.Modules)
|
||||
{
|
||||
if (moduleCount < 5) // Show first 5 modules
|
||||
{
|
||||
Console.WriteLine($" {module.ModuleName,-30} Base: 0x{module.BaseAddress:X16} Size: 0x{module.ModuleMemorySize:X}");
|
||||
moduleCount++;
|
||||
}
|
||||
}
|
||||
if (process.Modules.Count > 5)
|
||||
{
|
||||
Console.WriteLine($" ... and {process.Modules.Count - 5} more modules");
|
||||
}
|
||||
|
||||
// Memory information
|
||||
Console.WriteLine("\nMemory Information:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine($"Image Base: 0x{magic.Memory.ImageBase:X}");
|
||||
Console.WriteLine($"Handle: 0x{magic.Memory.Handle.DangerousGetHandle():X}");
|
||||
Console.WriteLine($"Bitness: {(magic.Memory.Is64Bit ? "64" : "32")} bits");
|
||||
|
||||
Console.WriteLine("✓ Process information retrieved successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Process info access failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.5: RemoteWindow (Window Manipulation)
|
||||
/// </summary>
|
||||
public static void RemoteWindowExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.5: RemoteWindow (Window Manipulation) ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Get main window handle from Process and create RemoteWindow
|
||||
if (process.MainWindowHandle != IntPtr.Zero)
|
||||
{
|
||||
var mainWindow = new RemoteWindow(process.MainWindowHandle);
|
||||
|
||||
Console.WriteLine($"Main Window: {mainWindow.Title}");
|
||||
Console.WriteLine($"Handle: 0x{mainWindow.Handle:X}");
|
||||
Console.WriteLine($"Class: {mainWindow.ClassName}");
|
||||
|
||||
// Get window rect through WinAPI calls (simplified - would need P/Invoke for full implementation)
|
||||
Console.WriteLine($"Note: Full window rect information requires additional WinAPI P/Invoke declarations");
|
||||
|
||||
// Modify window properties
|
||||
Console.WriteLine("\nModifying window properties...");
|
||||
|
||||
// Flash window
|
||||
mainWindow.Flash();
|
||||
Console.WriteLine("✓ Window flashed");
|
||||
|
||||
// Activate window
|
||||
mainWindow.Activate();
|
||||
Console.WriteLine("✓ Window activated");
|
||||
|
||||
// Modify title (temporary)
|
||||
string originalTitle = mainWindow.Title;
|
||||
mainWindow.Title = "WhiteMagic Demo!";
|
||||
Console.WriteLine($"✓ Window title changed to: {mainWindow.Title}");
|
||||
|
||||
// Restore original title
|
||||
System.Threading.Thread.Sleep(1000);
|
||||
mainWindow.Title = originalTitle;
|
||||
Console.WriteLine($"✓ Window title restored to: {mainWindow.Title}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("✗ No main window found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ Window manipulation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.6: Module Resolution by Name
|
||||
/// </summary>
|
||||
public static void ModuleResolutionExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.6: Module Resolution ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Common modules to check
|
||||
string[] moduleNames = { "kernel32.dll", "user32.dll", "ntdll.dll", "notepad.exe" };
|
||||
|
||||
Console.WriteLine("Module resolution:");
|
||||
Console.WriteLine("─────────────────────────────────────────────────────────────────");
|
||||
|
||||
foreach (var moduleName in moduleNames)
|
||||
{
|
||||
var module = magic[moduleName];
|
||||
if (module != null && module.BaseAddress != IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine($"✓ {moduleName,-20} at 0x{module.BaseAddress:X}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"✗ {moduleName,-20} not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Get main module using Process.MainModule
|
||||
if (process.MainModule != null)
|
||||
{
|
||||
Console.WriteLine($"\n✓ Main module: {process.MainModule.ModuleName} at 0x{process.MainModule.BaseAddress:X}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.7: Pattern Scanning (Basic)
|
||||
/// </summary>
|
||||
public static void PatternScanningExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.7: Pattern Scanning ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Get main module for scanning
|
||||
if (process.MainModule == null)
|
||||
{
|
||||
Console.WriteLine("✗ Main module not available");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Scanning for patterns in {process.MainModule.ModuleName}...");
|
||||
|
||||
// Example patterns (these are common x64 instruction patterns)
|
||||
// Note: In real use, you'd use patterns specific to your target
|
||||
byte[][] patterns =
|
||||
{
|
||||
new byte[] { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 }, // mov rax, [rip+disp]
|
||||
new byte[] { 0xE8, 0x00, 0x00, 0x00, 0x00 }, // call rel32
|
||||
new byte[] { 0xB8, 0x00, 0x00, 0x00, 0x00 } // mov eax, imm32
|
||||
};
|
||||
|
||||
// Masks: 'x' = match exactly, '?' = wildcard
|
||||
string[] masks =
|
||||
{
|
||||
"xxx????", // mov rax, [rip+disp] - last 4 bytes are displacement (wildcard)
|
||||
"x????", // call rel32 - displacement is wildcard
|
||||
"x????" // mov eax, imm32 - immediate is wildcard
|
||||
};
|
||||
|
||||
for (int i = 0; i < patterns.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr result = PatternScanner.FindInModule(
|
||||
magic.Memory,
|
||||
patterns[i],
|
||||
masks[i],
|
||||
process.MainModule
|
||||
);
|
||||
|
||||
Console.WriteLine($" Pattern {i + 1}: {(result != IntPtr.Zero ? $"✓ Found at 0x{result:X}" : "✗ Not found")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" Pattern {i + 1}: ✗ Scan failed - {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.8: Cached Pattern Scanning
|
||||
/// </summary>
|
||||
public static void CachedPatternScanningExample()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.8: Cached Pattern Scanning ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
if (process.MainModule == null)
|
||||
{
|
||||
Console.WriteLine("✗ Main module not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a cache for pattern scanning
|
||||
var cache = new PatternScannerCache(magic.Memory);
|
||||
|
||||
Console.WriteLine("Demonstrating cached pattern scanning...");
|
||||
|
||||
// Pattern to find
|
||||
byte[] pattern = { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 };
|
||||
string mask = "xxx????";
|
||||
|
||||
try
|
||||
{
|
||||
// First scan (reads memory)
|
||||
Console.Write(" First scan: ");
|
||||
IntPtr result1 = cache.FindInModuleCached(pattern, mask, process.MainModule);
|
||||
Console.WriteLine(result1 != IntPtr.Zero ? $"✓ 0x{result1:X}" : "✗ Not found");
|
||||
|
||||
// Second scan (uses cache)
|
||||
Console.Write(" Second scan (cached): ");
|
||||
IntPtr result2 = cache.FindInModuleCached(pattern, mask, process.MainModule);
|
||||
Console.WriteLine(result2 != IntPtr.Zero ? $"✓ 0x{result2:X}" : "✗ Not found");
|
||||
|
||||
Console.WriteLine(" ✓ Results match (cache working)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ✗ Cache scan failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example 5.9: High-Level API Chaining
|
||||
/// </summary>
|
||||
public static void HighLevelAPIChaining()
|
||||
{
|
||||
Console.WriteLine("\n=== Example 5.9: High-Level API Chaining ===");
|
||||
|
||||
var process = TargetProcess();
|
||||
if (process == null) return;
|
||||
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
try
|
||||
{
|
||||
// Chain: Module → Function → Execute
|
||||
var module = magic["kernel32.dll"];
|
||||
if (module != null && module.BaseAddress != IntPtr.Zero)
|
||||
{
|
||||
var function = module["GetTickCount"];
|
||||
if (function.Address != IntPtr.Zero)
|
||||
{
|
||||
uint result = function.Execute<uint>(CallConvention.Stdcall);
|
||||
Console.WriteLine($"✓ Chained call: magic[\"kernel32.dll\"][\"GetTickCount\"].Execute<uint>() = {result}");
|
||||
}
|
||||
}
|
||||
|
||||
// Chain: Pointer → Read → Pointer → Read
|
||||
var basePtr = magic[magic.Memory.ImageBase];
|
||||
try
|
||||
{
|
||||
IntPtr ptr1 = basePtr.Read<IntPtr>(0x1000);
|
||||
if (ptr1 != IntPtr.Zero)
|
||||
{
|
||||
var ptr2 = magic[ptr1];
|
||||
int value = ptr2.Read<int>(0x50);
|
||||
Console.WriteLine($"✓ Pointer chain: base → 0x{ptr1:X} → {value}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine("✗ Pointer chain: Address not accessible (expected for demo)");
|
||||
}
|
||||
|
||||
// Chain: Window → Title → Length
|
||||
if (process.MainWindowHandle != IntPtr.Zero)
|
||||
{
|
||||
var window = new RemoteWindow(process.MainWindowHandle);
|
||||
int titleLength = window.Title.Length;
|
||||
Console.WriteLine($"✓ Window chain: RemoteWindow(MainWindowHandle).Title.Length = {titleLength}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"✗ API chaining failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all high-level API examples
|
||||
/// </summary>
|
||||
public static void RunAll()
|
||||
{
|
||||
Console.WriteLine("╔════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ WhiteMagic Example 5: High-Level API ║");
|
||||
Console.WriteLine("╚════════════════════════════════════════════════════════════╝");
|
||||
|
||||
RemotePointerExample();
|
||||
RemoteModuleExample();
|
||||
RemoteFunctionExample();
|
||||
ProcessInfoExample();
|
||||
RemoteWindowExample();
|
||||
ModuleResolutionExample();
|
||||
PatternScanningExample();
|
||||
CachedPatternScanningExample();
|
||||
HighLevelAPIChaining();
|
||||
|
||||
Console.WriteLine("\n✓ All high-level API examples completed!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace WhiteMagic.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Main program for WhiteMagic examples
|
||||
/// Demonstrates all major features of the library
|
||||
/// </summary>
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine(@"
|
||||
╔════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ████████╗██╗ ██╗██╗ ██████╗███████╗███████╗██╗ ██╗███████╗██╗ ║
|
||||
║ ╚══██╔══╝██║ ██║██║██╔════╝██╔════╝██╔════╝██║ ██║██╔════╝██║ ║
|
||||
║ ██║ ██║ ██║██║██║ ███████╗███████╗███████║█████╗ ██║ ║
|
||||
║ ██║ ██║ ██║██║██║ ╚════██║╚════██║██╔══██║██╔══╝ ██║ ║
|
||||
║ ██║ ╚██████╔╝██║╚██████╗███████║███████║██║ ██║███████╗███████╗║
|
||||
║ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝║
|
||||
║ ║
|
||||
║ Process Introspection Library for .NET ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════════════════╝
|
||||
");
|
||||
Console.ResetColor();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine(" Examples for Learning WhiteMagic API");
|
||||
Console.WriteLine(" ─────────────────────────────────────");
|
||||
Console.ResetColor();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.White;
|
||||
Console.WriteLine("Select an example to run:");
|
||||
Console.ResetColor();
|
||||
|
||||
Console.WriteLine(" 1. Basic Memory Operations (Read/Write, Arrays, Strings, Structs)");
|
||||
Console.WriteLine(" 2. Pattern Scanning (Find patterns in memory)");
|
||||
Console.WriteLine(" 3. Execution Models (RemoteThread, MainThreadPump, InProcess)");
|
||||
Console.WriteLine(" 4. Function Hooking (Detours, Patches)");
|
||||
Console.WriteLine(" 5. High-Level API (RemotePointer, RemoteModule, RemoteWindow, etc.)");
|
||||
Console.WriteLine(" 6. Run All Examples");
|
||||
Console.WriteLine(" 0. Exit");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.Write("Enter your choice (0-6): ");
|
||||
string? input = Console.ReadLine();
|
||||
|
||||
if (!int.TryParse(input, out int choice))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine("Invalid input. Please enter a number between 0 and 6.");
|
||||
Console.ResetColor();
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
switch (choice)
|
||||
{
|
||||
case 1:
|
||||
BasicMemoryOperations.RunAll();
|
||||
break;
|
||||
|
||||
case 2:
|
||||
PatternScanning.RunAll();
|
||||
break;
|
||||
|
||||
case 3:
|
||||
await ExecutionModels.RunAll();
|
||||
break;
|
||||
|
||||
case 4:
|
||||
FunctionHooking.RunAll();
|
||||
break;
|
||||
|
||||
case 5:
|
||||
HighLevelAPI.RunAll();
|
||||
break;
|
||||
|
||||
case 6:
|
||||
// Run all examples sequentially
|
||||
BasicMemoryOperations.RunAll();
|
||||
Console.WriteLine("\n" + new string('=', 70) + "\n");
|
||||
await Task.Delay(500);
|
||||
|
||||
PatternScanning.RunAll();
|
||||
Console.WriteLine("\n" + new string('=', 70) + "\n");
|
||||
await Task.Delay(500);
|
||||
|
||||
await ExecutionModels.RunAll();
|
||||
Console.WriteLine("\n" + new string('=', 70) + "\n");
|
||||
await Task.Delay(500);
|
||||
|
||||
FunctionHooking.RunAll();
|
||||
Console.WriteLine("\n" + new string('=', 70) + "\n");
|
||||
await Task.Delay(500);
|
||||
|
||||
HighLevelAPI.RunAll();
|
||||
break;
|
||||
|
||||
case 0:
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("Exiting WhiteMagic Examples. Thank you!");
|
||||
Console.ResetColor();
|
||||
return;
|
||||
|
||||
default:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine("Invalid choice. Please enter a number between 0 and 6.");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"\n✗ Error running example: {ex.Message}");
|
||||
Console.WriteLine($" Stack trace: {ex.StackTrace}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
if (choice != 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("Press any key to continue...");
|
||||
Console.ResetColor();
|
||||
Console.ReadKey();
|
||||
Console.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
# WhiteMagic Examples
|
||||
|
||||
This directory contains comprehensive examples demonstrating all major features of the WhiteMagic library.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Target Process**: Most examples use Notepad as the target. Launch Notepad before running the examples.
|
||||
- **Administrator Privileges**: Some operations require elevated privileges. Run Visual Studio or terminal as Administrator.
|
||||
- **.NET 8.0 SDK**: Ensure you have .NET 8.0 installed.
|
||||
|
||||
## Running the Examples
|
||||
|
||||
### From Visual Studio
|
||||
|
||||
1. Open `WhiteMagic.slnx` in Visual Studio
|
||||
2. Set `WhiteMagic.Examples` as the startup project
|
||||
3. Press F5 to run
|
||||
|
||||
### From Command Line
|
||||
|
||||
```bash
|
||||
# Navigate to the examples directory
|
||||
cd WhiteMagic.Examples
|
||||
|
||||
# Run the examples
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Example Categories
|
||||
|
||||
### 1. Basic Memory Operations (`Example1_BasicMemoryOperations.cs`)
|
||||
|
||||
**Demonstrates:**
|
||||
- Reading and writing primitive types (int, float, double, bool)
|
||||
- Reading and writing arrays
|
||||
- Reading and writing strings (ANSI and Unicode)
|
||||
- Reading and writing raw bytes
|
||||
- Using `RemotePointer` for fluent pointer arithmetic
|
||||
- Relative addressing (module-relative offsets)
|
||||
- Error handling for memory operations
|
||||
- Working with custom structs
|
||||
|
||||
**Key Takeaways:**
|
||||
- All memory operations go through the `Magic.Memory` API
|
||||
- Use `RemotePointer` for clean, fluent pointer arithmetic
|
||||
- Reads throw exceptions on failure; writes return `false`
|
||||
- Prefer blittable types for best performance
|
||||
|
||||
### 2. Pattern Scanning (`Example2_PatternScanning.cs`)
|
||||
|
||||
**Demonstrates:**
|
||||
- Simple pattern scans with wildcards
|
||||
- Multiple pattern scans in one operation
|
||||
- Region-specific scanning (e.g., .text section only)
|
||||
- Finding function signatures
|
||||
- Cached pattern scanning for performance
|
||||
- Flexible wildcard patterns
|
||||
- Signature-based scanning (code caves, NOPs, INT3s)
|
||||
- Pattern validation
|
||||
|
||||
**Key Takeaways:**
|
||||
- Use IDA-style patterns: `"48 8B ? ? ? ? ?"` where `?` is a wildcard
|
||||
- Cache results for repeated scans
|
||||
- Narrow search region when possible for better performance
|
||||
|
||||
### 3. Execution Models (`Example3_ExecutionModels.cs`)
|
||||
|
||||
**Demonstrates:**
|
||||
- **RemoteThreadExecutor**: `CreateRemoteThread` for thread-agnostic calls
|
||||
- **MainThreadPump**: Crash-safe execution for state-sensitive calls
|
||||
- **InProcessInvoker**: Direct delegates for in-process calls
|
||||
- Choosing the right execution model
|
||||
- Combining execution models (inject then switch)
|
||||
- Error handling for each model
|
||||
|
||||
**Key Takeaways:**
|
||||
- **CRITICAL**: Use the right model for the payload:
|
||||
- `RemoteThreadExecutor`: Only for thread-agnostic functions (WinAPI, DLL injection)
|
||||
- `MainThreadPump`: For state-sensitive calls (game state, scripting, UI)
|
||||
- `InProcessInvoker`: Only after DLL injection
|
||||
- Using the wrong model crashes the target application
|
||||
|
||||
### 4. Function Hooking (`Example4_FunctionHooking.cs`)
|
||||
|
||||
**Demonstrates:**
|
||||
- **Inline Detours**: Function hooking with trampolines
|
||||
- Detours with parameter modification
|
||||
- Detours with return value modification
|
||||
- Multiple detours (chain hooking)
|
||||
- **Byte Patching**: Named byte patches
|
||||
- NOP patching (removing instructions)
|
||||
- Conditional patching (toggle on/off)
|
||||
- Prologue validation and safety
|
||||
- Automatic restoration on disposal
|
||||
|
||||
**Key Takeaways:**
|
||||
- Detours ONLY work in-process (requires DLL injection)
|
||||
- Patches work externally (no injection required)
|
||||
- `DetourManager`/`PatchManager` are NOT thread-safe
|
||||
- All hooks and patches auto-restore on disposal
|
||||
|
||||
### 5. High-Level API (`Example5_HighLevelAPI.cs`)
|
||||
|
||||
**Demonstrates:**
|
||||
- **RemotePointer**: Fluent pointer arithmetic with `[]` indexing
|
||||
- **RemoteModule**: Module enumeration and export resolution
|
||||
- **RemoteFunction**: Function resolution and execution
|
||||
- **ManagedPeb**/**ManagedTeb**: Typed PEB/TEB access
|
||||
- **RemoteWindow**: Window manipulation (move, resize, flash, activate)
|
||||
- Memory allocation and freeing
|
||||
- Module pattern scanning
|
||||
- Export function iteration
|
||||
- Chaining high-level operations
|
||||
|
||||
**Key Takeaways:**
|
||||
- Use high-level APIs for cleaner, more readable code
|
||||
- `magic["ModuleName"]` returns a `RemoteModule`
|
||||
- `module["FunctionName"]` returns a `RemoteFunction`
|
||||
- All high-level operations are built on top of the core memory API
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Reading a Nested Structure
|
||||
|
||||
```csharp
|
||||
// GameManager -> PlayerList -> Player[i] -> Health
|
||||
IntPtr gameManagerPtr = magic.Memory.ImageBase + 0x1000;
|
||||
IntPtr playerListPtr = magic.Memory.Read<IntPtr>(gameManagerPtr + 0x20);
|
||||
IntPtr playerPtr = magic.Memory.Read<IntPtr>(playerListPtr + (playerIndex * 8));
|
||||
int health = magic.Memory.Read<int>(playerPtr + 0x4);
|
||||
```
|
||||
|
||||
### Using RemotePointer for Cleaner Code
|
||||
|
||||
```csharp
|
||||
int health = magic[gameManagerPtr]
|
||||
.Read<IntPtr>(0x20) // PlayerList
|
||||
.Let(ptr => magic[ptr]
|
||||
.Read<IntPtr>(playerIndex * 8)) // Player
|
||||
.Let(ptr => magic[ptr]
|
||||
.Read<int>(0x4)); // Health
|
||||
```
|
||||
|
||||
### Safe Retry Loop for Writes
|
||||
|
||||
```csharp
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
if (magic.Memory.Write(address, value))
|
||||
break;
|
||||
Thread.Sleep(100 * (1 << i)); // Exponential backoff
|
||||
}
|
||||
```
|
||||
|
||||
### Crash-Safe State Access
|
||||
|
||||
```csharp
|
||||
// WRONG (crashes in most games)
|
||||
int health = magic.RemoteThread.Execute<int>(fn, CallConvention.Cdecl);
|
||||
|
||||
// RIGHT (crash-safe)
|
||||
var pump = magic.CreateMainThreadPump(frameAddress);
|
||||
int health = await pump.Enqueue(() => magic.Memory.Read<int>(healthAddress));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Process is not open for read/write"
|
||||
|
||||
**Cause**: Process has exited or handle is invalid
|
||||
|
||||
**Solution**: Ensure process is still running and handle is valid
|
||||
|
||||
### "Read returns default value"
|
||||
|
||||
**Cause**: Address is invalid or memory is protected
|
||||
|
||||
**Solution**: Verify address with debugger; check memory protection
|
||||
|
||||
### "Write returns false"
|
||||
|
||||
**Cause**: Memory is read-only or process has exited
|
||||
|
||||
**Solution**: Retry with delay; use `PatchManager` for code patches
|
||||
|
||||
### "CreateRemoteThread failed"
|
||||
|
||||
**Cause**: Insufficient permissions or target is protected
|
||||
|
||||
**Solution**: Run as Administrator; check target process protection
|
||||
|
||||
### "Crash when calling target function"
|
||||
|
||||
**Cause**: Calling single-threaded function from remote thread
|
||||
|
||||
**Solution**: Use `MainThreadPump` instead of `RemoteThreadExecutor`
|
||||
|
||||
### "Detour failed: prologue too short"
|
||||
|
||||
**Cause**: Function prologue is shorter than minimum (5 bytes)
|
||||
|
||||
**Solution**: Hook different function; patch deeper into function
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Main README](../README.md) - Overview and quick start
|
||||
- [Architecture Documentation](../docs/architecture.md) - System design and layer structure
|
||||
- [Execution Models](../docs/execution-models.md) - Deep dive on execution strategies
|
||||
- [Function Hooking](../docs/hooking.md) - DetourManager and PatchManager internals
|
||||
- [Memory Access](../docs/memory-access.md) - MemoryBase and MarshalCache
|
||||
- [Troubleshooting Guide](../docs/troubleshooting.md) - Common issues and solutions
|
||||
|
||||
## Safety Reminders
|
||||
|
||||
⚠️ **CRITICAL WARNINGS**:
|
||||
|
||||
1. **Execution Model Choice**:
|
||||
- `RemoteThreadExecutor`: ONLY for thread-agnostic functions
|
||||
- `MainThreadPump`: For state-sensitive calls (crash-safe)
|
||||
- `InProcessInvoker`: Only after DLL injection
|
||||
- **Using the wrong model crashes the target application!**
|
||||
|
||||
2. **Detours vs Patches**:
|
||||
- Detours ONLY work in-process (requires injection)
|
||||
- Patches work externally (no injection required)
|
||||
|
||||
3. **Thread Safety**:
|
||||
- `DetourManager`/`PatchManager` are NOT thread-safe
|
||||
- Synchronize concurrent modifications
|
||||
|
||||
4. **Handle Management**:
|
||||
- Always use `using` statements or dispose `Magic` properly
|
||||
- Leaked handles can cause resource exhaustion
|
||||
|
||||
5. **Anti-Cheat Detection**:
|
||||
- Some operations (e.g., `CreateRemoteThread`) are easily detected
|
||||
- Use `MainThreadPump` for stealthier operation
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a bug or have a suggestion? Please open an issue on GitHub.
|
||||
|
||||
## License
|
||||
|
||||
These examples are part of the WhiteMagic project. See the main LICENSE file for details.
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WhiteMagic\WhiteMagic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,5 @@
|
||||
<Solution>
|
||||
<Project Path="WhiteMagic.Examples/WhiteMagic.Examples.csproj" />
|
||||
<Project Path="WhiteMagic/WhiteMagic.csproj" />
|
||||
<Project Path="WhiteMagicTest/WhiteMagicTest.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using Iced.Intel;
|
||||
|
||||
namespace WhiteMagic.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// Optional <see cref="IAssembler"/> backend that assembles arbitrary x86/x64 mnemonic
|
||||
/// text to machine code using the Iced library, and provides full instruction-boundary
|
||||
/// decoding for detour prologue validation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Iced ships a fluent code assembler (typed method calls) and a decoder, but no
|
||||
/// text parser. This class bridges Intel-syntax text onto Iced's fluent
|
||||
/// <see cref="Assembler"/> by reflection: each line's mnemonic selects the matching
|
||||
/// <see cref="Assembler"/> method and its operands are bound to registers, immediates, or
|
||||
/// labels. Register and immediate operands and label-relative branches are supported;
|
||||
/// memory operands (<c>[reg+disp]</c>) are not — a caller needing those should emit bytes
|
||||
/// directly.</para>
|
||||
/// <para>This backend is entirely optional. Constructing it is the only thing that pulls
|
||||
/// Iced into a behavioral path; the default <see cref="StubAssembler"/> never references it.</para>
|
||||
/// </remarks>
|
||||
public sealed class IcedAssembler : IAssembler
|
||||
{
|
||||
private const int DefaultBitness = 64;
|
||||
|
||||
private readonly int _bitness;
|
||||
|
||||
// Lowercased register name -> boxed AssemblerRegisterNN value, built once from
|
||||
// Iced's AssemblerRegisters. Enables binding a text operand like "esp" to a typed
|
||||
// fluent-API register argument.
|
||||
private static readonly Dictionary<string, object> Registers = BuildRegisterMap();
|
||||
|
||||
/// <summary>Creates an assembler for the given bitness (32 or 64).</summary>
|
||||
/// <param name="bitness">32 for x86, 64 for x64. Defaults to 64.</param>
|
||||
public IcedAssembler(int bitness = DefaultBitness)
|
||||
{
|
||||
if (bitness != 32 && bitness != 64)
|
||||
throw new ArgumentOutOfRangeException(nameof(bitness), "Bitness must be 32 or 64.");
|
||||
|
||||
_bitness = bitness;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] Assemble(string assemblyText, ulong origin = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assemblyText);
|
||||
|
||||
var assembler = new Assembler(_bitness);
|
||||
|
||||
List<(string Mnemonic, string[] Operands)> lines = Tokenize(assemblyText, out var labelNames);
|
||||
|
||||
// Pre-create every label so a forward branch can reference it before its definition.
|
||||
var labels = new Dictionary<string, Label>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string name in labelNames)
|
||||
labels[name] = assembler.CreateLabel(name);
|
||||
|
||||
foreach ((string mnemonic, string[] operands) in lines)
|
||||
{
|
||||
// A pure label definition (e.g. "loop:") marks the current position.
|
||||
if (mnemonic.EndsWith(':'))
|
||||
{
|
||||
Label label = labels[mnemonic[..^1]];
|
||||
assembler.Label(ref label);
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitInstruction(assembler, mnemonic, operands, labels);
|
||||
}
|
||||
|
||||
var writer = new ByteListCodeWriter();
|
||||
assembler.Assemble(writer, origin);
|
||||
return writer.Bytes.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the number of whole prologue-instruction bytes that must be preserved for
|
||||
/// a splice of <paramref name="requiredBytes"/> bytes, decoding arbitrary instructions
|
||||
/// (not just the common prologue shapes the built-in decoder covers). Matches the
|
||||
/// <c>PrologueLengthResolver</c> delegate so it can be assigned to
|
||||
/// <see cref="WhiteMagic.Hooking.DetourManager.PrologueLengthResolver"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">A prologue byte sequence does not decode
|
||||
/// to a valid instruction.</exception>
|
||||
public int GetPrologueLength(byte[] prologue, int requiredBytes, bool is64Bit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(prologue);
|
||||
|
||||
var reader = new ByteArrayCodeReader(prologue);
|
||||
var decoder = Decoder.Create(is64Bit ? 64 : 32, reader);
|
||||
|
||||
int total = 0;
|
||||
while (total < requiredBytes)
|
||||
{
|
||||
decoder.Decode(out Instruction instruction);
|
||||
if (instruction.IsInvalid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The target prologue contains a byte sequence that does not decode to a valid instruction.");
|
||||
}
|
||||
|
||||
total += instruction.Length;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private void EmitInstruction(
|
||||
Assembler assembler,
|
||||
string mnemonic,
|
||||
string[] operandText,
|
||||
Dictionary<string, Label> labels)
|
||||
{
|
||||
object?[] operands = new object?[operandText.Length];
|
||||
for (int i = 0; i < operandText.Length; i++)
|
||||
operands[i] = ParseOperand(operandText[i], labels);
|
||||
|
||||
// Find the fluent Assembler method whose name equals the mnemonic and whose
|
||||
// parameters bind to the parsed operands.
|
||||
foreach (MethodInfo method in typeof(Assembler).GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (!string.Equals(method.Name, mnemonic, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
if (parameters.Length != operands.Length)
|
||||
continue;
|
||||
|
||||
if (TryBind(parameters, operands, out object?[]? boundArgs))
|
||||
{
|
||||
try
|
||||
{
|
||||
method.Invoke(assembler, boundArgs);
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||
{
|
||||
// Surface the real Iced failure rather than the reflection wrapper.
|
||||
throw ex.InnerException;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotSupportedException(
|
||||
$"Cannot assemble '{mnemonic}{(operandText.Length > 0 ? " " + string.Join(", ", operandText) : "")}': " +
|
||||
"no matching Iced assembler overload for the given operands (registers, immediates and " +
|
||||
"labels are supported; memory operands are not).");
|
||||
}
|
||||
|
||||
private static bool TryBind(ParameterInfo[] parameters, object?[] operands, out object?[]? boundArgs)
|
||||
{
|
||||
var args = new object?[parameters.Length];
|
||||
for (int i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
Type paramType = parameters[i].ParameterType;
|
||||
object? operand = operands[i];
|
||||
|
||||
switch (operand)
|
||||
{
|
||||
case Immediate imm when IsNumeric(paramType):
|
||||
// An immediate that overflows this parameter's type means this overload
|
||||
// is the wrong width; return false so a wider overload can be tried
|
||||
// instead of crashing the whole assembly.
|
||||
if (!TryChangeType(imm.Value, paramType, out object? converted))
|
||||
{
|
||||
boundArgs = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
args[i] = converted;
|
||||
break;
|
||||
|
||||
case not null when paramType.IsInstanceOfType(operand):
|
||||
args[i] = operand;
|
||||
break;
|
||||
|
||||
default:
|
||||
boundArgs = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
boundArgs = args;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object ParseOperand(string text, Dictionary<string, Label> labels)
|
||||
{
|
||||
string token = text.Trim();
|
||||
|
||||
if (Registers.TryGetValue(token, out object? register))
|
||||
return register;
|
||||
|
||||
if (labels.TryGetValue(token, out Label label))
|
||||
return label;
|
||||
|
||||
if (TryParseImmediate(token, out object? value))
|
||||
return new Immediate(value!);
|
||||
|
||||
throw new NotSupportedException(
|
||||
$"Unrecognized operand '{token}' (expected a register, an immediate, or a label).");
|
||||
}
|
||||
|
||||
// Parses an immediate as the narrowest of long/ulong that holds it, boxed. Storing the
|
||||
// widest representation lets TryChangeType later narrow it to whatever integer parameter
|
||||
// the chosen overload expects — and reject (rather than crash on) values that do not fit.
|
||||
private static bool TryParseImmediate(string token, out object? value)
|
||||
{
|
||||
value = null;
|
||||
bool negative = token.StartsWith('-');
|
||||
string body = negative ? token[1..] : token;
|
||||
|
||||
if (body.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!ulong.TryParse(body[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong hex))
|
||||
return false;
|
||||
|
||||
value = negative ? -(long)hex : hex;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (negative)
|
||||
{
|
||||
if (!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long signed))
|
||||
return false;
|
||||
|
||||
value = signed;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-negative decimal: prefer long, fall back to ulong for values above long.MaxValue.
|
||||
if (long.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out long asLong))
|
||||
value = asLong;
|
||||
else if (ulong.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong asULong))
|
||||
value = asULong;
|
||||
else
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryChangeType(object value, Type targetType, out object? result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
|
||||
{
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNumeric(Type type) => Type.GetTypeCode(type) is
|
||||
TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or
|
||||
TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64;
|
||||
|
||||
private static List<(string Mnemonic, string[] Operands)> Tokenize(string text, out List<string> labelNames)
|
||||
{
|
||||
var result = new List<(string, string[])>();
|
||||
labelNames = new List<string>();
|
||||
|
||||
foreach (string rawLine in text.Split('\n'))
|
||||
{
|
||||
string line = rawLine;
|
||||
|
||||
int comment = line.IndexOf(';');
|
||||
if (comment >= 0)
|
||||
line = line[..comment];
|
||||
|
||||
line = line.Trim();
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
// A "name:" prefix is a label definition; keep any instruction that follows it
|
||||
// on the same line as a separate entry.
|
||||
int colon = line.IndexOf(':');
|
||||
if (colon >= 0)
|
||||
{
|
||||
string labelName = line[..colon].Trim();
|
||||
labelNames.Add(labelName);
|
||||
result.Add((labelName + ":", Array.Empty<string>()));
|
||||
|
||||
line = line[(colon + 1)..].Trim();
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
int space = line.IndexOfAny([' ', '\t']);
|
||||
if (space < 0)
|
||||
{
|
||||
result.Add((line, Array.Empty<string>()));
|
||||
continue;
|
||||
}
|
||||
|
||||
string mnemonic = line[..space];
|
||||
string[] operands = line[(space + 1)..]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
result.Add((mnemonic, operands));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> BuildRegisterMap()
|
||||
{
|
||||
var map = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (FieldInfo field in typeof(AssemblerRegisters).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
object? value = field.GetValue(null);
|
||||
if (value is not null)
|
||||
map[field.Name] = value;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// A parsed immediate (boxed long or ulong), distinguished from register/label operands
|
||||
// so binding can narrow it to whichever integer parameter type the chosen overload
|
||||
// expects — or reject it when it does not fit.
|
||||
private readonly record struct Immediate(object Value);
|
||||
|
||||
private sealed class ByteListCodeWriter : CodeWriter
|
||||
{
|
||||
public List<byte> Bytes { get; } = new();
|
||||
|
||||
public override void WriteByte(byte value) => Bytes.Add(value);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,10 @@ public sealed class StubAssembler : IAssembler
|
||||
|
||||
// ── Emit primitives ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Emits a single byte into the buffer.</summary>
|
||||
public void EmitU8(List<byte> buffer, byte value) => buffer.Add(value);
|
||||
|
||||
/// <summary>Emits a 32-bit little-endian integer into the buffer.</summary>
|
||||
public void EmitU32(List<byte> buffer, uint value)
|
||||
{
|
||||
buffer.Add((byte)value);
|
||||
@@ -40,6 +42,7 @@ public sealed class StubAssembler : IAssembler
|
||||
buffer.Add((byte)(value >> 24));
|
||||
}
|
||||
|
||||
/// <summary>Emits a 64-bit little-endian integer into the buffer.</summary>
|
||||
public void EmitU64(List<byte> buffer, ulong value)
|
||||
{
|
||||
EmitU32(buffer, (uint)value);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace WhiteMagic.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Scans process memory for a byte pattern with an optional wildcard mask.
|
||||
/// </summary>
|
||||
public static class PatternScanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a memory range for the first occurrence of a pattern with an optional wildcard mask.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="pattern">The byte pattern to search for.</param>
|
||||
/// <param name="mask">
|
||||
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
|
||||
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
|
||||
/// </param>
|
||||
/// <param name="start">The starting address of the scan range.</param>
|
||||
/// <param name="end">The ending address (exclusive) of the scan range.</param>
|
||||
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="pattern"/> is empty, or <paramref name="mask"/> length does not match
|
||||
/// <paramref name="pattern"/> length, or <paramref name="mask"/> contains invalid characters.
|
||||
/// </exception>
|
||||
/// <exception cref="Win32Exception">Memory read fails with an unexpected error.</exception>
|
||||
public static IntPtr Find(
|
||||
MemoryBase memory,
|
||||
byte[] pattern,
|
||||
string? mask,
|
||||
IntPtr start,
|
||||
IntPtr end)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
ArgumentNullException.ThrowIfNull(pattern);
|
||||
|
||||
if (pattern.Length == 0)
|
||||
throw new ArgumentException("Pattern cannot be empty.", nameof(pattern));
|
||||
|
||||
// Validate and normalize mask
|
||||
if (mask is not null)
|
||||
{
|
||||
if (mask.Length != pattern.Length)
|
||||
throw new ArgumentException(
|
||||
$"Mask length ({mask.Length}) must match pattern length ({pattern.Length}).",
|
||||
nameof(mask));
|
||||
|
||||
foreach (char c in mask)
|
||||
{
|
||||
if (c != 'x' && c != '?')
|
||||
throw new ArgumentException(
|
||||
$"Mask may contain only 'x' (match) or '?' (wildcard); found '{c}'.",
|
||||
nameof(mask));
|
||||
}
|
||||
}
|
||||
|
||||
// Null mask means treat all bytes as 'x' (exact match)
|
||||
mask ??= new string('x', pattern.Length);
|
||||
|
||||
// Scan range in reasonable chunks (64 KB to avoid massive single reads)
|
||||
const int chunkSize = 64 * 1024;
|
||||
int patternLen = pattern.Length;
|
||||
long rangeSize = (long)end - (long)start;
|
||||
|
||||
if (rangeSize <= 0)
|
||||
return IntPtr.Zero;
|
||||
|
||||
// For small ranges, read all at once
|
||||
if (rangeSize <= chunkSize)
|
||||
{
|
||||
byte[] buffer = memory.ReadBytes(start, (int)rangeSize);
|
||||
return FindInBuffer(buffer, pattern, mask, start);
|
||||
}
|
||||
|
||||
// For larger ranges, scan in chunks
|
||||
long remaining = rangeSize;
|
||||
IntPtr current = start;
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int toRead = (int)Math.Min(chunkSize, remaining);
|
||||
byte[] chunk = memory.ReadBytes(current, toRead);
|
||||
|
||||
// Empty read means we hit an unmapped region or read failure
|
||||
if (chunk.Length == 0)
|
||||
{
|
||||
// Skip past this unreadable region
|
||||
current += toRead;
|
||||
remaining -= toRead;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Search in this chunk
|
||||
IntPtr found = FindInBuffer(chunk, pattern, mask, current);
|
||||
if (found != IntPtr.Zero)
|
||||
return found;
|
||||
|
||||
// Move to next chunk, leaving room for pattern that might straddle boundary
|
||||
// We advance by (chunkSize - patternLen + 1) to ensure we don't miss matches
|
||||
int advance = toRead - patternLen + 1;
|
||||
if (advance <= 0)
|
||||
advance = toRead;
|
||||
|
||||
current += advance;
|
||||
remaining -= advance;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a module's memory region (from its base address through its size) for a pattern.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="pattern">The byte pattern to search for.</param>
|
||||
/// <param name="mask">
|
||||
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
|
||||
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
|
||||
/// </param>
|
||||
/// <param name="module">The module to scan.</param>
|
||||
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
|
||||
public static IntPtr FindInModule(
|
||||
MemoryBase memory,
|
||||
byte[] pattern,
|
||||
string? mask,
|
||||
ProcessModule module)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(module);
|
||||
|
||||
IntPtr start = module.BaseAddress;
|
||||
IntPtr end = start + module.ModuleMemorySize;
|
||||
|
||||
return Find(memory, pattern, mask, start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans multiple modules for a pattern, returning the first match found.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="pattern">The byte pattern to search for.</param>
|
||||
/// <param name="mask">
|
||||
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
|
||||
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
|
||||
/// </param>
|
||||
/// <param name="modules">The modules to scan, in order.</param>
|
||||
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
|
||||
public static IntPtr FindInModules(
|
||||
MemoryBase memory,
|
||||
byte[] pattern,
|
||||
string? mask,
|
||||
IEnumerable<ProcessModule> modules)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(modules);
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
IntPtr found = FindInModule(memory, pattern, mask, module);
|
||||
if (found != IntPtr.Zero)
|
||||
return found;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches a buffer for the first pattern match given a mask.
|
||||
/// </summary>
|
||||
private static IntPtr FindInBuffer(
|
||||
byte[] buffer,
|
||||
byte[] pattern,
|
||||
string mask,
|
||||
IntPtr bufferBase)
|
||||
{
|
||||
if (buffer.Length < pattern.Length)
|
||||
return IntPtr.Zero;
|
||||
|
||||
int patternLen = pattern.Length;
|
||||
int maxOffset = buffer.Length - patternLen;
|
||||
|
||||
for (int offset = 0; offset <= maxOffset; offset++)
|
||||
{
|
||||
bool match = true;
|
||||
|
||||
for (int i = 0; i < patternLen; i++)
|
||||
{
|
||||
// Only compare if mask says 'x' (exact match required)
|
||||
if (mask[i] == 'x' && buffer[offset + i] != pattern[i])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
return bufferBase + offset;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace WhiteMagic.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Caches pattern scan results to avoid repeated scans of the same memory range.
|
||||
/// </summary>
|
||||
public sealed class PatternScannerCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<CacheKey, IntPtr> _cache = new();
|
||||
private readonly MemoryBase _memory;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new cache for the given memory accessor.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor to scan.</param>
|
||||
public PatternScannerCache(MemoryBase memory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a pattern, returning a cached result if available.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The byte pattern to search for.</param>
|
||||
/// <param name="mask">
|
||||
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
|
||||
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
|
||||
/// </param>
|
||||
/// <param name="start">The starting address of the scan range.</param>
|
||||
/// <param name="end">The ending address (exclusive) of the scan range.</param>
|
||||
/// <returns>
|
||||
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
|
||||
/// </returns>
|
||||
public IntPtr FindCached(
|
||||
byte[] pattern,
|
||||
string? mask,
|
||||
IntPtr start,
|
||||
IntPtr end)
|
||||
{
|
||||
var key = new CacheKey(pattern, mask, start, end);
|
||||
|
||||
// Try to get from cache first
|
||||
if (_cache.TryGetValue(key, out IntPtr cached))
|
||||
return cached;
|
||||
|
||||
// Not in cache, perform the scan
|
||||
IntPtr found = PatternScanner.Find(_memory, pattern, mask, start, end);
|
||||
|
||||
// Cache the result (even if Zero)
|
||||
_cache[key] = found;
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a pattern within a module, returning a cached result if available.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The byte pattern to search for.</param>
|
||||
/// <param name="mask">
|
||||
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
|
||||
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
|
||||
/// </param>
|
||||
/// <param name="module">The module to scan.</param>
|
||||
/// <returns>
|
||||
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
|
||||
/// </returns>
|
||||
public IntPtr FindInModuleCached(
|
||||
byte[] pattern,
|
||||
string? mask,
|
||||
ProcessModule module)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(module);
|
||||
|
||||
IntPtr start = module.BaseAddress;
|
||||
IntPtr end = start + module.ModuleMemorySize;
|
||||
|
||||
return FindCached(pattern, mask, start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a pattern across multiple modules, returning a cached result if available.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The byte pattern to search for.</param>
|
||||
/// <param name="mask">
|
||||
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
|
||||
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
|
||||
/// </param>
|
||||
/// <param name="modules">The modules to scan, in order.</param>
|
||||
/// <returns>
|
||||
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
|
||||
/// </returns>
|
||||
public IntPtr FindInModulesCached(
|
||||
byte[] pattern,
|
||||
string? mask,
|
||||
IEnumerable<ProcessModule> modules)
|
||||
{
|
||||
// For multiple modules, we use a combined key (all modules hashed together)
|
||||
// This is less granular but still useful for repeated queries
|
||||
var moduleList = modules.ToList();
|
||||
var key = new CacheKey(pattern, mask, IntPtr.Zero, IntPtr.Zero, Modules: moduleList);
|
||||
if (_cache.TryGetValue(key, out IntPtr cached))
|
||||
return cached;
|
||||
|
||||
IntPtr found = PatternScanner.FindInModules(_memory, pattern, mask, moduleList);
|
||||
|
||||
_cache[key] = found;
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached scan results.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache key combining pattern, mask, and address range.
|
||||
/// </summary>
|
||||
private sealed record CacheKey(
|
||||
byte[] Pattern,
|
||||
string? Mask,
|
||||
IntPtr Start,
|
||||
IntPtr End,
|
||||
IReadOnlyList<ProcessModule>? Modules = null) : IEquatable<CacheKey>
|
||||
{
|
||||
public bool Equals(CacheKey? other)
|
||||
{
|
||||
if (other is null)
|
||||
return false;
|
||||
if (Start != other.Start || End != other.End || Mask != other.Mask)
|
||||
return false;
|
||||
if (!Pattern.AsSpan().SequenceEqual(other.Pattern))
|
||||
return false;
|
||||
|
||||
if (Modules is null)
|
||||
return other.Modules is null;
|
||||
if (other.Modules is null || Modules.Count != other.Modules.Count)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < Modules.Count; i++)
|
||||
{
|
||||
if (Modules[i].BaseAddress != other.Modules[i].BaseAddress)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Override GetHashCode to hash the contents, not references
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
|
||||
// Hash pattern bytes
|
||||
foreach (byte b in Pattern)
|
||||
hash.Add(b);
|
||||
|
||||
// Hash mask
|
||||
hash.Add(Mask?.GetHashCode() ?? 0);
|
||||
|
||||
// Hash address range
|
||||
hash.Add(Start.GetHashCode());
|
||||
hash.Add(End.GetHashCode());
|
||||
|
||||
// Hash modules if present (by base address)
|
||||
if (Modules is not null)
|
||||
{
|
||||
foreach (var m in Modules)
|
||||
hash.Add(m.BaseAddress.GetHashCode());
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a section in a PE file.
|
||||
/// </summary>
|
||||
public readonly record struct PeSection
|
||||
{
|
||||
/// <summary>
|
||||
/// The 8-byte null-terminated section name (e.g., ".text", ".data").
|
||||
/// </summary>
|
||||
public string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The virtual address of the section when loaded into memory (RVA).
|
||||
/// </summary>
|
||||
public IntPtr VirtualAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The size of the section in memory.
|
||||
/// </summary>
|
||||
public int VirtualSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses PE headers to expose section information and entry points.
|
||||
/// </summary>
|
||||
public sealed class PeHeaderParser
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly IntPtr _baseAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PE header parser for the module at the specified base address.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="baseAddress">The base address of the module.</param>
|
||||
public PeHeaderParser(MemoryBase memory, IntPtr baseAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
if (baseAddress == IntPtr.Zero)
|
||||
throw new ArgumentException("Base address cannot be zero.", nameof(baseAddress));
|
||||
|
||||
_memory = memory;
|
||||
_baseAddress = baseAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entry point RVA (Relative Virtual Address) of the PE file.
|
||||
/// </summary>
|
||||
/// <returns>The entry point RVA, or <see cref="IntPtr.Zero"/> if unavailable.</returns>
|
||||
/// <exception cref="Win32Exception">Reading memory fails.</exception>
|
||||
/// <exception cref="InvalidDataException">The PE headers are invalid.</exception>
|
||||
public IntPtr EntryPoint
|
||||
{
|
||||
get
|
||||
{
|
||||
// Read and parse PE headers
|
||||
var (optionalHeader, _) = ParseOptionalHeader();
|
||||
|
||||
if (optionalHeader is null)
|
||||
return IntPtr.Zero;
|
||||
|
||||
// Entry point RVA is at offset 16 in the optional header (both PE32 and PE32+)
|
||||
return (IntPtr)BitConverter.ToUInt32(optionalHeader.AsSpan(16, 4));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all sections in the PE file.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of PE sections.</returns>
|
||||
/// <exception cref="Win32Exception">Reading memory fails.</exception>
|
||||
/// <exception cref="InvalidDataException">The PE headers are invalid.</exception>
|
||||
public IEnumerable<PeSection> Sections
|
||||
{
|
||||
get
|
||||
{
|
||||
var (optionalHeader, sectionHeaders) = ParseOptionalHeaderAndSectionHeaders();
|
||||
|
||||
if (sectionHeaders is null || sectionHeaders.Length == 0)
|
||||
yield break;
|
||||
|
||||
foreach (var sectionHeader in sectionHeaders)
|
||||
{
|
||||
// Parse section name (8-byte, null-terminated)
|
||||
string name = ParseSectionName(sectionHeader);
|
||||
|
||||
// VirtualAddress and VirtualSize
|
||||
uint virtualAddress = BitConverter.ToUInt32(sectionHeader, 12);
|
||||
uint virtualSize = BitConverter.ToUInt32(sectionHeader, 8);
|
||||
|
||||
yield return new PeSection
|
||||
{
|
||||
Name = name,
|
||||
VirtualAddress = (IntPtr)virtualAddress,
|
||||
VirtualSize = (int)virtualSize
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an exported function's absolute address by name, following export
|
||||
/// forwarders (e.g. <c>kernel32!HeapAlloc</c> → <c>NTDLL.RtlAllocateHeap</c>) into
|
||||
/// other modules loaded in the same target process.
|
||||
/// </summary>
|
||||
/// <param name="functionName">The exported symbol name (case-sensitive, as stored
|
||||
/// in the export name table).</param>
|
||||
/// <returns>The absolute address of the export in the target process.</returns>
|
||||
/// <remarks>
|
||||
/// Forwarders are resolved by locating the target module in the process's loaded-module
|
||||
/// list. API-set forwarders (virtual <c>api-ms-win-*</c> / <c>ext-ms-*</c> names) are NOT
|
||||
/// supported: those are not real loaded modules, so resolution through the module list is
|
||||
/// impossible without parsing the API-set schema — such a forwarder throws
|
||||
/// <see cref="NotSupportedException"/>. On modern Windows many system-DLL exports forward
|
||||
/// through API sets; resolve those via the OS loader (<c>GetProcAddress</c>) instead.
|
||||
/// Ordinal forwarders (<c>Module.#N</c>) are likewise unsupported.
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">The export is not present.</exception>
|
||||
/// <exception cref="NotSupportedException">The export forwards to an ordinal or to a
|
||||
/// module (such as an API set) that is not resolvable from the target's module list.</exception>
|
||||
/// <exception cref="InvalidDataException">The PE export data is malformed.</exception>
|
||||
public IntPtr GetExportAddress(string functionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(functionName);
|
||||
return ResolveExport(functionName, 0);
|
||||
}
|
||||
|
||||
// Maximum forwarder hops before giving up, to bound pathological chains.
|
||||
private const int MaxForwarderDepth = 16;
|
||||
|
||||
private IntPtr ResolveExport(string functionName, int depth)
|
||||
{
|
||||
if (depth > MaxForwarderDepth)
|
||||
throw new InvalidDataException($"Export forwarder chain for '{functionName}' is too deep.");
|
||||
|
||||
var (optionalHeader, _) = ParseOptionalHeader();
|
||||
if (optionalHeader is null || optionalHeader.Length < 2)
|
||||
throw new InvalidDataException("Optional header unavailable.");
|
||||
|
||||
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit). Data directories start at a
|
||||
// different offset in each: 96 for PE32, 112 for PE32+. The export table is
|
||||
// directory index 0, so its 8-byte entry sits at that offset.
|
||||
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
|
||||
bool pe32Plus = magic == 0x20b;
|
||||
int exportDirOffset = pe32Plus ? 112 : 96;
|
||||
if (optionalHeader.Length < exportDirOffset + 8)
|
||||
throw new InvalidDataException("Optional header does not contain the export data directory.");
|
||||
|
||||
uint exportRva = BitConverter.ToUInt32(optionalHeader, exportDirOffset);
|
||||
uint exportSize = BitConverter.ToUInt32(optionalHeader, exportDirOffset + 4);
|
||||
if (exportRva == 0 || exportSize == 0)
|
||||
throw new InvalidOperationException("Module has no export table.");
|
||||
|
||||
// IMAGE_EXPORT_DIRECTORY is 40 bytes.
|
||||
byte[] dir = _memory.ReadBytes(_baseAddress + (nint)exportRva, 40);
|
||||
if (dir.Length < 40)
|
||||
throw new InvalidDataException("Failed to read the export directory.");
|
||||
|
||||
uint numberOfFunctions = BitConverter.ToUInt32(dir, 20);
|
||||
uint numberOfNames = BitConverter.ToUInt32(dir, 24);
|
||||
uint addressOfFunctions = BitConverter.ToUInt32(dir, 28);
|
||||
uint addressOfNames = BitConverter.ToUInt32(dir, 32);
|
||||
uint addressOfNameOrdinals = BitConverter.ToUInt32(dir, 36);
|
||||
|
||||
// Guard against corrupt counts before allocating arrays sized from them.
|
||||
if (numberOfNames > 0x10000 || numberOfFunctions > 0x10000)
|
||||
throw new InvalidDataException("Export table entry count is out of range.");
|
||||
|
||||
if (numberOfNames == 0)
|
||||
throw new InvalidOperationException($"Export '{functionName}' not found (module exports no names).");
|
||||
|
||||
byte[] nameRvas = _memory.ReadBytes(_baseAddress + (nint)addressOfNames, checked((int)(numberOfNames * 4)));
|
||||
byte[] nameOrdinals = _memory.ReadBytes(_baseAddress + (nint)addressOfNameOrdinals, checked((int)(numberOfNames * 2)));
|
||||
if (nameRvas.Length < numberOfNames * 4 || nameOrdinals.Length < numberOfNames * 2)
|
||||
throw new InvalidDataException("Failed to read the export name tables.");
|
||||
|
||||
int nameIndex = -1;
|
||||
for (int i = 0; i < numberOfNames; i++)
|
||||
{
|
||||
uint nameRva = BitConverter.ToUInt32(nameRvas, i * 4);
|
||||
string name = _memory.ReadString(_baseAddress + (nint)nameRva, Encoding.ASCII, 512);
|
||||
if (string.Equals(name, functionName, StringComparison.Ordinal))
|
||||
{
|
||||
nameIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nameIndex < 0)
|
||||
throw new InvalidOperationException($"Export '{functionName}' not found in module.");
|
||||
|
||||
ushort ordinal = BitConverter.ToUInt16(nameOrdinals, nameIndex * 2);
|
||||
if (ordinal >= numberOfFunctions)
|
||||
throw new InvalidDataException("Export name ordinal is out of range.");
|
||||
|
||||
byte[] funcRvaBytes = _memory.ReadBytes(
|
||||
_baseAddress + (nint)(addressOfFunctions + (uint)ordinal * 4u), 4);
|
||||
if (funcRvaBytes.Length < 4)
|
||||
throw new InvalidDataException("Failed to read the export address table entry.");
|
||||
|
||||
uint funcRva = BitConverter.ToUInt32(funcRvaBytes, 0);
|
||||
if (funcRva == 0)
|
||||
throw new InvalidOperationException($"Export '{functionName}' has no address.");
|
||||
|
||||
// A function RVA that lands inside the export directory region is not code but a
|
||||
// null-terminated "Module.Function" forwarder string.
|
||||
if (funcRva >= exportRva && funcRva < exportRva + exportSize)
|
||||
{
|
||||
string forwarder = _memory.ReadString(_baseAddress + (nint)funcRva, Encoding.ASCII, 512);
|
||||
return ResolveForwarder(forwarder, depth);
|
||||
}
|
||||
|
||||
return _baseAddress + (nint)funcRva;
|
||||
}
|
||||
|
||||
private IntPtr ResolveForwarder(string forwarder, int depth)
|
||||
{
|
||||
// A forwarder is "Module.Function"; the module name carries no extension, so the
|
||||
// FIRST dot is the boundary. Splitting on the last dot would misparse export names
|
||||
// that themselves contain a dot (e.g. some C++/managed exports).
|
||||
int dot = forwarder.IndexOf('.');
|
||||
if (dot <= 0 || dot >= forwarder.Length - 1)
|
||||
throw new InvalidDataException($"Malformed export forwarder string '{forwarder}'.");
|
||||
|
||||
string moduleName = forwarder[..dot];
|
||||
string target = forwarder[(dot + 1)..];
|
||||
|
||||
if (target.StartsWith('#'))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Ordinal export forwarders are not supported (forwarder '{forwarder}').");
|
||||
}
|
||||
|
||||
IntPtr targetBase = RemoteModule.ResolveBase(_memory.ProcessId, moduleName);
|
||||
if (targetBase == IntPtr.Zero)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Export forwarder target module '{moduleName}' is not loaded in the target " +
|
||||
$"process, or is an unresolvable API set (forwarder '{forwarder}').");
|
||||
}
|
||||
|
||||
return new PeHeaderParser(_memory, targetBase).ResolveExport(target, depth + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the DOS header, PE signature, and optional header.
|
||||
/// </summary>
|
||||
private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeaderAndSectionHeaders()
|
||||
{
|
||||
// Read DOS header (first 64 bytes)
|
||||
byte[] dosHeader = _memory.ReadBytes(_baseAddress, 64);
|
||||
if (dosHeader.Length < 64)
|
||||
throw new InvalidDataException("Failed to read DOS header.");
|
||||
|
||||
// Verify DOS signature "MZ"
|
||||
if (dosHeader[0] != 0x4D || dosHeader[1] != 0x5A)
|
||||
throw new InvalidDataException("Invalid DOS signature (not a PE file).");
|
||||
|
||||
// PE header offset is at 0x3C in DOS header
|
||||
int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
|
||||
if (peOffset < 0 || peOffset > 0x1000) // Sanity check
|
||||
throw new InvalidDataException($"Invalid PE offset: {peOffset}");
|
||||
|
||||
// Read PE signature (4 bytes: "PE\0\0")
|
||||
IntPtr peSigAddr = _baseAddress + peOffset;
|
||||
byte[] peSignature = _memory.ReadBytes(peSigAddr, 4);
|
||||
if (peSignature.Length < 4)
|
||||
throw new InvalidDataException("Failed to read PE signature.");
|
||||
|
||||
if (peSignature[0] != 0x50 || peSignature[1] != 0x45 ||
|
||||
peSignature[2] != 0x00 || peSignature[3] != 0x00)
|
||||
throw new InvalidDataException("Invalid PE signature.");
|
||||
|
||||
// COFF header follows PE signature (20 bytes)
|
||||
IntPtr coffAddr = peSigAddr + 4;
|
||||
byte[] coffHeader = _memory.ReadBytes(coffAddr, 20);
|
||||
if (coffHeader.Length < 20)
|
||||
throw new InvalidDataException("Failed to read COFF header.");
|
||||
|
||||
// SizeOfOptionalHeader is at offset 16 in COFF header
|
||||
ushort sizeOfOptionalHeader = BitConverter.ToUInt16(coffHeader, 16);
|
||||
// NumberOfSections is at offset 2 in COFF header
|
||||
ushort numberOfSections = BitConverter.ToUInt16(coffHeader, 2);
|
||||
|
||||
if (numberOfSections == 0 || numberOfSections > 96)
|
||||
return (null, null); // No sections or unreasonable number
|
||||
// Optional header follows COFF header
|
||||
IntPtr optAddr = coffAddr + 20;
|
||||
byte[] optionalHeader = _memory.ReadBytes(optAddr, sizeOfOptionalHeader);
|
||||
if (optionalHeader.Length < sizeOfOptionalHeader)
|
||||
throw new InvalidDataException("Failed to read optional header.");
|
||||
|
||||
// Section headers follow optional header
|
||||
IntPtr sectionAddr = optAddr + sizeOfOptionalHeader;
|
||||
int sectionHeaderSize = 40; // IMAGE_SECTION_HEADER is 40 bytes
|
||||
|
||||
byte[][] sectionHeaders = new byte[numberOfSections][];
|
||||
for (int i = 0; i < numberOfSections; i++)
|
||||
{
|
||||
byte[] section = _memory.ReadBytes(sectionAddr + (i * sectionHeaderSize), sectionHeaderSize);
|
||||
if (section.Length < sectionHeaderSize)
|
||||
throw new InvalidDataException($"Failed to read section header {i}.");
|
||||
sectionHeaders[i] = section;
|
||||
}
|
||||
|
||||
return (optionalHeader, sectionHeaders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses just the optional header (for entry point).
|
||||
/// </summary>
|
||||
private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeader()
|
||||
{
|
||||
// Read DOS header (first 64 bytes)
|
||||
byte[] dosHeader = _memory.ReadBytes(_baseAddress, 64);
|
||||
if (dosHeader.Length < 64)
|
||||
throw new InvalidDataException("Failed to read DOS header.");
|
||||
|
||||
// Verify DOS signature "MZ"
|
||||
if (dosHeader[0] != 0x4D || dosHeader[1] != 0x5A)
|
||||
throw new InvalidDataException("Invalid DOS signature (not a PE file).");
|
||||
|
||||
// PE header offset is at 0x3C in DOS header
|
||||
int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
|
||||
if (peOffset < 0 || peOffset > 0x1000) // Sanity check
|
||||
throw new InvalidDataException($"Invalid PE offset: {peOffset}");
|
||||
|
||||
// Read PE signature (4 bytes: "PE\0\0")
|
||||
IntPtr peSigAddr = _baseAddress + peOffset;
|
||||
byte[] peSignature = _memory.ReadBytes(peSigAddr, 4);
|
||||
if (peSignature.Length < 4)
|
||||
throw new InvalidDataException("Failed to read PE signature.");
|
||||
|
||||
if (peSignature[0] != 0x50 || peSignature[1] != 0x45 ||
|
||||
peSignature[2] != 0x00 || peSignature[3] != 0x00)
|
||||
throw new InvalidDataException("Invalid PE signature.");
|
||||
|
||||
// COFF header follows PE signature (20 bytes)
|
||||
IntPtr coffAddr = peSigAddr + 4;
|
||||
byte[] coffHeader = _memory.ReadBytes(coffAddr, 20);
|
||||
if (coffHeader.Length < 20)
|
||||
throw new InvalidDataException("Failed to read COFF header.");
|
||||
|
||||
// SizeOfOptionalHeader is at offset 16 in COFF header
|
||||
ushort sizeOfOptionalHeader = BitConverter.ToUInt16(coffHeader, 16);
|
||||
|
||||
// Optional header follows COFF header
|
||||
IntPtr optAddr = coffAddr + 20;
|
||||
byte[] optionalHeader = _memory.ReadBytes(optAddr, sizeOfOptionalHeader);
|
||||
if (optionalHeader.Length < sizeOfOptionalHeader)
|
||||
throw new InvalidDataException("Failed to read optional header.");
|
||||
|
||||
return (optionalHeader, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the PE file is PE32+ (64-bit) or PE32 (32-bit).
|
||||
/// </summary>
|
||||
private bool IsPe32Plus()
|
||||
{
|
||||
var (optionalHeader, _) = ParseOptionalHeaderAndSectionHeaders();
|
||||
|
||||
if (optionalHeader is null || optionalHeader.Length < 2)
|
||||
throw new InvalidDataException("Optional header too short.");
|
||||
|
||||
// Magic is at offset 0 in optional header
|
||||
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit)
|
||||
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
|
||||
return magic == 0x20b;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a null-terminated 8-byte section name.
|
||||
/// </summary>
|
||||
private static string ParseSectionName(byte[] sectionHeader)
|
||||
{
|
||||
// Name is first 8 bytes
|
||||
var nameBytes = new Span<byte>(sectionHeader, 0, 8);
|
||||
|
||||
// Find null terminator
|
||||
int len = 0;
|
||||
for (; len < 8; len++)
|
||||
{
|
||||
if (nameBytes[len] == 0)
|
||||
break;
|
||||
}
|
||||
|
||||
return System.Text.Encoding.ASCII.GetString(nameBytes[..len]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WhiteMagic.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Direct native-to-managed delegate calls for the in-process scenario.
|
||||
/// This is the third execution tier: no remote thread is created; the call runs
|
||||
/// synchronously on the current thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class assumes the WhiteMagic consumer has already arranged to run inside the
|
||||
/// target process. Bootstrapping the managed loader (e.g., via a CLR host or native
|
||||
/// shim) that places WhiteMagic into a foreign process is a separate follow-up change
|
||||
/// and is not implemented here.</para>
|
||||
/// </remarks>
|
||||
public sealed class InProcessInvoker
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
|
||||
/// <summary>Creates an invoker bound to the supplied memory reader.</summary>
|
||||
public InProcessInvoker(MemoryBase memory)
|
||||
{
|
||||
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed delegate of type <typeparamref name="TDelegate"/> that calls
|
||||
/// the native function at <paramref name="address"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDelegate">A delegate type whose signature matches the native function.</typeparam>
|
||||
public TDelegate CreateFunction<TDelegate>(IntPtr address)
|
||||
where TDelegate : Delegate
|
||||
{
|
||||
if (address == IntPtr.Zero)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Function address cannot be zero.", nameof(address));
|
||||
}
|
||||
|
||||
return Marshal.GetDelegateForFunctionPointer<TDelegate>(address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the vtable pointer stored at the start of an object in memory.
|
||||
/// </summary>
|
||||
/// <param name="objectAddress">The address of the object instance.</param>
|
||||
/// <returns>The address of the vtable.</returns>
|
||||
public IntPtr ReadVTable(IntPtr objectAddress)
|
||||
{
|
||||
return _memory.Read<IntPtr>(objectAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a function pointer from a vtable by index.
|
||||
/// </summary>
|
||||
/// <param name="vTableAddress">The address of the vtable.</param>
|
||||
/// <param name="methodIndex">The zero-based index of the method slot.</param>
|
||||
/// <returns>The address in the specified vtable slot.</returns>
|
||||
public IntPtr ReadVTableFunction(IntPtr vTableAddress, int methodIndex)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(methodIndex);
|
||||
|
||||
int pointerSize = _memory.Is64Bit ? 8 : 4;
|
||||
IntPtr slotAddress = vTableAddress + (methodIndex * pointerSize);
|
||||
return _memory.Read<IntPtr>(slotAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience helper that reads an object's vtable and returns the function
|
||||
/// address at the requested method index.
|
||||
/// </summary>
|
||||
public IntPtr GetObjectVTableFunction(IntPtr objectAddress, int methodIndex)
|
||||
{
|
||||
IntPtr vTable = ReadVTable(objectAddress);
|
||||
return ReadVTableFunction(vTable, methodIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using WhiteMagic.Hooking;
|
||||
|
||||
namespace WhiteMagic.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// A crash-safe work queue drained on the target's own thread via a detour on a
|
||||
/// per-frame function. Callers queue work and receive the result (or exception)
|
||||
/// on their own thread through a completion handle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pump assumes the frame function is parameterless and returns an <see cref="int"/>.
|
||||
/// This matches common per-frame functions such as D3D9 <c>EndScene</c>.
|
||||
/// </remarks>
|
||||
public sealed class MainThreadPump : IDisposable
|
||||
{
|
||||
private readonly DetourManager _detours;
|
||||
private readonly IntPtr _frameAddress;
|
||||
private readonly ConcurrentQueue<WorkItem> _queue = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
private Detour? _detour;
|
||||
private bool _installed;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pump that will hook the frame function at <paramref name="frameAddress"/>.
|
||||
/// </summary>
|
||||
public MainThreadPump(DetourManager detours, IntPtr frameAddress)
|
||||
{
|
||||
_detours = detours;
|
||||
_frameAddress = frameAddress;
|
||||
}
|
||||
|
||||
/// <summary>Returns <see langword="true"/> after the frame hook has been applied.</summary>
|
||||
public bool IsInstalled => _installed;
|
||||
|
||||
/// <summary>Installs the frame-function detour.</summary>
|
||||
public void Install()
|
||||
{
|
||||
if (_installed)
|
||||
return;
|
||||
|
||||
_detour = _detours.Create("MainThreadPump", _frameAddress, (FrameDelegate)PumpHook);
|
||||
_detour.Apply();
|
||||
_installed = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues work to run on the hooked thread and blocks until it completes.
|
||||
/// </summary>
|
||||
public TResult Execute<TResult>(Func<TResult> work)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<object?>();
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
ThrowDisposed();
|
||||
if (!_installed)
|
||||
throw new InvalidOperationException("The main-thread pump is not installed. Call Install() first.");
|
||||
|
||||
_queue.Enqueue(new WorkItem(() => work()!, tcs));
|
||||
}
|
||||
|
||||
object? result = tcs.Task.GetAwaiter().GetResult();
|
||||
return (TResult)result!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues work to run on the hooked thread and returns a <see cref="Task{TResult}"/>.
|
||||
/// </summary>
|
||||
public Task<TResult> ExecuteAsync<TResult>(Func<TResult> work)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<TResult>();
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
ThrowDisposed();
|
||||
if (!_installed)
|
||||
throw new InvalidOperationException("The main-thread pump is not installed. Call Install() first.");
|
||||
|
||||
object? Box() => work()!;
|
||||
_queue.Enqueue(new WorkItem(Box, r => tcs.TrySetResult((TResult)r!), ex => tcs.TrySetException(ex)));
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>Removes the frame-function detour if it is installed.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
if (_installed && _detour is not null)
|
||||
_detour.Remove();
|
||||
_installed = false;
|
||||
}
|
||||
|
||||
// Fault any caller still blocked on queued work so Execute cannot hang forever.
|
||||
while (_queue.TryDequeue(out WorkItem? item))
|
||||
{
|
||||
item.SetException(new ObjectDisposedException(nameof(MainThreadPump)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowDisposed()
|
||||
=> throw new ObjectDisposedException(nameof(MainThreadPump));
|
||||
|
||||
private int PumpHook()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (_queue.TryDequeue(out WorkItem? item))
|
||||
{
|
||||
try
|
||||
{
|
||||
object? result = item.Work();
|
||||
item.SetResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
item.SetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
// Call the original frame function so rendering/game logic continues.
|
||||
return _detour is null ? 0 : (int?)_detour.CallOriginal() ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never let an exception escape back into the native frame caller.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int FrameDelegate();
|
||||
|
||||
private sealed class WorkItem
|
||||
{
|
||||
private readonly Action<object?>? _setResult;
|
||||
private readonly Action<Exception>? _setException;
|
||||
|
||||
public WorkItem(Func<object?> work, Action<object?> setResult, Action<Exception> setException)
|
||||
{
|
||||
Work = work;
|
||||
_setResult = setResult;
|
||||
_setException = setException;
|
||||
}
|
||||
|
||||
public WorkItem(Func<object?> work, TaskCompletionSource<object?> tcs)
|
||||
{
|
||||
Work = work;
|
||||
_setResult = r => tcs.TrySetResult(r);
|
||||
_setException = ex => tcs.TrySetException(ex);
|
||||
}
|
||||
|
||||
public Func<object?> Work { get; }
|
||||
|
||||
public void SetResult(object? result)
|
||||
{
|
||||
try
|
||||
{
|
||||
_setResult?.Invoke(result);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Already completed, e.g. concurrent Dispose/PumpHook race.
|
||||
}
|
||||
}
|
||||
|
||||
public void SetException(Exception exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
_setException?.Invoke(exception);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Already completed, e.g. concurrent Dispose/PumpHook race.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes a function in the target process by creating a remote thread at a
|
||||
/// calling-convention-aware stub. Waits for the thread to finish and returns the
|
||||
/// typed exit value read from the thread's exit code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>This executor is safe only for thread-agnostic payloads. Calls that touch
|
||||
/// single-threaded process state should use <see cref="MainThreadPump"/> instead.</para>
|
||||
/// <para>String arguments are encoded as null-terminated UTF-8 and allocated in the
|
||||
/// remote process; struct arguments are serialized with the default interop marshaler
|
||||
/// (<see cref="Marshal.StructureToPtr"/>) and allocated with <see cref="Marshal.SizeOf(Type)"/>
|
||||
/// bytes. All temporary remote allocations are released after the call, including on failure.</para>
|
||||
/// </remarks>
|
||||
public sealed class RemoteThreadExecutor
|
||||
{
|
||||
private const uint WaitObject0 = 0x00000000;
|
||||
private const uint WaitTimeout = 0x00000102;
|
||||
private const uint WaitFailed = 0xFFFFFFFF;
|
||||
|
||||
private const nuint AllocationGranularity = 0x10000; // 64 KB
|
||||
private const int NearAllocationAttempts = 64;
|
||||
|
||||
private readonly MemoryBase _reader;
|
||||
private readonly StubAssembler _assembler;
|
||||
|
||||
/// <summary>
|
||||
/// Internal hook for tests that need to place the generated call stub inside an
|
||||
/// already-allocated executable region (for example, immediately after the target
|
||||
/// payload to keep the relative CALL within ±2 GiB).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When this delegate returns a non-zero pointer, the executor does not take
|
||||
/// ownership of that memory and will not free it.
|
||||
/// </remarks>
|
||||
internal Func<IntPtr, int, IntPtr>? StubAllocator { get; set; }
|
||||
|
||||
/// <summary>Test seam: overrides remote scratch allocation for string/struct args.
|
||||
/// Defaults to <see cref="NativeMethods.VirtualAllocEx"/>.</summary>
|
||||
internal Func<int, IntPtr>? RemoteAllocator { get; set; }
|
||||
|
||||
/// <summary>Test seam: overrides remote scratch release. Defaults to
|
||||
/// <see cref="NativeMethods.VirtualFreeEx"/>.</summary>
|
||||
internal Action<IntPtr>? RemoteReleaser { get; set; }
|
||||
|
||||
private IntPtr AllocateScratch(int size)
|
||||
{
|
||||
if (RemoteAllocator is not null)
|
||||
return RemoteAllocator(size);
|
||||
|
||||
return NativeMethods.VirtualAllocEx(
|
||||
_reader.Handle,
|
||||
IntPtr.Zero,
|
||||
size,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ReadWrite);
|
||||
}
|
||||
|
||||
private void ReleaseScratch(IntPtr address)
|
||||
{
|
||||
if (RemoteReleaser is not null)
|
||||
{
|
||||
RemoteReleaser(address);
|
||||
return;
|
||||
}
|
||||
|
||||
NativeMethods.VirtualFreeEx(_reader.Handle, address, 0, MemoryFreeType.Release);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="RemoteThreadExecutor"/> for the process exposed by
|
||||
/// <paramref name="reader"/>.
|
||||
/// </summary>
|
||||
/// <param name="reader">The memory reader that owns the target process handle.</param>
|
||||
public RemoteThreadExecutor(MemoryBase reader)
|
||||
{
|
||||
_reader = reader ?? throw new ArgumentNullException(nameof(reader));
|
||||
_assembler = new StubAssembler();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls the function at <paramref name="address"/> in the target process using a
|
||||
/// remote thread and returns its exit value cast to <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The expected return type.</typeparam>
|
||||
/// <param name="address">The target function address.</param>
|
||||
/// <param name="convention">The calling convention (ignored on x64 targets).</param>
|
||||
/// <param name="args">Arguments to pass. Primitives, pointers and enums are packed
|
||||
/// into pointer-sized slots. Strings and structs are allocated remotely and passed
|
||||
/// by pointer.</param>
|
||||
/// <returns>The function's exit value converted to <typeparamref name="T"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">The process handle is not open or a
|
||||
/// required native operation failed.</exception>
|
||||
/// <exception cref="TimeoutException">The remote thread did not complete in time.</exception>
|
||||
public Task<T> ExecuteAsync<T>(IntPtr address, CallConvention convention, params object?[] args)
|
||||
{
|
||||
return Task.Run(() => Execute<T>(address, convention, args));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous variant of <see cref="ExecuteAsync{T}"/>.
|
||||
/// </summary>
|
||||
public T Execute<T>(IntPtr address, CallConvention convention, params object?[] args)
|
||||
{
|
||||
if (_reader.Handle.IsInvalid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot execute a remote function: the target process handle is not open.");
|
||||
}
|
||||
|
||||
if (address == IntPtr.Zero)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Target function address cannot be zero.", nameof(address));
|
||||
}
|
||||
|
||||
int pointerSize = _reader.Is64Bit ? 8 : 4;
|
||||
var allocations = new List<IntPtr>(args.Length + 1);
|
||||
IntPtr stubAddress = IntPtr.Zero;
|
||||
SafeMemoryHandle? thread = null;
|
||||
|
||||
try
|
||||
{
|
||||
nuint[] nativeArgs = MarshalArguments(args, pointerSize, allocations);
|
||||
|
||||
// Compute the exact stub size with a dummy address close to the target;
|
||||
// the emitted byte count does not depend on the stub's final address.
|
||||
byte[] stubBytes = _assembler.BuildCallStub(
|
||||
address, address, nativeArgs, pointerSize, convention);
|
||||
|
||||
bool stubOwnedByExecutor = true;
|
||||
if (StubAllocator != null)
|
||||
{
|
||||
stubAddress = StubAllocator(address, stubBytes.Length);
|
||||
// A caller-provided stub region is owned by the caller; never free it.
|
||||
if (stubAddress != IntPtr.Zero)
|
||||
stubOwnedByExecutor = false;
|
||||
}
|
||||
|
||||
if (stubAddress == IntPtr.Zero)
|
||||
{
|
||||
stubAddress = AllocateExecutableMemory(_reader.Handle, address, stubBytes.Length);
|
||||
stubOwnedByExecutor = true;
|
||||
}
|
||||
|
||||
if (stubAddress == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to allocate remote stub memory: error {error}");
|
||||
}
|
||||
|
||||
if (stubOwnedByExecutor)
|
||||
{
|
||||
allocations.Add(stubAddress);
|
||||
}
|
||||
|
||||
// Re-emit with the real stub address so the relative call lands correctly.
|
||||
stubBytes = _assembler.BuildCallStub(
|
||||
stubAddress, address, nativeArgs, pointerSize, convention);
|
||||
|
||||
int written = _reader.WriteBytes(stubAddress, stubBytes);
|
||||
if (written != stubBytes.Length)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to write the call stub to the remote process (wrote {written} of {stubBytes.Length} bytes).");
|
||||
}
|
||||
|
||||
thread = NativeMethods.CreateRemoteThread(
|
||||
_reader.Handle,
|
||||
IntPtr.Zero,
|
||||
0,
|
||||
stubAddress,
|
||||
IntPtr.Zero,
|
||||
ThreadCreationFlags.RunImmediately,
|
||||
out _);
|
||||
|
||||
if (thread.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"CreateRemoteThread failed: error {error}");
|
||||
}
|
||||
|
||||
uint waitResult = NativeMethods.WaitForSingleObject(thread, uint.MaxValue);
|
||||
if (waitResult == WaitFailed)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"WaitForSingleObject failed: error {error}");
|
||||
}
|
||||
|
||||
if (waitResult == WaitTimeout)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
"The remote thread did not complete within the requested timeout.");
|
||||
}
|
||||
|
||||
if (waitResult != WaitObject0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unexpected wait status: 0x{waitResult:X}");
|
||||
}
|
||||
|
||||
if (!NativeMethods.GetExitCodeThread(thread, out uint exitCode))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"GetExitCodeThread failed: error {error}");
|
||||
}
|
||||
|
||||
return ConvertExitCode<T>(exitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dispose the thread handle explicitly so the safe handle releases it
|
||||
// before any virtual memory is freed.
|
||||
thread?.Dispose();
|
||||
|
||||
foreach (IntPtr alloc in allocations)
|
||||
{
|
||||
ReleaseScratch(alloc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the raw DWORD exit code into the requested return type.
|
||||
/// </summary>
|
||||
private static T ConvertExitCode<T>(uint exitCode)
|
||||
{
|
||||
Type target = typeof(T);
|
||||
|
||||
if (target == typeof(IntPtr) || target == typeof(nint))
|
||||
{
|
||||
return (T)(object)(IntPtr)(nint)exitCode;
|
||||
}
|
||||
|
||||
if (target == typeof(UIntPtr) || target == typeof(nuint))
|
||||
{
|
||||
return (T)(object)(UIntPtr)(nuint)exitCode;
|
||||
}
|
||||
|
||||
if (Nullable.GetUnderlyingType(target) is Type underlying)
|
||||
{
|
||||
return (T)Convert.ChangeType(exitCode, underlying, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return (T)Convert.ChangeType(exitCode, target, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals managed arguments into pointer-sized native argument slots. Allocates
|
||||
/// remote memory for strings and structs and records each allocation in
|
||||
/// <paramref name="allocations"/>.
|
||||
/// </summary>
|
||||
private nuint[] MarshalArguments(object?[] args, int pointerSize, List<IntPtr> allocations)
|
||||
{
|
||||
var nativeArgs = new nuint[args.Length];
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
object? arg = args[i];
|
||||
nativeArgs[i] = MarshalArgument(arg, pointerSize, allocations);
|
||||
}
|
||||
|
||||
return nativeArgs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals a single argument. Strings and structs become remote pointers; primitives,
|
||||
/// enums and pointer values are packed directly.
|
||||
/// </summary>
|
||||
private nuint MarshalArgument(object? arg, int pointerSize, List<IntPtr> allocations)
|
||||
{
|
||||
if (arg is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (arg is string s)
|
||||
{
|
||||
return MarshalString(s, allocations);
|
||||
}
|
||||
|
||||
Type type = arg.GetType();
|
||||
if (IsPrimitiveOrPointer(type))
|
||||
{
|
||||
return PackPrimitive(arg, pointerSize);
|
||||
}
|
||||
|
||||
if (type.IsValueType)
|
||||
{
|
||||
return MarshalStruct(arg, type, allocations);
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Unsupported argument type: {type.FullName}. Only primitives, pointers, enums, strings and structs are supported.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocates the UTF-8 encoding of a string in the target process and returns its
|
||||
/// remote address.
|
||||
/// </summary>
|
||||
private nuint MarshalString(string value, List<IntPtr> allocations)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(value);
|
||||
byte[] buffer = new byte[bytes.Length + 1];
|
||||
bytes.CopyTo(buffer, 0);
|
||||
buffer[^1] = 0;
|
||||
|
||||
IntPtr remote = AllocateScratch(buffer.Length);
|
||||
|
||||
if (remote == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to allocate remote string memory: error {error}");
|
||||
}
|
||||
|
||||
allocations.Add(remote);
|
||||
|
||||
int written = _reader.WriteBytes(remote, buffer);
|
||||
if (written != buffer.Length)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to write string bytes to the remote process (wrote {written} of {buffer.Length} bytes).");
|
||||
}
|
||||
|
||||
return (nuint)(nint)remote;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocates unmanaged space for a struct in the target process, writes its bytes with
|
||||
/// the default interop marshaler, and returns the remote address.
|
||||
/// </summary>
|
||||
private nuint MarshalStruct(object value, Type type, List<IntPtr> allocations)
|
||||
{
|
||||
int size;
|
||||
try
|
||||
{
|
||||
size = Marshal.SizeOf(type);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot marshal argument of type {type.FullName}: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[size];
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
|
||||
IntPtr remote = AllocateScratch(size);
|
||||
|
||||
if (remote == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to allocate remote struct memory: error {error}");
|
||||
}
|
||||
|
||||
allocations.Add(remote);
|
||||
|
||||
int written = _reader.WriteBytes(remote, buffer);
|
||||
if (written != size)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to write struct bytes to the remote process (wrote {written} of {size} bytes).");
|
||||
}
|
||||
|
||||
return (nuint)(nint)remote;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a type can be passed directly as a pointer-sized value.
|
||||
/// </summary>
|
||||
private static bool IsPrimitiveOrPointer(Type type)
|
||||
{
|
||||
if (type == typeof(IntPtr) || type == typeof(UIntPtr) ||
|
||||
type == typeof(nint) || type == typeof(nuint))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
TypeCode code = Type.GetTypeCode(type);
|
||||
|
||||
switch (code)
|
||||
{
|
||||
case TypeCode.Boolean:
|
||||
case TypeCode.Char:
|
||||
case TypeCode.SByte:
|
||||
case TypeCode.Byte:
|
||||
case TypeCode.Int16:
|
||||
case TypeCode.UInt16:
|
||||
case TypeCode.Int32:
|
||||
case TypeCode.UInt32:
|
||||
case TypeCode.Int64:
|
||||
case TypeCode.UInt64:
|
||||
return true;
|
||||
|
||||
case TypeCode.Object when type.IsEnum:
|
||||
case TypeCode.Object when type == typeof(IntPtr) || type == typeof(UIntPtr):
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs a primitive, enum or pointer value into a pointer-sized unsigned integer.
|
||||
/// Values are truncated to the target pointer width so x86 arguments receive their
|
||||
/// low 32 bits.
|
||||
/// </summary>
|
||||
private static nuint PackPrimitive(object value, int pointerSize)
|
||||
{
|
||||
Type type = value.GetType();
|
||||
ulong raw;
|
||||
|
||||
if (type == typeof(IntPtr) || type == typeof(nint))
|
||||
{
|
||||
raw = unchecked((ulong)(nint)value);
|
||||
}
|
||||
else if (type == typeof(UIntPtr) || type == typeof(nuint))
|
||||
{
|
||||
raw = (ulong)(UIntPtr)value;
|
||||
}
|
||||
else if (type.IsEnum)
|
||||
{
|
||||
raw = Convert.ToUInt64(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
raw = Convert.ToUInt64(value);
|
||||
}
|
||||
|
||||
if (pointerSize == 4)
|
||||
{
|
||||
raw &= uint.MaxValue;
|
||||
}
|
||||
|
||||
return unchecked((nuint)raw);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to allocate executable memory close to <paramref name="preferredAddress"/>
|
||||
/// so that the relative CALL instruction in the generated stub stays within its
|
||||
/// ±2 GiB range.
|
||||
/// </summary>
|
||||
private static IntPtr AllocateExecutableMemory(
|
||||
SafeMemoryHandle handle,
|
||||
IntPtr preferredAddress,
|
||||
nint size)
|
||||
{
|
||||
nuint preferred = (nuint)(nint)preferredAddress;
|
||||
nuint mask = AllocationGranularity - (nuint)1;
|
||||
nuint aligned = (preferred + AllocationGranularity - (nuint)1) & ~mask;
|
||||
|
||||
for (int i = 0; i < NearAllocationAttempts; i++)
|
||||
{
|
||||
nuint candidate;
|
||||
if (i == 0)
|
||||
{
|
||||
candidate = aligned;
|
||||
}
|
||||
else if ((i & 1) == 1)
|
||||
{
|
||||
candidate = aligned + (nuint)i * AllocationGranularity;
|
||||
}
|
||||
else
|
||||
{
|
||||
nuint offset = (nuint)i * AllocationGranularity;
|
||||
if (offset > aligned)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidate = aligned - offset;
|
||||
}
|
||||
|
||||
IntPtr result = NativeMethods.VirtualAllocEx(
|
||||
handle,
|
||||
(IntPtr)(nint)candidate,
|
||||
size,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
if (result != IntPtr.Zero)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return NativeMethods.VirtualAllocEx(
|
||||
handle,
|
||||
IntPtr.Zero,
|
||||
size,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using Process = System.Diagnostics.Process;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
@@ -13,6 +14,8 @@ public sealed class ExternalReader : MemoryBase
|
||||
{
|
||||
private readonly SafeMemoryHandle _handle;
|
||||
private readonly IntPtr _imageBase;
|
||||
private readonly bool _is64Bit;
|
||||
private readonly int _processId;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -31,16 +34,36 @@ public sealed class ExternalReader : MemoryBase
|
||||
/// <param name="process">The target process.</param>
|
||||
/// <param name="desiredAccess">The access rights to request. Defaults to
|
||||
/// <see cref="DefaultAccess"/>.</param>
|
||||
public ExternalReader(Process process, ProcessAccess desiredAccess = DefaultAccess)
|
||||
public ExternalReader(System.Diagnostics.Process process, ProcessAccess desiredAccess = DefaultAccess)
|
||||
{
|
||||
_handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id);
|
||||
_processId = process.Id;
|
||||
const ProcessAccess queryAccess = ProcessAccess.QueryInformation | ProcessAccess.QueryLimitedInformation;
|
||||
if ((desiredAccess & queryAccess) == 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"ExternalReader requires ProcessAccess.QueryInformation or ProcessAccess.QueryLimitedInformation to determine target bitness.",
|
||||
nameof(desiredAccess));
|
||||
}
|
||||
|
||||
_handle = NativeMethods.OpenProcess(desiredAccess, false, _processId);
|
||||
if (_handle.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"OpenProcess failed for PID {process.Id}: error {error}");
|
||||
$"OpenProcess failed for PID {_processId}: error {error}");
|
||||
}
|
||||
|
||||
// Derive target bitness. A 64-bit host sees a 32-bit target as WOW64.
|
||||
// A 32-bit host can only open 32-bit targets.
|
||||
if (!NativeMethods.IsWow64Process(_handle, out bool wow64))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"IsWow64Process failed for PID {_processId}: error {error}.");
|
||||
}
|
||||
|
||||
_is64Bit = Environment.Is64BitProcess && !wow64;
|
||||
|
||||
// Process.MainModule throws Win32Exception for a bitness-mismatched or protected
|
||||
// target. A missing image base must not sink the whole reader — callers can still
|
||||
// use absolute addresses when ImageBase is unknown.
|
||||
@@ -60,6 +83,12 @@ public sealed class ExternalReader : MemoryBase
|
||||
/// <inheritdoc />
|
||||
public override SafeMemoryHandle Handle => _handle;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Is64Bit => _is64Bit;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int ProcessId => _processId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
{
|
||||
@@ -84,7 +113,7 @@ public sealed class ExternalReader : MemoryBase
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_handle.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// A single reversible inline detour. Replaces the start of a native function
|
||||
/// with a jump to a managed hook delegate, preserves the overwritten bytes in a
|
||||
/// remote trampoline, and exposes the trampoline through <see cref="CallOriginal"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only supported in-process. The detour uses a 5-byte relative <c>jmp</c> on x86
|
||||
/// targets and a 14-byte RIP-relative absolute <c>jmp</c> on x64 targets.
|
||||
/// </remarks>
|
||||
public sealed class Detour : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly PrologueLengthResolver _prologueLength;
|
||||
|
||||
/// <summary>The unique name of this detour.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The target native function address.</summary>
|
||||
public IntPtr Target { get; }
|
||||
|
||||
/// <summary>The managed hook delegate that the detour invokes.</summary>
|
||||
public Delegate Hook { get; }
|
||||
|
||||
/// <summary>The bytes overwritten at <see cref="Target"/>.</summary>
|
||||
public byte[] OverwrittenBytes { get; private set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// The allocated trampoline that executes the original prologue and then jumps
|
||||
/// back into the original function.
|
||||
/// </summary>
|
||||
public IntPtr Trampoline { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// A delegate wrapping <see cref="Trampoline"/> with the same type as <see cref="Hook"/>.
|
||||
/// </summary>
|
||||
public Delegate? Original { get; private set; }
|
||||
|
||||
/// <summary><see langword="true"/> while the detour bytes are live at <see cref="Target"/>.</summary>
|
||||
public bool IsApplied { get; private set; }
|
||||
|
||||
internal Detour(
|
||||
MemoryBase memory,
|
||||
string name,
|
||||
IntPtr target,
|
||||
Delegate hook,
|
||||
PrologueLengthResolver prologueLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hook);
|
||||
ArgumentNullException.ThrowIfNull(prologueLength);
|
||||
|
||||
_memory = memory;
|
||||
Name = name;
|
||||
Target = target;
|
||||
Hook = hook;
|
||||
_prologueLength = prologueLength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the detour after validating that the required overwrite covers whole
|
||||
/// prologue instructions.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The prologue cannot be safely spliced.</exception>
|
||||
public void Apply()
|
||||
{
|
||||
if (IsApplied)
|
||||
return;
|
||||
|
||||
int pointerSize = _memory.Is64Bit ? 8 : 4;
|
||||
int detourLength = pointerSize == 8 ? 14 : 5;
|
||||
|
||||
// The prologue decoder may need to see bytes past the minimum detour length
|
||||
// to identify the whole instruction that crosses the splice point. Prefer a
|
||||
// generous read, but if the target sits near an unmapped page boundary, read
|
||||
// only up to that boundary so ReadProcessMemory does not fail entirely.
|
||||
int preferredBuffer = detourLength + 16;
|
||||
int pageSize = Environment.SystemPageSize;
|
||||
int pageOffset = (int)(Target.ToInt64() & (pageSize - 1));
|
||||
int bytesToPageBoundary = pageSize - pageOffset;
|
||||
int readSize = Math.Min(preferredBuffer, bytesToPageBoundary);
|
||||
|
||||
byte[] prologue = _memory.ReadBytes(Target, readSize);
|
||||
if (prologue.Length < detourLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not read enough bytes from the target function to install a detour.");
|
||||
}
|
||||
|
||||
int preserveLength = _prologueLength(prologue, detourLength, _memory.Is64Bit);
|
||||
OverwrittenBytes = new byte[preserveLength];
|
||||
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
|
||||
|
||||
IntPtr hookAddress = Marshal.GetFunctionPointerForDelegate(Hook);
|
||||
byte[] hookJump = pointerSize == 8
|
||||
? BuildAbsoluteJump(hookAddress)
|
||||
: BuildRelativeJump(Target, hookAddress);
|
||||
|
||||
// Allocate and build the trampoline before touching the target.
|
||||
int returnJumpSize = pointerSize == 8 ? 14 : 5;
|
||||
int trampolineSize = preserveLength + returnJumpSize;
|
||||
IntPtr trampoline = NativeMethods.VirtualAllocEx(
|
||||
_memory.Handle,
|
||||
IntPtr.Zero,
|
||||
trampolineSize,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
if (trampoline == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to allocate detour trampoline: error {error}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var trampolineBytes = new byte[trampolineSize];
|
||||
OverwrittenBytes.CopyTo(trampolineBytes, 0);
|
||||
|
||||
byte[] returnJump = pointerSize == 8
|
||||
? BuildAbsoluteJump(Target + preserveLength)
|
||||
: BuildRelativeJump(trampoline + preserveLength, Target + preserveLength);
|
||||
|
||||
returnJump.CopyTo(trampolineBytes, preserveLength);
|
||||
|
||||
int written = _memory.WriteBytes(trampoline, trampolineBytes);
|
||||
if (written != trampolineSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Failed to write the detour trampoline into the target process.");
|
||||
}
|
||||
|
||||
// Make the target page writable if necessary, then write the detour jump,
|
||||
// restoring the original protection regardless of success or failure.
|
||||
if (!NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Target,
|
||||
preserveLength,
|
||||
MemoryProtectionType.ExecuteReadWrite,
|
||||
out MemoryProtectionType oldProtect))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to change target memory protection: error {error}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
written = _memory.WriteBytes(Target, hookJump);
|
||||
if (written != hookJump.Length)
|
||||
throw new InvalidOperationException("Failed to write detour jump to target.");
|
||||
|
||||
Trampoline = trampoline;
|
||||
Original = Marshal.GetDelegateForFunctionPointer(Trampoline, Hook.GetType());
|
||||
IsApplied = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Target,
|
||||
preserveLength,
|
||||
oldProtect,
|
||||
out _);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(
|
||||
_memory.Handle,
|
||||
trampoline,
|
||||
0,
|
||||
MemoryFreeType.Release);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the original bytes and releases the trampoline.</summary>
|
||||
public void Remove()
|
||||
{
|
||||
if (!IsApplied)
|
||||
return;
|
||||
|
||||
if (OverwrittenBytes.Length > 0 && Target != IntPtr.Zero)
|
||||
{
|
||||
NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Target,
|
||||
OverwrittenBytes.Length,
|
||||
MemoryProtectionType.ExecuteReadWrite,
|
||||
out MemoryProtectionType oldProtect);
|
||||
|
||||
try
|
||||
{
|
||||
_memory.WriteBytes(Target, OverwrittenBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Target,
|
||||
OverwrittenBytes.Length,
|
||||
oldProtect,
|
||||
out _);
|
||||
}
|
||||
}
|
||||
|
||||
if (Trampoline != IntPtr.Zero)
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(
|
||||
_memory.Handle,
|
||||
Trampoline,
|
||||
0,
|
||||
MemoryFreeType.Release);
|
||||
}
|
||||
|
||||
Trampoline = IntPtr.Zero;
|
||||
Original = null;
|
||||
OverwrittenBytes = Array.Empty<byte>();
|
||||
IsApplied = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the original function through the trampoline. Pass the same arguments
|
||||
/// that the native signature expects; the return value is boxed.
|
||||
/// </summary>
|
||||
public object? CallOriginal(params object?[] args)
|
||||
{
|
||||
if (Original is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The detour is not applied; there is no original trampoline to call.");
|
||||
}
|
||||
|
||||
return Original.DynamicInvoke(args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Remove();
|
||||
}
|
||||
|
||||
private static byte[] BuildRelativeJump(IntPtr source, IntPtr destination)
|
||||
{
|
||||
byte[] bytes = new byte[5];
|
||||
bytes[0] = 0xE9;
|
||||
|
||||
long distance = (long)destination - ((long)source + 5);
|
||||
if (distance < int.MinValue || distance > int.MaxValue)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(destination),
|
||||
"Relative jump distance exceeds the 2 GiB range of an E8/E9 encoding.");
|
||||
}
|
||||
|
||||
uint rel = (uint)distance;
|
||||
bytes[1] = (byte)rel;
|
||||
bytes[2] = (byte)(rel >> 8);
|
||||
bytes[3] = (byte)(rel >> 16);
|
||||
bytes[4] = (byte)(rel >> 24);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] BuildAbsoluteJump(IntPtr destination)
|
||||
{
|
||||
// jmp [rip+0] followed by the absolute target address.
|
||||
byte[] bytes = new byte[14];
|
||||
bytes[0] = 0xFF;
|
||||
bytes[1] = 0x25;
|
||||
bytes[2] = 0x00;
|
||||
bytes[3] = 0x00;
|
||||
bytes[4] = 0x00;
|
||||
bytes[5] = 0x00;
|
||||
|
||||
long addr = (long)destination;
|
||||
bytes[6] = (byte)addr;
|
||||
bytes[7] = (byte)(addr >> 8);
|
||||
bytes[8] = (byte)(addr >> 16);
|
||||
bytes[9] = (byte)(addr >> 24);
|
||||
bytes[10] = (byte)(addr >> 32);
|
||||
bytes[11] = (byte)(addr >> 40);
|
||||
bytes[12] = (byte)(addr >> 48);
|
||||
bytes[13] = (byte)(addr >> 56);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Manages named inline detours against a <see cref="MemoryBase"/>.
|
||||
/// Detours only work when operating in-process; applying a detour to an
|
||||
/// external target will fail because the hook delegate lives in the host process.
|
||||
/// </summary>
|
||||
public sealed class DetourManager
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly Dictionary<string, Detour> _detours = new();
|
||||
|
||||
/// <summary>Creates a detour manager bound to the supplied memory reader.</summary>
|
||||
public DetourManager(MemoryBase memory)
|
||||
{
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves how many whole prologue-instruction bytes a splice must preserve. Defaults
|
||||
/// to the built-in <see cref="PrologueDecoder"/>, which covers only the common prologue
|
||||
/// shapes and rejects anything else. Assign <c>new IcedAssembler().GetPrologueLength</c>
|
||||
/// to validate arbitrary prologues via the optional Iced disassembler.
|
||||
/// </summary>
|
||||
public PrologueLengthResolver PrologueLengthResolver { get; set; } =
|
||||
PrologueDecoder.GetWholeInstructionLength;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new detour and registers it with the manager.
|
||||
/// The <paramref name="hook"/> delegate's type must match the native signature of
|
||||
/// <paramref name="target"/>.
|
||||
/// </summary>
|
||||
public Detour Create(string name, IntPtr target, Delegate hook)
|
||||
{
|
||||
var detour = new Detour(_memory, name, target, hook, PrologueLengthResolver);
|
||||
_detours[name] = detour;
|
||||
return detour;
|
||||
}
|
||||
|
||||
/// <summary>Looks up a detour by name.</summary>
|
||||
public Detour? this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
_detours.TryGetValue(name, out Detour? detour);
|
||||
return detour;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All detours registered in this manager.</summary>
|
||||
public IEnumerable<Detour> All => _detours.Values;
|
||||
|
||||
/// <summary>Removes every applied detour, restoring original bytes.</summary>
|
||||
public void RemoveAll()
|
||||
{
|
||||
foreach (Detour detour in _detours.Values)
|
||||
{
|
||||
detour.Remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// A single reversible byte patch. Captures the original bytes when applied,
|
||||
/// restores them when removed, and reports its state by comparing live memory.
|
||||
/// </summary>
|
||||
public sealed class Patch : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
|
||||
/// <summary>The unique name of this patch.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The address the patch overwrites.</summary>
|
||||
public IntPtr Address { get; }
|
||||
|
||||
/// <summary>The bytes written by the patch.</summary>
|
||||
public byte[] PatchBytes { get; }
|
||||
|
||||
/// <summary>The bytes captured before the patch was applied.</summary>
|
||||
public byte[]? OriginalBytes { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when the live bytes at <see cref="Address"/> match
|
||||
/// <see cref="PatchBytes"/>.
|
||||
/// </summary>
|
||||
public bool IsApplied
|
||||
{
|
||||
get
|
||||
{
|
||||
byte[] current = _memory.ReadBytes(Address, PatchBytes.Length);
|
||||
return current.SequenceEqual(PatchBytes);
|
||||
}
|
||||
}
|
||||
|
||||
internal Patch(MemoryBase memory, string name, IntPtr address, byte[] patchBytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(patchBytes);
|
||||
|
||||
_memory = memory;
|
||||
Name = name;
|
||||
Address = address;
|
||||
PatchBytes = patchBytes;
|
||||
}
|
||||
|
||||
/// <summary>Captures the original bytes and writes the patch bytes.</summary>
|
||||
public void Apply()
|
||||
{
|
||||
if (IsApplied)
|
||||
return;
|
||||
|
||||
OriginalBytes = _memory.ReadBytes(Address, PatchBytes.Length);
|
||||
|
||||
if (!NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Address,
|
||||
PatchBytes.Length,
|
||||
MemoryProtectionType.ExecuteReadWrite,
|
||||
out MemoryProtectionType oldProtect))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"Failed to change target memory protection: error {error}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_memory.WriteBytes(Address, PatchBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Address,
|
||||
PatchBytes.Length,
|
||||
oldProtect,
|
||||
out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the original bytes if they were captured.</summary>
|
||||
public void Remove()
|
||||
{
|
||||
if (OriginalBytes is null)
|
||||
return;
|
||||
|
||||
if (!NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Address,
|
||||
OriginalBytes.Length,
|
||||
MemoryProtectionType.ExecuteReadWrite,
|
||||
out MemoryProtectionType oldProtect))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"Failed to change target memory protection: error {error}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_memory.WriteBytes(Address, OriginalBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualProtectEx(
|
||||
_memory.Handle,
|
||||
Address,
|
||||
OriginalBytes.Length,
|
||||
oldProtect,
|
||||
out _);
|
||||
}
|
||||
|
||||
OriginalBytes = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Manages named, reversible byte patches against a <see cref="MemoryBase"/>.
|
||||
/// Every patch records the bytes it replaced and can restore them later.
|
||||
/// </summary>
|
||||
public sealed class PatchManager
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly Dictionary<string, Patch> _patches = new();
|
||||
|
||||
/// <summary>Creates a patch manager bound to the supplied memory reader.</summary>
|
||||
public PatchManager(MemoryBase memory)
|
||||
{
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new patch and registers it with the manager.</summary>
|
||||
public Patch Create(string name, IntPtr address, byte[] patchBytes)
|
||||
{
|
||||
var patch = new Patch(_memory, name, address, patchBytes);
|
||||
_patches[name] = patch;
|
||||
return patch;
|
||||
}
|
||||
|
||||
/// <summary>Looks up a patch by name.</summary>
|
||||
public Patch? this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
_patches.TryGetValue(name, out Patch? patch);
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All patches registered in this manager.</summary>
|
||||
public IEnumerable<Patch> All => _patches.Values;
|
||||
|
||||
/// <summary>Removes every applied patch.</summary>
|
||||
public void RestoreAll()
|
||||
{
|
||||
foreach (Patch patch in _patches.Values)
|
||||
{
|
||||
patch.Remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
|
||||
namespace WhiteMagic.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves how many whole prologue-instruction bytes must be preserved to splice
|
||||
/// <paramref name="requiredBytes"/> bytes at a target. The built-in
|
||||
/// <see cref="PrologueDecoder.GetWholeInstructionLength"/> satisfies this delegate, as
|
||||
/// does <c>IcedAssembler.GetPrologueLength</c> for full instruction coverage.
|
||||
/// </summary>
|
||||
public delegate int PrologueLengthResolver(byte[] prologue, int requiredBytes, bool is64Bit);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
|
||||
/// The set is intentionally small: any opcode outside the covered set is rejected
|
||||
/// rather than guessed. Full arbitrary-prologue validation is provided by the
|
||||
/// optional Iced backend (Phase 8).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Covered shapes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>push reg</c>: 0x50-0x57 (1 byte), including REX-prefixed forms.</item>
|
||||
/// <item><c>push ebp/rbp</c>: 0x55 (1 byte).</item>
|
||||
/// <item><c>mov edi, edi</c>: 8B FF (2 bytes).</item>
|
||||
/// <item><c>mov ebp/rbp, esp/rsp</c>: 8B EC / 48 8B EC (2/3 bytes).</item>
|
||||
/// <item><c>sub esp/rsp, imm8</c>: 83 EC imm8 / 48 83 EC imm8 (3/4 bytes).</item>
|
||||
/// <item><c>sub esp/rsp, imm32</c>: 81 EC imm32 / 48 81 EC imm32 (6/7 bytes).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class PrologueDecoder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the length of the first instruction in <paramref name="bytes"/>
|
||||
/// if it matches a covered shape; otherwise returns -1.
|
||||
/// </summary>
|
||||
public static int GetInstructionLength(ReadOnlySpan<byte> bytes, bool is64Bit)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
return 0;
|
||||
|
||||
int i = 0;
|
||||
if (is64Bit && bytes[i] >= 0x40 && bytes[i] <= 0x4F)
|
||||
{
|
||||
// REX prefix.
|
||||
i++;
|
||||
if (bytes.Length <= i)
|
||||
return -1;
|
||||
}
|
||||
|
||||
byte op = bytes[i];
|
||||
|
||||
// push reg (0x50-0x57), including rbp (0x55).
|
||||
if ((op & 0xF8) == 0x50)
|
||||
return i + 1;
|
||||
|
||||
// mov r32/64, r/m32/64. Recognize only the specific forms listed above.
|
||||
if (op == 0x8B && bytes.Length > i + 1)
|
||||
{
|
||||
byte modrm = bytes[i + 1];
|
||||
if (modrm == 0xFF || modrm == 0xEC)
|
||||
return i + 2;
|
||||
}
|
||||
|
||||
// sub esp/rsp, imm8 — register-direct ModRM 0xEC only.
|
||||
if (op == 0x83 && bytes.Length > i + 2 && bytes[i + 1] == 0xEC)
|
||||
return i + 3;
|
||||
|
||||
// sub esp/rsp, imm32 — register-direct ModRM 0xEC only.
|
||||
if (op == 0x81 && bytes.Length > i + 5 && bytes[i + 1] == 0xEC)
|
||||
return i + 6;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks prologue instructions until at least <paramref name="requiredBytes"/>
|
||||
/// have been covered, returning the total length of whole instructions that must
|
||||
/// be preserved in the trampoline.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">An opcode is outside the covered set.</exception>
|
||||
public static int GetWholeInstructionLength(byte[] prologue, int requiredBytes, bool is64Bit)
|
||||
{
|
||||
int total = 0;
|
||||
while (total < requiredBytes)
|
||||
{
|
||||
int len = GetInstructionLength(prologue.AsSpan(total), is64Bit);
|
||||
if (len <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The target prologue contains an instruction outside the covered opcode set. " +
|
||||
"Install the optional Iced backend for full instruction-boundary validation.");
|
||||
}
|
||||
|
||||
total += len;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public sealed class InProcessReader : MemoryBase
|
||||
{
|
||||
private readonly SafeMemoryHandle _handle;
|
||||
private readonly IntPtr _imageBase;
|
||||
private readonly int _processId;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -31,8 +32,10 @@ public sealed class InProcessReader : MemoryBase
|
||||
public InProcessReader()
|
||||
{
|
||||
Process current = Process.GetCurrentProcess();
|
||||
_processId = current.Id;
|
||||
_handle = NativeMethods.OpenProcess(
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation,
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
|
||||
| ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize,
|
||||
false,
|
||||
current.Id);
|
||||
if (_handle.IsInvalid)
|
||||
@@ -60,6 +63,12 @@ public sealed class InProcessReader : MemoryBase
|
||||
/// <inheritdoc />
|
||||
public override SafeMemoryHandle Handle => _handle;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Is64Bit => Environment.Is64BitProcess;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int ProcessId => _processId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
{
|
||||
@@ -84,7 +93,7 @@ public sealed class InProcessReader : MemoryBase
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_handle.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Memory;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Injects raw machine code into a process's memory.
|
||||
/// </summary>
|
||||
public static class CodeInjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Injects code at a specific address.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="address">The target address.</param>
|
||||
/// <param name="code">The machine code bytes to write.</param>
|
||||
/// <returns>The address the code was written to (same as <paramref name="address"/>).</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="code"/> is empty.</exception>
|
||||
/// <exception cref="Win32Exception">Write fails.</exception>
|
||||
public static IntPtr InjectAtAddress(MemoryBase memory, IntPtr address, byte[] code)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
ArgumentNullException.ThrowIfNull(code);
|
||||
|
||||
if (code.Length == 0)
|
||||
throw new ArgumentException("Code cannot be empty.", nameof(code));
|
||||
|
||||
if (address == IntPtr.Zero)
|
||||
throw new ArgumentException("Address cannot be zero.", nameof(address));
|
||||
|
||||
// Write the code to the target address
|
||||
int written = memory.WriteBytes(address, code);
|
||||
if (written != code.Length)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new Win32Exception(error,
|
||||
$"WriteProcessMemory failed at {address} (wrote {written} of {code.Length} bytes).");
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocates executable memory and injects code into it.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="code">The machine code bytes to inject.</param>
|
||||
/// <param name="protection">
|
||||
/// The memory protection. Defaults to <see cref="MemoryProtectionType.ExecuteReadWrite"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The base address of the allocated memory containing the code.
|
||||
/// The caller is responsible for freeing this memory (e.g., via <see cref="AllocatedMemory.Dispose"/>).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="code"/> is empty.</exception>
|
||||
/// <exception cref="Win32Exception">Allocation or write fails.</exception>
|
||||
public static AllocatedMemory Inject(
|
||||
MemoryBase memory,
|
||||
byte[] code,
|
||||
MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
ArgumentNullException.ThrowIfNull(code);
|
||||
|
||||
if (code.Length == 0)
|
||||
throw new ArgumentException("Code cannot be empty.", nameof(code));
|
||||
|
||||
// Allocate memory with the specified protection
|
||||
var allocated = new AllocatedMemory(memory, code.Length, protection);
|
||||
|
||||
// Write the code to the allocated memory
|
||||
int written = memory.WriteBytes(allocated.BaseAddress, code);
|
||||
if (written != code.Length)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
allocated.Dispose();
|
||||
throw new Win32Exception(error,
|
||||
$"WriteProcessMemory failed (wrote {written} of {code.Length} bytes).");
|
||||
}
|
||||
|
||||
return allocated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Injects DLLs into an open target process by creating a remote thread or by
|
||||
/// hijacking an existing thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The DLL path is sent to <c>LoadLibraryW</c>, so it is encoded as a null-terminated
|
||||
/// UTF-16 string in the target process.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Injection requires the target process to have the same bitness as the current
|
||||
/// process, because the emitted x86/x64 stubs and the captured thread context must
|
||||
/// match the target architecture.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DllInjector
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly bool _currentIs64Bit;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="DllInjector"/> for the target represented by
|
||||
/// <paramref name="memory"/>.
|
||||
/// </summary>
|
||||
/// <param name="memory">A reader/writer for the target process.</param>
|
||||
public DllInjector(MemoryBase memory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
_memory = memory;
|
||||
_currentIs64Bit = Environment.Is64BitProcess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="MemoryBase"/> the injector is operating on.
|
||||
/// </summary>
|
||||
public MemoryBase Memory => _memory;
|
||||
|
||||
/// <summary>
|
||||
/// Injects a DLL into the target process by creating a remote thread that loads it.
|
||||
/// </summary>
|
||||
/// <param name="dllPath">The path to the DLL. The file must exist.</param>
|
||||
/// <returns>The base address of the loaded module in the target process.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="dllPath"/> is null or empty.</exception>
|
||||
/// <exception cref="FileNotFoundException"><paramref name="dllPath"/> does not exist.</exception>
|
||||
/// <exception cref="InvalidOperationException">The target bitness does not match the caller.</exception>
|
||||
/// <exception cref="InvalidOperationException">The remote load failed or timed out.</exception>
|
||||
public IntPtr InjectWithRemoteThread(string dllPath)
|
||||
{
|
||||
ValidateAndCheckBitness(dllPath);
|
||||
|
||||
IntPtr loadLibrary = ResolveLoadLibraryW();
|
||||
byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
|
||||
|
||||
int pointerSize = _currentIs64Bit ? 8 : 4;
|
||||
int stubSize = _currentIs64Bit ? 39 : 18;
|
||||
int pathOffset = Align(stubSize, pointerSize);
|
||||
int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
|
||||
int totalSize = resultOffset + pointerSize + 4096;
|
||||
|
||||
IntPtr remoteBase = NativeMethods.VirtualAllocEx(
|
||||
_memory.Handle,
|
||||
IntPtr.Zero,
|
||||
totalSize,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
if (remoteBase == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IntPtr pathAddress = remoteBase + pathOffset;
|
||||
IntPtr resultAddress = remoteBase + resultOffset;
|
||||
|
||||
if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
|
||||
throw new InvalidOperationException("Failed to write the DLL path into the target process.");
|
||||
|
||||
byte[] stub = _currentIs64Bit
|
||||
? BuildRemoteThreadStubX64(pathAddress, resultAddress, loadLibrary)
|
||||
: BuildRemoteThreadStubX86(pathAddress, resultAddress, loadLibrary);
|
||||
|
||||
if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
|
||||
throw new InvalidOperationException("Failed to write the remote thread stub.");
|
||||
|
||||
using SafeMemoryHandle thread = NativeMethods.CreateRemoteThread(
|
||||
_memory.Handle,
|
||||
IntPtr.Zero,
|
||||
0,
|
||||
remoteBase,
|
||||
IntPtr.Zero,
|
||||
ThreadCreationFlags.RunImmediately,
|
||||
out _);
|
||||
|
||||
if (thread.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"CreateRemoteThread failed (error {error}).");
|
||||
}
|
||||
|
||||
const uint timeoutMs = 30000;
|
||||
uint wait = NativeMethods.WaitForSingleObject(thread, timeoutMs);
|
||||
if (wait == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"WaitForSingleObject failed (error {error}).");
|
||||
}
|
||||
|
||||
if (wait == 0x00000102)
|
||||
throw new InvalidOperationException("Remote thread timed out while loading the DLL.");
|
||||
|
||||
IntPtr result = _memory.Read<IntPtr>(resultAddress);
|
||||
if (result == IntPtr.Zero)
|
||||
throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The DLL is already loaded; the temporary stub, path and result slot can be released.
|
||||
NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects a DLL by hijacking an existing thread in the target process.
|
||||
/// </summary>
|
||||
/// <param name="threadId">The operating-system identifier of the thread to hijack.</param>
|
||||
/// <param name="dllPath">The path to the DLL. The file must exist.</param>
|
||||
/// <returns>The base address of the loaded module in the target process.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="threadId"/> is not a positive value or
|
||||
/// <paramref name="dllPath"/> is null or empty.</exception>
|
||||
/// <exception cref="FileNotFoundException"><paramref name="dllPath"/> does not exist.</exception>
|
||||
/// <exception cref="InvalidOperationException">The target bitness does not match the caller.</exception>
|
||||
/// <exception cref="InvalidOperationException">The hijack, load, or context restore failed.</exception>
|
||||
public IntPtr InjectWithThreadHijack(int threadId, string dllPath)
|
||||
{
|
||||
if (threadId <= 0)
|
||||
throw new ArgumentException("Thread ID must be a positive value.", nameof(threadId));
|
||||
|
||||
ValidateAndCheckBitness(dllPath);
|
||||
|
||||
IntPtr loadLibrary = ResolveLoadLibraryW();
|
||||
byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
|
||||
|
||||
int pointerSize = _currentIs64Bit ? 8 : 4;
|
||||
int stubSize = _currentIs64Bit ? 40 : 19;
|
||||
int pathOffset = Align(stubSize, pointerSize);
|
||||
int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
|
||||
int totalSize = resultOffset + pointerSize + 4096;
|
||||
|
||||
IntPtr remoteBase = NativeMethods.VirtualAllocEx(
|
||||
_memory.Handle,
|
||||
IntPtr.Zero,
|
||||
totalSize,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
if (remoteBase == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IntPtr pathAddress = remoteBase + pathOffset;
|
||||
IntPtr resultAddress = remoteBase + resultOffset;
|
||||
|
||||
if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
|
||||
throw new InvalidOperationException("Failed to write the DLL path into the target process.");
|
||||
|
||||
byte[] stub = _currentIs64Bit
|
||||
? BuildHijackStubX64(pathAddress, resultAddress, loadLibrary)
|
||||
: BuildHijackStubX86(pathAddress, resultAddress, loadLibrary);
|
||||
|
||||
if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
|
||||
throw new InvalidOperationException("Failed to write the hijack stub.");
|
||||
|
||||
nint stackTop = (nint)(remoteBase + totalSize);
|
||||
stackTop = AlignDown(stackTop, pointerSize);
|
||||
if (_currentIs64Bit)
|
||||
stackTop = AlignDown(stackTop, 16);
|
||||
|
||||
using SafeMemoryHandle thread = NativeMethods.OpenThread(
|
||||
ThreadAccess.SuspendResume | ThreadAccess.GetContext | ThreadAccess.SetContext | ThreadAccess.QueryInformation,
|
||||
false,
|
||||
threadId);
|
||||
|
||||
if (thread.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"OpenThread failed (error {error}).");
|
||||
}
|
||||
|
||||
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SuspendThread failed (error {error}).");
|
||||
}
|
||||
|
||||
bool restored = false;
|
||||
|
||||
try
|
||||
{
|
||||
IntPtr result;
|
||||
|
||||
if (_currentIs64Bit)
|
||||
{
|
||||
var originalContext = new Context64 { ContextFlags = ContextFlags.Amd64Full };
|
||||
if (!NativeMethods.GetThreadContext(thread, ref originalContext))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"GetThreadContext failed (error {error}).");
|
||||
}
|
||||
|
||||
var redirectContext = originalContext;
|
||||
redirectContext.Rip = (ulong)(nint)remoteBase;
|
||||
redirectContext.Rsp = (ulong)stackTop;
|
||||
|
||||
if (!NativeMethods.SetThreadContext(thread, ref redirectContext))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SetThreadContext failed (error {error}).");
|
||||
}
|
||||
|
||||
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"ResumeThread failed (error {error}).");
|
||||
}
|
||||
|
||||
result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
|
||||
}
|
||||
|
||||
if (!NativeMethods.SetThreadContext(thread, ref originalContext))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SetThreadContext restore failed (error {error}).");
|
||||
}
|
||||
|
||||
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
|
||||
}
|
||||
|
||||
restored = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 32-bit process targeting a 32-bit process. The target context is
|
||||
// a native x86 CONTEXT; the WOW64 APIs are for 64-bit callers only.
|
||||
var originalContext = new Context32 { ContextFlags = ContextFlags.X86Full };
|
||||
if (!NativeMethods.GetThreadContext(thread, ref originalContext))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"GetThreadContext failed (error {error}).");
|
||||
}
|
||||
|
||||
var redirectContext = originalContext;
|
||||
redirectContext.Eip = (uint)(nint)remoteBase;
|
||||
redirectContext.Esp = (uint)(nint)stackTop;
|
||||
|
||||
if (!NativeMethods.SetThreadContext(thread, ref redirectContext))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SetThreadContext failed (error {error}).");
|
||||
}
|
||||
|
||||
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"ResumeThread failed (error {error}).");
|
||||
}
|
||||
|
||||
result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
|
||||
}
|
||||
|
||||
if (!NativeMethods.SetThreadContext(thread, ref originalContext))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"SetThreadContext restore failed (error {error}).");
|
||||
}
|
||||
|
||||
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
|
||||
}
|
||||
|
||||
restored = true;
|
||||
}
|
||||
|
||||
if (result == IntPtr.Zero)
|
||||
throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If we never successfully restored the thread's original context, the
|
||||
// thread may still be executing (or about to execute) code inside the
|
||||
// injected allocation. Freeing that memory now would crash the target
|
||||
// process, so leak the block and leave the thread suspended.
|
||||
if (!restored)
|
||||
{
|
||||
remoteBase = IntPtr.Zero;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateAndCheckBitness(string dllPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dllPath))
|
||||
throw new ArgumentException("DLL path cannot be null or empty.", nameof(dllPath));
|
||||
|
||||
if (!File.Exists(dllPath))
|
||||
throw new FileNotFoundException("The specified DLL was not found.", dllPath);
|
||||
|
||||
if (_memory.Is64Bit != _currentIs64Bit)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The target process bitness does not match the current process bitness.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr ResolveLoadLibraryW()
|
||||
{
|
||||
// kernel32.dll is loaded at the same base address in every process at a given
|
||||
// bitness, so resolving the export in the current process gives the correct
|
||||
// target address for the remote process.
|
||||
IntPtr kernel32 = NativeMethods.LoadLibrary("kernel32.dll");
|
||||
if (kernel32 == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"Unable to obtain a handle to kernel32.dll (error {error}).");
|
||||
}
|
||||
|
||||
IntPtr loadLibrary = NativeMethods.GetProcAddress(kernel32, "LoadLibraryW");
|
||||
if (loadLibrary == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"Unable to resolve LoadLibraryW (error {error}).");
|
||||
}
|
||||
|
||||
return loadLibrary;
|
||||
}
|
||||
|
||||
private IntPtr WaitForResult(IntPtr resultAddress, TimeSpan timeout)
|
||||
{
|
||||
Stopwatch watch = Stopwatch.StartNew();
|
||||
while (watch.Elapsed < timeout)
|
||||
{
|
||||
IntPtr value = _memory.Read<IntPtr>(resultAddress);
|
||||
if (value != IntPtr.Zero)
|
||||
return value;
|
||||
|
||||
Thread.Sleep(5);
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static byte[] BuildRemoteThreadStubX86(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
|
||||
{
|
||||
var buffer = new List<byte>(18);
|
||||
|
||||
// push pathAddress
|
||||
buffer.Add(0x68);
|
||||
EmitU32(buffer, (uint)(nint)pathAddress);
|
||||
|
||||
// mov ecx, LoadLibraryW
|
||||
buffer.Add(0xB9);
|
||||
EmitU32(buffer, (uint)(nint)loadLibrary);
|
||||
|
||||
// call ecx
|
||||
buffer.Add(0xFF);
|
||||
buffer.Add(0xD1);
|
||||
|
||||
// mov [resultAddress], eax
|
||||
buffer.Add(0xA3);
|
||||
EmitU32(buffer, (uint)(nint)resultAddress);
|
||||
|
||||
// ret
|
||||
buffer.Add(0xC3);
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildRemoteThreadStubX64(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
|
||||
{
|
||||
var buffer = new List<byte>(39);
|
||||
|
||||
// mov rcx, pathAddress
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0xB9);
|
||||
EmitU64(buffer, (ulong)(nint)pathAddress);
|
||||
|
||||
// mov rax, LoadLibraryW
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0xB8);
|
||||
EmitU64(buffer, (ulong)(nint)loadLibrary);
|
||||
|
||||
// call rax
|
||||
buffer.Add(0xFF);
|
||||
buffer.Add(0xD0);
|
||||
|
||||
// mov rdx, rax
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0x89);
|
||||
buffer.Add(0xC2);
|
||||
|
||||
// mov rax, resultAddress
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0xB8);
|
||||
EmitU64(buffer, (ulong)(nint)resultAddress);
|
||||
|
||||
// mov [rax], rdx
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0x89);
|
||||
buffer.Add(0x10);
|
||||
|
||||
// ret
|
||||
buffer.Add(0xC3);
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildHijackStubX86(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
|
||||
{
|
||||
var buffer = new List<byte>(19);
|
||||
|
||||
// push pathAddress
|
||||
buffer.Add(0x68);
|
||||
EmitU32(buffer, (uint)(nint)pathAddress);
|
||||
|
||||
// mov ecx, LoadLibraryW
|
||||
buffer.Add(0xB9);
|
||||
EmitU32(buffer, (uint)(nint)loadLibrary);
|
||||
|
||||
// call ecx
|
||||
buffer.Add(0xFF);
|
||||
buffer.Add(0xD1);
|
||||
|
||||
// mov [resultAddress], eax
|
||||
buffer.Add(0xA3);
|
||||
EmitU32(buffer, (uint)(nint)resultAddress);
|
||||
|
||||
// jmp $ (infinite loop so the main injector can suspend and restore context)
|
||||
buffer.Add(0xEB);
|
||||
buffer.Add(0xFE);
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildHijackStubX64(IntPtr pathAddress, IntPtr resultAddress, IntPtr loadLibrary)
|
||||
{
|
||||
var buffer = new List<byte>(40);
|
||||
|
||||
// mov rcx, pathAddress
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0xB9);
|
||||
EmitU64(buffer, (ulong)(nint)pathAddress);
|
||||
|
||||
// mov rax, LoadLibraryW
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0xB8);
|
||||
EmitU64(buffer, (ulong)(nint)loadLibrary);
|
||||
|
||||
// call rax
|
||||
buffer.Add(0xFF);
|
||||
buffer.Add(0xD0);
|
||||
|
||||
// mov rdx, rax
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0x89);
|
||||
buffer.Add(0xC2);
|
||||
|
||||
// mov rax, resultAddress
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0xB8);
|
||||
EmitU64(buffer, (ulong)(nint)resultAddress);
|
||||
|
||||
// mov [rax], rdx
|
||||
buffer.Add(0x48);
|
||||
buffer.Add(0x89);
|
||||
buffer.Add(0x10);
|
||||
|
||||
// jmp $ (infinite loop)
|
||||
buffer.Add(0xEB);
|
||||
buffer.Add(0xFE);
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static void EmitU32(List<byte> buffer, uint value)
|
||||
{
|
||||
buffer.Add((byte)value);
|
||||
buffer.Add((byte)(value >> 8));
|
||||
buffer.Add((byte)(value >> 16));
|
||||
buffer.Add((byte)(value >> 24));
|
||||
}
|
||||
|
||||
private static void EmitU64(List<byte> buffer, ulong value)
|
||||
{
|
||||
EmitU32(buffer, (uint)value);
|
||||
EmitU32(buffer, (uint)(value >> 32));
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
return (value + alignment - 1) / alignment * alignment;
|
||||
}
|
||||
|
||||
private static nint AlignDown(nint value, int alignment)
|
||||
{
|
||||
return (nint)((nuint)value & ~((nuint)alignment - 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse buttons supported by <see cref="InputSimulator.SendMouseClick"/>.
|
||||
/// </summary>
|
||||
public enum MouseButton
|
||||
{
|
||||
/// <summary>The left mouse button.</summary>
|
||||
Left,
|
||||
|
||||
/// <summary>The right mouse button.</summary>
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates keyboard and mouse input directed at a target window via window messages.
|
||||
/// </summary>
|
||||
public sealed class InputSimulator
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a sequence of character messages to <paramref name="hWnd"/>.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> if every character was posted successfully.</returns>
|
||||
public bool SendKeys(IntPtr hWnd, string text)
|
||||
{
|
||||
if (text is null)
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
foreach (char c in text)
|
||||
{
|
||||
if (!NativeMethods.PostMessageW(hWnd, NativeMethods.WmChar, (nuint)c, 0))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a mouse click at client-area coordinates <paramref name="x"/>,
|
||||
/// <paramref name="y"/> to <paramref name="hWnd"/>.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> if the click was posted successfully.</returns>
|
||||
public bool SendMouseClick(IntPtr hWnd, int x, int y, MouseButton button)
|
||||
{
|
||||
if (hWnd == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
nint lParam = MakeLong(x, y);
|
||||
|
||||
(uint down, uint downWParam) = button switch
|
||||
{
|
||||
MouseButton.Left => (NativeMethods.WmLButtonDown, NativeMethods.MkLButton),
|
||||
MouseButton.Right => (NativeMethods.WmRButtonDown, NativeMethods.MkRButton),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(button)),
|
||||
};
|
||||
|
||||
if (!NativeMethods.PostMessageW(hWnd, down, downWParam, lParam))
|
||||
return false;
|
||||
|
||||
uint up = button == MouseButton.Left ? NativeMethods.WmLButtonUp : NativeMethods.WmRButtonUp;
|
||||
return NativeMethods.PostMessageW(hWnd, up, 0, lParam);
|
||||
}
|
||||
|
||||
private static nint MakeLong(int low, int high)
|
||||
{
|
||||
return (nint)((uint)low | ((uint)high << 16));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Diagnostics;
|
||||
using Process = System.Diagnostics.Process;
|
||||
using WhiteMagic.Execution;
|
||||
using WhiteMagic.Hooking;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// High-level entry point for a WhiteMagic session. Opens a process, exposes the
|
||||
/// memory reader, execution tiers, hooking managers, and the <see cref="RemotePointer"/>
|
||||
/// indexer.
|
||||
/// </summary>
|
||||
public sealed class Magic : IDisposable
|
||||
{
|
||||
/// <summary>The underlying memory reader for this session.</summary>
|
||||
public MemoryBase Memory { get; }
|
||||
|
||||
/// <summary>Out-of-process execution via <c>CreateRemoteThread</c>.</summary>
|
||||
public RemoteThreadExecutor RemoteThread { get; }
|
||||
|
||||
/// <summary>Named byte-patch manager.</summary>
|
||||
public PatchManager PatchManager => Memory.PatchManager;
|
||||
|
||||
/// <summary>Inline-detour manager (in-process only).</summary>
|
||||
public DetourManager DetourManager => Memory.DetourManager;
|
||||
|
||||
private Magic(MemoryBase memory)
|
||||
{
|
||||
Memory = memory;
|
||||
RemoteThread = new RemoteThreadExecutor(memory);
|
||||
}
|
||||
|
||||
/// <summary>Opens an external process for reading, writing, and execution.</summary>
|
||||
public static Magic Open(System.Diagnostics.Process process)
|
||||
{
|
||||
return new Magic(new ExternalReader(process));
|
||||
}
|
||||
|
||||
/// <summary>Creates an in-process session for the current process.</summary>
|
||||
public static Magic OpenInProcess()
|
||||
{
|
||||
return new Magic(new InProcessReader());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a main-thread pump that hooks the per-frame function at
|
||||
/// <paramref name="frameAddress"/>.
|
||||
/// </summary>
|
||||
public MainThreadPump CreateMainThreadPump(IntPtr frameAddress)
|
||||
{
|
||||
return new MainThreadPump(DetourManager, frameAddress);
|
||||
}
|
||||
|
||||
/// <summary>Returns a <see cref="RemotePointer"/> at <paramref name="address"/>.</summary>
|
||||
public RemotePointer this[IntPtr address] => new RemotePointer(Memory, address);
|
||||
|
||||
/// <summary>Returns the loaded <see cref="RemoteModule"/> named <paramref name="moduleName"/>
|
||||
/// (e.g. <c>magic["user32"]["MessageBoxA"]</c>).</summary>
|
||||
public RemoteModule this[string moduleName] => new RemoteModule(this, moduleName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Memory.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the widths marshalling decisions for type <typeparamref name="T"/>
|
||||
/// once, at static-constructor time. <see cref="MemoryBase.Read{T}"/> and
|
||||
/// <see cref="MemoryBase.Write{T}"/> branch on <see cref="TypeRequiresMarshal"/>
|
||||
/// once, at static-constructor time. <see cref="MemoryBase.Read{T}(nint, bool)"/> and
|
||||
/// <see cref="MemoryBase.Write{T}(nint, T, bool)"/> branch on <see cref="TypeRequiresMarshal"/>
|
||||
/// and pick the appropriate width from this cache.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to cache metadata for.</typeparam>
|
||||
@@ -24,8 +24,8 @@ public static class MarshalCache<T>
|
||||
public static readonly int Size;
|
||||
|
||||
/// <summary>
|
||||
/// The unmanaged (interop) width via <see cref="Marshal.SizeOf"/>. The marshal
|
||||
/// path (<see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/>)
|
||||
/// The unmanaged (interop) width via <see cref="Marshal.SizeOf(System.Type)"/>. The marshal
|
||||
/// path (<see cref="Marshal.PtrToStructure(nint, System.Type)"/>/<see cref="Marshal.StructureToPtr"/>)
|
||||
/// reads/writes this many bytes. Exceeds <see cref="Size"/> whenever a struct
|
||||
/// carries inline unmanaged data that the marshaler expands — inline
|
||||
/// <c>ByValTStr</c>/<c>ByValArray</c> buffers, <c>bool</c> fields (4 bytes per
|
||||
@@ -46,7 +46,7 @@ public static class MarshalCache<T>
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through
|
||||
/// the blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path
|
||||
/// and must fall back to <see cref="Marshal.PtrToStructure"/> /
|
||||
/// and must fall back to <see cref="Marshal.PtrToStructure(nint, System.Type)"/> /
|
||||
/// <see cref="Marshal.StructureToPtr"/>. This is the case when a top-level field
|
||||
/// carries <see cref="MarshalAsAttribute"/>, or when <typeparamref name="T"/>
|
||||
/// contains a managed reference
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of remote memory subdivided into named regions.
|
||||
/// </summary>
|
||||
public sealed class AllocatedMemory : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly IntPtr _baseAddress;
|
||||
private readonly int _size;
|
||||
private readonly Dictionary<string, int> _regions;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new allocated memory chunk.
|
||||
/// </summary>
|
||||
/// <param name="memory">The memory accessor.</param>
|
||||
/// <param name="size">The size of the allocation in bytes.</param>
|
||||
/// <param name="protection">The initial memory protection.</param>
|
||||
/// <exception cref="Win32Exception">Allocation fails.</exception>
|
||||
public AllocatedMemory(MemoryBase memory, int size, MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memory);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size);
|
||||
|
||||
_memory = memory;
|
||||
_size = size;
|
||||
_regions = new Dictionary<string, int>();
|
||||
|
||||
// Allocate using VirtualAllocEx
|
||||
_baseAddress = NativeMethods.VirtualAllocEx(
|
||||
memory.Handle,
|
||||
IntPtr.Zero,
|
||||
size,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
protection);
|
||||
|
||||
if (_baseAddress == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new Win32Exception(error, $"VirtualAllocEx failed (size={size}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base address of the allocated memory.
|
||||
/// </summary>
|
||||
public IntPtr BaseAddress => _baseAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of the allocation in bytes.
|
||||
/// </summary>
|
||||
public int Size => _size;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a named region at a specific offset within the allocation.
|
||||
/// </summary>
|
||||
/// <param name="name">The unique name for the region.</param>
|
||||
/// <param name="offset">The offset from the base address.</param>
|
||||
/// <exception cref="ArgumentException">A region with this name already exists.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Offset is outside the allocation bounds.</exception>
|
||||
public void AddRegion(string name, int offset)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(offset);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(offset, _size);
|
||||
|
||||
if (_regions.ContainsKey(name))
|
||||
throw new ArgumentException($"Region '{name}' already exists.", nameof(name));
|
||||
|
||||
_regions[name] = offset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute address of a named region.
|
||||
/// </summary>
|
||||
/// <param name="name">The region name.</param>
|
||||
/// <returns>The absolute address of the region.</returns>
|
||||
/// <exception cref="ArgumentException">No region with this name exists.</exception>
|
||||
public IntPtr AddressOf(string name)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
int offset = GetRegionOffset(name);
|
||||
return _baseAddress + offset;
|
||||
}
|
||||
|
||||
private int GetRegionOffset(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
if (!_regions.TryGetValue(name, out int offset))
|
||||
throw new ArgumentException($"Region '{name}' does not exist.", nameof(name));
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a value of type <typeparamref name="T"/> from a named region.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="name">The region name.</param>
|
||||
/// <returns>The value read from memory.</returns>
|
||||
/// <exception cref="ArgumentException">No region with this name exists.</exception>
|
||||
public T Read<T>(string name) where T : struct
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
int offset = GetRegionOffset(name);
|
||||
int size = Marshal.SizeOf<T>();
|
||||
if (offset > _size - size)
|
||||
throw new ArgumentOutOfRangeException(nameof(name), $"Region '{name}' read of {size} bytes exceeds allocation size {_size}.");
|
||||
|
||||
return _memory.Read<T>(_baseAddress + offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value of type <typeparamref name="T"/> to a named region.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="name">The region name.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <returns><see langword="true"/> if all bytes were written.</returns>
|
||||
/// <exception cref="ArgumentException">No region with this name exists.</exception>
|
||||
public bool Write<T>(string name, T value) where T : struct
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
int offset = GetRegionOffset(name);
|
||||
int size = Marshal.SizeOf<T>();
|
||||
if (offset > _size - size)
|
||||
throw new ArgumentOutOfRangeException(nameof(name), $"Region '{name}' write of {size} bytes exceeds allocation size {_size}.");
|
||||
|
||||
return _memory.Write(_baseAddress + offset, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads bytes from a named region.
|
||||
/// </summary>
|
||||
/// <param name="name">The region name.</param>
|
||||
/// <param name="count">The number of bytes to read.</param>
|
||||
/// <returns>The bytes read from memory.</returns>
|
||||
/// <exception cref="ArgumentException">No region with this name exists.</exception>
|
||||
public byte[] ReadBytes(string name, int count)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
int offset = GetRegionOffset(name);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(count);
|
||||
if (offset > _size - count)
|
||||
throw new ArgumentOutOfRangeException(nameof(count), $"Region '{name}' read of {count} bytes exceeds allocation size {_size}.");
|
||||
|
||||
return _memory.ReadBytes(_baseAddress + offset, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes bytes to a named region.
|
||||
/// </summary>
|
||||
/// <param name="name">The region name.</param>
|
||||
/// <param name="bytes">The bytes to write.</param>
|
||||
/// <returns>The number of bytes written.</returns>
|
||||
/// <exception cref="ArgumentException">No region with this name exists.</exception>
|
||||
public int WriteBytes(string name, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
int offset = GetRegionOffset(name);
|
||||
if (offset > _size - bytes.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(bytes), $"Region '{name}' write of {bytes.Length} bytes exceeds allocation size {_size}.");
|
||||
|
||||
return _memory.WriteBytes(_baseAddress + offset, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees the allocated memory.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
|
||||
// Free using VirtualFreeEx
|
||||
if (_baseAddress != IntPtr.Zero)
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(
|
||||
_memory.Handle,
|
||||
_baseAddress,
|
||||
0,
|
||||
MemoryFreeType.Release);
|
||||
}
|
||||
|
||||
_regions.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-47
@@ -1,3 +1,4 @@
|
||||
using WhiteMagic.Hooking;
|
||||
using WhiteMagic.Native;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
@@ -6,18 +7,37 @@ namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base for all memory-access readers and writers. Provides typed
|
||||
/// <see cref="Read{T}"/>/<see cref="Write{T}"/>, array IO, string IO, and
|
||||
/// <see cref="Read{T}(nint, bool)"/>/<see cref="Write{T}(nint, T, bool)"/>, array IO, string IO, and
|
||||
/// relative/absolute addressing. Subclasses implement the concrete
|
||||
/// <see cref="ReadBytes"/> and <see cref="WriteBytes"/> methods.
|
||||
/// </summary>
|
||||
public abstract class MemoryBase : IDisposable
|
||||
{
|
||||
/// <summary>Creates the shared hooking managers for this memory instance.</summary>
|
||||
protected MemoryBase()
|
||||
{
|
||||
PatchManager = new PatchManager(this);
|
||||
DetourManager = new DetourManager(this);
|
||||
}
|
||||
|
||||
/// <summary>The base address of the target process's main module.</summary>
|
||||
public abstract IntPtr ImageBase { get; }
|
||||
|
||||
/// <summary>The native handle to the target process.</summary>
|
||||
public abstract SafeMemoryHandle Handle { get; }
|
||||
|
||||
/// <summary><see langword="true"/> if the target process is 64-bit.</summary>
|
||||
public abstract bool Is64Bit { get; }
|
||||
|
||||
/// <summary>The operating-system process identifier of the target process.</summary>
|
||||
public abstract int ProcessId { get; }
|
||||
|
||||
/// <summary>Named byte-patch manager; valid for in-process and external readers.</summary>
|
||||
public PatchManager PatchManager { get; }
|
||||
|
||||
/// <summary>Inline-detour manager; valid only when operating in-process.</summary>
|
||||
public DetourManager DetourManager { get; }
|
||||
|
||||
// ── Raw byte IO ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Reads a sequence of bytes from the target address.</summary>
|
||||
@@ -153,11 +173,17 @@ public abstract class MemoryBase : IDisposable
|
||||
/// chunks. Stops at the null terminator, the maximum length, or the first page boundary
|
||||
/// that fails to read (avoids an atomic failure when a 512-byte window crosses an unmapped
|
||||
/// region).</summary>
|
||||
/// <param name="address">The address to read from.</param>
|
||||
/// <param name="address">The address to read from. For multi-byte encodings this must be
|
||||
/// aligned to a code-unit boundary or the result is undefined.</param>
|
||||
/// <param name="encoding">The text encoding.</param>
|
||||
/// <param name="maxLength">The maximum number of bytes to read.</param>
|
||||
/// <param name="relative">If <see langword="true"/>, <paramref name="address"/> is relative
|
||||
/// to <see cref="ImageBase"/>.</param>
|
||||
/// <remarks>
|
||||
/// The scan is aligned to the encoding's code-unit width (1 byte for UTF-8/ASCII, 2 bytes
|
||||
/// for UTF-16, 4 bytes for UTF-32). The trailing bytes of each chunk are merged with the
|
||||
/// next chunk so a null terminator that straddles the chunk boundary is not missed.
|
||||
/// </remarks>
|
||||
public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
|
||||
{
|
||||
if (relative)
|
||||
@@ -166,46 +192,54 @@ public abstract class MemoryBase : IDisposable
|
||||
// The encoded null terminator. For ASCII/UTF-8 this is a single 0x00 byte;
|
||||
// for UTF-16 it is two zero bytes (0x00 0x00); for UTF-32 it is four.
|
||||
byte[] nullTerminator = encoding.GetBytes("\0");
|
||||
int nullLen = nullTerminator.Length;
|
||||
|
||||
const int chunkSize = 64;
|
||||
int remaining = maxLength;
|
||||
var accumulated = new System.Collections.Generic.List<byte[]>();
|
||||
var accumulated = new System.Collections.Generic.List<byte>();
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int take = Math.Min(chunkSize, remaining);
|
||||
byte[] chunk = ReadBytes(address, take);
|
||||
byte[] chunk = ReadBytes(address + accumulated.Count, take);
|
||||
if (chunk.Length == 0)
|
||||
break;
|
||||
|
||||
int nullPos = IndexOfPattern(chunk, nullTerminator);
|
||||
if (nullPos >= 0)
|
||||
int previousLen = accumulated.Count;
|
||||
accumulated.AddRange(chunk);
|
||||
|
||||
// Search the newly extended buffer at code-unit-aligned positions. A terminator
|
||||
// can start as far back as (nullLen - 1) bytes before the new bytes, so start
|
||||
// the search just before the previous end, rounded up to the next code-unit.
|
||||
int firstAligned = previousLen - (previousLen % nullLen);
|
||||
if (firstAligned < 0) firstAligned = 0;
|
||||
|
||||
int limit = accumulated.Count - nullLen;
|
||||
for (int i = firstAligned; i <= limit; i += nullLen)
|
||||
{
|
||||
if (nullPos > 0)
|
||||
accumulated.Add(chunk[..nullPos]);
|
||||
break;
|
||||
bool match = true;
|
||||
for (int j = 0; j < nullLen; j++)
|
||||
{
|
||||
if (accumulated[i + j] != nullTerminator[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
accumulated.RemoveRange(i, accumulated.Count - i);
|
||||
remaining = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
accumulated.Add(chunk);
|
||||
// Advance by the bytes actually read, not the amount requested: a partial
|
||||
// read (chunk.Length < take) must not skip the unread tail of the window.
|
||||
address += chunk.Length;
|
||||
remaining -= chunk.Length;
|
||||
if (remaining > 0)
|
||||
remaining -= chunk.Length;
|
||||
}
|
||||
|
||||
int totalLength = 0;
|
||||
foreach (byte[] part in accumulated)
|
||||
totalLength += part.Length;
|
||||
|
||||
byte[] combined = new byte[totalLength];
|
||||
int offset = 0;
|
||||
foreach (byte[] part in accumulated)
|
||||
{
|
||||
part.CopyTo(combined, offset);
|
||||
offset += part.Length;
|
||||
}
|
||||
|
||||
return encoding.GetString(combined);
|
||||
return encoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(accumulated));
|
||||
}
|
||||
|
||||
/// <summary>Writes a null-terminated string to the target address.</summary>
|
||||
@@ -239,6 +273,8 @@ public abstract class MemoryBase : IDisposable
|
||||
/// <inheritdoc />
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DetourManager.RemoveAll();
|
||||
PatchManager.RestoreAll();
|
||||
Handle?.Dispose();
|
||||
}
|
||||
|
||||
@@ -278,24 +314,4 @@ public abstract class MemoryBase : IDisposable
|
||||
bytes.CopyTo(destination);
|
||||
}
|
||||
|
||||
private static int IndexOfPattern(byte[] data, byte[] pattern)
|
||||
{
|
||||
int lastStart = data.Length - pattern.Length;
|
||||
int stride = Math.Max(1, pattern.Length);
|
||||
for (int i = 0; i <= lastStart; i += stride)
|
||||
{
|
||||
bool match = true;
|
||||
for (int j = 0; j < pattern.Length; j++)
|
||||
{
|
||||
if (data[i + j] != pattern[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,28 @@ public enum ProcessAccess : uint
|
||||
AllAccess = 0x001F0000 | Synchronize | 0xFFFF,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access rights that open a thread object.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ThreadAccess : uint
|
||||
{
|
||||
/// <summary>The right to terminate the thread with TerminateThread.</summary>
|
||||
Terminate = 0x0001,
|
||||
/// <summary>The right to suspend and resume the thread.</summary>
|
||||
SuspendResume = 0x0002,
|
||||
/// <summary>The right to read the thread context with GetThreadContext.</summary>
|
||||
GetContext = 0x0008,
|
||||
/// <summary>The right to set the thread context with SetThreadContext.</summary>
|
||||
SetContext = 0x0010,
|
||||
/// <summary>The right to query information from the thread.</summary>
|
||||
QueryInformation = 0x0040,
|
||||
/// <summary>The right to set information on the thread.</summary>
|
||||
SetInformation = 0x0020,
|
||||
/// <summary>All access rights for a thread object.</summary>
|
||||
AllAccess = 0x001F0FFF,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Values that control how VirtualAllocEx allocates memory.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,11 +19,32 @@ internal static partial class NativeMethods
|
||||
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
|
||||
int processId);
|
||||
|
||||
/// <summary>Opens an existing thread and returns a handle to it.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial SafeMemoryHandle OpenThread(
|
||||
ThreadAccess desiredAccess,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
|
||||
int threadId);
|
||||
|
||||
/// <summary>Closes an open object handle.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool CloseHandle(IntPtr handle);
|
||||
|
||||
/// <summary>Determines whether the specified process is running under WOW64.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool IsWow64Process(
|
||||
SafeMemoryHandle process,
|
||||
[MarshalAs(UnmanagedType.Bool)] out bool wow64Process);
|
||||
|
||||
/// <summary>Retrieves the termination status of the specified thread.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool GetExitCodeThread(
|
||||
SafeMemoryHandle thread,
|
||||
out uint exitCode);
|
||||
|
||||
// ── Memory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Reads memory from a process.</summary>
|
||||
@@ -87,6 +108,22 @@ internal static partial class NativeMethods
|
||||
ThreadCreationFlags creationFlags,
|
||||
out uint threadId);
|
||||
|
||||
/// <summary>Suspends the specified thread.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial uint SuspendThread(SafeMemoryHandle thread);
|
||||
|
||||
/// <summary>Resumes the specified thread.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial uint ResumeThread(SafeMemoryHandle thread);
|
||||
|
||||
/// <summary>Returns the thread identifier of the specified thread.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial uint GetThreadId(SafeMemoryHandle thread);
|
||||
|
||||
/// <summary>Returns the identifier of the calling thread.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial uint GetCurrentThreadId();
|
||||
|
||||
/// <summary>Sets a 64-bit thread context (AMD64).</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
@@ -101,20 +138,21 @@ internal static partial class NativeMethods
|
||||
SafeMemoryHandle thread,
|
||||
ref Context64 context);
|
||||
|
||||
/// <summary>Sets a 32-bit (WOW64) thread context.</summary>
|
||||
/// <summary>Sets a 32-bit thread context (x86 or WOW64).</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool Wow64SetThreadContext(
|
||||
internal static partial bool SetThreadContext(
|
||||
SafeMemoryHandle thread,
|
||||
ref Context32 context);
|
||||
|
||||
/// <summary>Gets a 32-bit (WOW64) thread context.</summary>
|
||||
/// <summary>Gets a 32-bit thread context (x86 or WOW64).</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool Wow64GetThreadContext(
|
||||
internal static partial bool GetThreadContext(
|
||||
SafeMemoryHandle thread,
|
||||
ref Context32 context);
|
||||
|
||||
|
||||
// ── Modules ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Loads a module into the calling process.</summary>
|
||||
@@ -134,4 +172,5 @@ internal static partial class NativeMethods
|
||||
internal static partial uint WaitForSingleObject(
|
||||
SafeMemoryHandle handle,
|
||||
uint milliseconds);
|
||||
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ public unsafe struct FloatingSaveArea32
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 32-bit (x86/WOW64) thread context. Use it with
|
||||
/// <c>Wow64GetThreadContext</c> and <c>Wow64SetThreadContext</c> to inspect a 32-bit thread.
|
||||
/// A 32-bit x86 thread context. Use it with <c>GetThreadContext</c> and
|
||||
/// <c>SetThreadContext</c> from a 32-bit process targeting a 32-bit thread.
|
||||
/// The total size is 716 bytes.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WhiteMagic.Native;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke declarations for kernel32/ntdll/user32 APIs used by the high-level
|
||||
/// PEB, TEB, windowing, and input helpers. These live in a separate partial file so
|
||||
/// they can evolve independently of <see cref="NativeMethods"/>.
|
||||
/// </summary>
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
// ── Natives used directly by public helpers ──────────────────────────────
|
||||
|
||||
/// <summary>Queries information about the specified process.</summary>
|
||||
[LibraryImport("ntdll.dll")]
|
||||
internal static partial int NtQueryInformationProcess(
|
||||
SafeMemoryHandle processHandle,
|
||||
int processInformationClass,
|
||||
ref ProcessBasicInformation processInformation,
|
||||
uint processInformationLength,
|
||||
out uint returnLength);
|
||||
|
||||
/// <summary>Queries information about the specified thread.</summary>
|
||||
[LibraryImport("ntdll.dll")]
|
||||
internal static partial int NtQueryInformationThread(
|
||||
SafeMemoryHandle threadHandle,
|
||||
int threadInformationClass,
|
||||
ref ThreadBasicInformation threadInformation,
|
||||
uint threadInformationLength,
|
||||
out uint returnLength);
|
||||
|
||||
/// <summary>Enumerates all top-level windows on the screen.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
internal static partial int EnumWindows(
|
||||
nint lpEnumFunc,
|
||||
IntPtr lParam);
|
||||
|
||||
/// <summary>Retrieves the identifier of the thread that created the window and the process id of the window.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
internal static partial uint GetWindowThreadProcessId(
|
||||
IntPtr hWnd,
|
||||
out uint lpdwProcessId);
|
||||
|
||||
/// <summary>Retrieves the name of the class to which the specified window belongs.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int GetClassNameW(
|
||||
IntPtr hWnd,
|
||||
[Out] char[] lpClassName,
|
||||
int nMaxCount);
|
||||
|
||||
/// <summary>Copies the text of the specified window's title bar into a buffer.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int GetWindowTextW(
|
||||
IntPtr hWnd,
|
||||
[Out] char[] lpString,
|
||||
int nMaxCount);
|
||||
|
||||
/// <summary>Changes the text of the specified window's title bar.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetWindowTextW(
|
||||
IntPtr hWnd,
|
||||
string lpString);
|
||||
|
||||
/// <summary>Changes the size, position, and Z order of a child, pop-up, or top-level window.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetWindowPos(
|
||||
IntPtr hWnd,
|
||||
IntPtr hWndInsertAfter,
|
||||
int x,
|
||||
int y,
|
||||
int cx,
|
||||
int cy,
|
||||
uint uFlags);
|
||||
|
||||
/// <summary>Retrieves a handle to the foreground window.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
internal static partial IntPtr GetForegroundWindow();
|
||||
|
||||
/// <summary>Brings the thread that created the specified window into the foreground and activates the window.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
/// <summary>Flashes the specified window.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool FlashWindowEx(ref FlashWindowInfo pwfi);
|
||||
|
||||
/// <summary>Attaches or detaches the input processing mechanism of one thread to that of another thread.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool AttachThreadInput(
|
||||
uint idAttach,
|
||||
uint idAttachTo,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool fAttach);
|
||||
|
||||
/// <summary>Places a message in the message queue associated with the thread that created the specified window.</summary>
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool PostMessageW(
|
||||
IntPtr hWnd,
|
||||
uint msg,
|
||||
nuint wParam,
|
||||
nint lParam);
|
||||
|
||||
// ── Window / input constants ───────────────────────────────────────────────
|
||||
|
||||
internal const uint WmChar = 0x0102;
|
||||
internal const uint WmLButtonDown = 0x0201;
|
||||
internal const uint WmLButtonUp = 0x0202;
|
||||
internal const uint WmRButtonDown = 0x0204;
|
||||
internal const uint WmRButtonUp = 0x0205;
|
||||
|
||||
// Mouse button state flags for WM_*BUTTONDOWN messages
|
||||
internal const uint MkLButton = 0x0001;
|
||||
internal const uint MkRButton = 0x0002;
|
||||
|
||||
internal static readonly IntPtr HwndTop = IntPtr.Zero;
|
||||
|
||||
internal const uint SwpShowWindow = 0x0040;
|
||||
internal const uint SwpNoActivate = 0x0010;
|
||||
|
||||
internal const uint FlashwAll = 0x00000003;
|
||||
internal const uint FlashwCaption = 0x00000001;
|
||||
internal const uint FlashwTray = 0x00000002;
|
||||
internal const uint FlashwTimer = 0x00000004;
|
||||
internal const uint FlashwTimerNoFg = 0x0000000C;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout matches <c>PROCESS_BASIC_INFORMATION</c> (ProcessBasicInformation = 0).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct ProcessBasicInformation
|
||||
{
|
||||
public int ExitStatus;
|
||||
public IntPtr PebBaseAddress;
|
||||
public UIntPtr AffinityMask;
|
||||
public int BasePriority;
|
||||
public UIntPtr UniqueProcessId;
|
||||
public UIntPtr InheritedFromUniqueProcessId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout matches <c>THREAD_BASIC_INFORMATION</c> (ThreadBasicInformation = 0).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct ThreadBasicInformation
|
||||
{
|
||||
public int ExitStatus;
|
||||
public IntPtr TebBaseAddress;
|
||||
public ClientId ClientId;
|
||||
public UIntPtr AffinityMask;
|
||||
public int Priority;
|
||||
public int BasePriority;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout matches <c>CLIENT_ID</c>.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct ClientId
|
||||
{
|
||||
public IntPtr UniqueProcess;
|
||||
public IntPtr UniqueThread;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout matches <c>FLASHWINFO</c> used by <see cref="NativeMethods.FlashWindowEx"/>.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct FlashWindowInfo
|
||||
{
|
||||
public uint cbSize;
|
||||
public IntPtr hwnd;
|
||||
public uint dwFlags;
|
||||
public uint uCount;
|
||||
public uint dwTimeout;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.ProcessEnvironment;
|
||||
|
||||
/// <summary>
|
||||
/// Managed reader for a target process's Process Environment Block (PEB).
|
||||
/// </summary>
|
||||
public sealed class ManagedPeb
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly IntPtr _pebAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PEB reader for the process associated with the specified memory facade.
|
||||
/// </summary>
|
||||
public ManagedPeb(MemoryBase memory)
|
||||
{
|
||||
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
|
||||
_pebAddress = QueryPebAddress();
|
||||
}
|
||||
|
||||
/// <summary>Returns the native address of the PEB in the target process.</summary>
|
||||
public IntPtr ReadPebAddress() => _pebAddress;
|
||||
|
||||
/// <summary>Reads the BeingDebugged byte from the PEB.</summary>
|
||||
public byte ReadBeingDebugged()
|
||||
{
|
||||
return _memory.Read<byte>(_pebAddress + 2);
|
||||
}
|
||||
|
||||
/// <summary>Reads the ImageBaseAddress pointer from the PEB.</summary>
|
||||
public IntPtr ReadImageBaseAddress()
|
||||
{
|
||||
int offset = _memory.Is64Bit ? 0x10 : 0x08;
|
||||
return ReadPointer(offset);
|
||||
}
|
||||
|
||||
/// <summary>Reads the PEB_LDR_DATA pointer from the PEB.</summary>
|
||||
public IntPtr ReadLdrAddress()
|
||||
{
|
||||
int offset = _memory.Is64Bit ? 0x18 : 0x0C;
|
||||
return ReadPointer(offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the target process is running under WOW64.
|
||||
/// </summary>
|
||||
public bool ReadIsWow64Process()
|
||||
{
|
||||
if (!NativeMethods.IsWow64Process(_memory.Handle, out bool wow64))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException($"IsWow64Process failed with error {error}.");
|
||||
}
|
||||
|
||||
return wow64;
|
||||
}
|
||||
|
||||
private IntPtr QueryPebAddress()
|
||||
{
|
||||
var info = new ProcessBasicInformation();
|
||||
int status = NativeMethods.NtQueryInformationProcess(
|
||||
_memory.Handle,
|
||||
0,
|
||||
ref info,
|
||||
(uint)Marshal.SizeOf<ProcessBasicInformation>(),
|
||||
out _);
|
||||
|
||||
if (status < 0 || info.PebBaseAddress == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"NtQueryInformationProcess failed to retrieve the PEB (NTSTATUS {status:X8}).");
|
||||
}
|
||||
|
||||
return info.PebBaseAddress;
|
||||
}
|
||||
|
||||
private IntPtr ReadPointer(int offset)
|
||||
{
|
||||
IntPtr address = _pebAddress + offset;
|
||||
|
||||
if (_memory.Is64Bit)
|
||||
{
|
||||
ulong raw = _memory.Read<ulong>(address);
|
||||
return new IntPtr((long)raw);
|
||||
}
|
||||
|
||||
uint raw32 = _memory.Read<uint>(address);
|
||||
return new IntPtr((int)raw32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Threading.Tasks;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Execution;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// An exported function resolved in the target process, obtained via
|
||||
/// <c>magic["module"]["function"]</c>. Executes through one of the session's execution
|
||||
/// strategies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default <see cref="Execute{T}"/> path uses the always-available
|
||||
/// <see cref="RemoteThreadExecutor"/> (<c>CreateRemoteThread</c>), which is safe for
|
||||
/// thread-agnostic exports. For a call that touches single-threaded target state, obtain
|
||||
/// the <see cref="Address"/> and route it through a <see cref="MainThreadPump"/>, or use
|
||||
/// <see cref="CreateDelegate{TDelegate}"/> when running in-process.
|
||||
/// </remarks>
|
||||
public sealed class RemoteFunction
|
||||
{
|
||||
private readonly Magic _magic;
|
||||
|
||||
/// <summary>The export name this function was resolved from.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The absolute address of the function in the target process.</summary>
|
||||
public IntPtr Address { get; }
|
||||
|
||||
internal RemoteFunction(Magic magic, string name, IntPtr address)
|
||||
{
|
||||
_magic = magic;
|
||||
Name = name;
|
||||
Address = address;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls the function via a remote thread and returns its result cast to
|
||||
/// <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="convention">The calling convention (ignored on x64 targets).</param>
|
||||
/// <param name="args">Arguments to pass; primitives, pointers, enums, strings and
|
||||
/// structs are supported.</param>
|
||||
public T Execute<T>(CallConvention convention, params object?[] args)
|
||||
{
|
||||
return _magic.RemoteThread.Execute<T>(Address, convention, args);
|
||||
}
|
||||
|
||||
/// <summary>Asynchronous variant of <see cref="Execute{T}"/>.</summary>
|
||||
public Task<T> ExecuteAsync<T>(CallConvention convention, params object?[] args)
|
||||
{
|
||||
return _magic.RemoteThread.ExecuteAsync<T>(Address, convention, args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed delegate bound to this function for the in-process scenario.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The session is not in-process. The
|
||||
/// resolved <see cref="Address"/> lives in the target process; a delegate to it would
|
||||
/// access-violate when invoked from the host, so this is rejected for external sessions.
|
||||
/// Use <see cref="Execute{T}"/> (remote thread) for external targets.</exception>
|
||||
public TDelegate CreateDelegate<TDelegate>() where TDelegate : Delegate
|
||||
{
|
||||
if (_magic.Memory is not InProcessReader)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CreateDelegate is only valid for an in-process session (Magic.OpenInProcess). " +
|
||||
"The function address is not mapped into the host process for an external target; " +
|
||||
"use Execute<T> to call it via a remote thread.");
|
||||
}
|
||||
|
||||
return new InProcessInvoker(_magic.Memory).CreateFunction<TDelegate>(Address);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using WhiteMagic.Discovery;
|
||||
using Process = System.Diagnostics.Process;
|
||||
using ProcessModule = System.Diagnostics.ProcessModule;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// A module (loaded DLL/EXE image) in the target process, obtained by indexing the
|
||||
/// facade with a module name (e.g. <c>magic["user32"]</c>). Exposes the module's base
|
||||
/// address and resolves exported functions by name.
|
||||
/// </summary>
|
||||
public sealed class RemoteModule
|
||||
{
|
||||
private readonly Magic _magic;
|
||||
|
||||
/// <summary>The module's file name as reported by the OS (e.g. <c>user32.dll</c>).</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The module's load address in the target process.</summary>
|
||||
public IntPtr BaseAddress { get; }
|
||||
|
||||
internal RemoteModule(Magic magic, string moduleName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(magic);
|
||||
ArgumentException.ThrowIfNullOrEmpty(moduleName);
|
||||
|
||||
_magic = magic;
|
||||
|
||||
(string name, IntPtr baseAddress) = FindModule(magic.Memory.ProcessId, moduleName);
|
||||
Name = name;
|
||||
BaseAddress = baseAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an exported function by name and returns a <see cref="RemoteFunction"/>
|
||||
/// bound to its address. Export forwarders are followed.
|
||||
/// </summary>
|
||||
public RemoteFunction this[string functionName]
|
||||
{
|
||||
get
|
||||
{
|
||||
IntPtr address = GetExportAddress(functionName);
|
||||
return new RemoteFunction(_magic, functionName, address);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves the absolute address of an exported function by name.</summary>
|
||||
public IntPtr GetExportAddress(string functionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(functionName);
|
||||
var parser = new PeHeaderParser(_magic.Memory, BaseAddress);
|
||||
return parser.GetExportAddress(functionName);
|
||||
}
|
||||
|
||||
private static (string Name, IntPtr BaseAddress) FindModule(int processId, string moduleName)
|
||||
{
|
||||
using Process process = Process.GetProcessById(processId);
|
||||
foreach (ProcessModule module in process.Modules)
|
||||
{
|
||||
if (NameMatches(module.ModuleName, moduleName))
|
||||
return (module.ModuleName, module.BaseAddress);
|
||||
}
|
||||
|
||||
throw new DllNotFoundException(
|
||||
$"Module '{moduleName}' is not loaded in process {processId}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a module's base address by name within a target process, returning
|
||||
/// <see cref="IntPtr.Zero"/> if it is not loaded. Used by export-forwarder resolution.
|
||||
/// </summary>
|
||||
internal static IntPtr ResolveBase(int processId, string moduleName)
|
||||
{
|
||||
using Process process = Process.GetProcessById(processId);
|
||||
foreach (ProcessModule module in process.Modules)
|
||||
{
|
||||
if (NameMatches(module.ModuleName, moduleName))
|
||||
return module.BaseAddress;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a loaded module's file name against a requested name, tolerating a missing
|
||||
/// or present <c>.dll</c> extension and ignoring case (e.g. <c>KERNEL32</c> matches
|
||||
/// <c>kernel32.dll</c>).
|
||||
/// </summary>
|
||||
private static bool NameMatches(string actual, string requested)
|
||||
{
|
||||
if (string.Equals(actual, requested, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
string actualNoExt = Path.GetFileNameWithoutExtension(actual);
|
||||
string requestedNoExt = requested.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
|
||||
? requested[..^4]
|
||||
: requested;
|
||||
|
||||
return string.Equals(actualNoExt, requestedNoExt, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// A pointer-relative view over a <see cref="MemoryBase"/>. Obtained through the
|
||||
/// high-level facade indexer, it provides read/write/string operations with optional
|
||||
/// offsets relative to a base address.
|
||||
/// </summary>
|
||||
public sealed class RemotePointer
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
|
||||
/// <summary>The base address of this view.</summary>
|
||||
public IntPtr BaseAddress { get; }
|
||||
|
||||
internal RemotePointer(MemoryBase memory, IntPtr baseAddress)
|
||||
{
|
||||
_memory = memory;
|
||||
BaseAddress = baseAddress;
|
||||
}
|
||||
|
||||
/// <summary>Reads a value of type <typeparamref name="T"/> at <c>BaseAddress + offset</c>.</summary>
|
||||
public T Read<T>(nint offset = 0) where T : struct
|
||||
{
|
||||
return _memory.Read<T>(BaseAddress + offset);
|
||||
}
|
||||
|
||||
/// <summary>Writes <paramref name="value"/> at <c>BaseAddress + offset</c>.</summary>
|
||||
public bool Write<T>(T value, nint offset = 0) where T : struct
|
||||
{
|
||||
return _memory.Write(BaseAddress + offset, value);
|
||||
}
|
||||
|
||||
/// <summary>Reads a null-terminated string at <c>BaseAddress + offset</c>.</summary>
|
||||
public string ReadString(Encoding encoding, int maxLength = 512, nint offset = 0)
|
||||
{
|
||||
return _memory.ReadString(BaseAddress + offset, encoding, maxLength);
|
||||
}
|
||||
|
||||
/// <summary>Writes a null-terminated string at <c>BaseAddress + offset</c>.</summary>
|
||||
public bool WriteString(string value, Encoding encoding, nint offset = 0)
|
||||
{
|
||||
return _memory.WriteString(BaseAddress + offset, value, encoding);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Returns a new <see cref="RemotePointer"/> with the offset added.</summary>
|
||||
public RemotePointer this[nint offset] => new RemotePointer(_memory, BaseAddress + offset);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.ThreadEnvironment;
|
||||
|
||||
/// <summary>
|
||||
/// Managed reader for a target thread's Thread Environment Block (TEB).
|
||||
/// </summary>
|
||||
public sealed class ManagedTeb : IDisposable
|
||||
{
|
||||
private readonly MemoryBase _memory;
|
||||
private readonly SafeMemoryHandle _threadHandle;
|
||||
private readonly IntPtr _tebAddress;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TEB reader for the specified thread in the process associated
|
||||
/// with the provided memory facade.
|
||||
/// </summary>
|
||||
public ManagedTeb(MemoryBase memory, int threadId)
|
||||
{
|
||||
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
|
||||
|
||||
_threadHandle = NativeMethods.OpenThread(
|
||||
ThreadAccess.QueryInformation,
|
||||
false,
|
||||
threadId);
|
||||
|
||||
if (_threadHandle.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"OpenThread failed for thread {threadId}: error {error}.");
|
||||
}
|
||||
|
||||
_tebAddress = QueryTebAddress();
|
||||
}
|
||||
|
||||
/// <summary>Returns the native address of the TEB in the target process.</summary>
|
||||
public IntPtr ReadTebAddress() => _tebAddress;
|
||||
|
||||
/// <summary>Reads the stack base pointer stored in the TEB.</summary>
|
||||
public IntPtr ReadStackBase()
|
||||
{
|
||||
int offset = _memory.Is64Bit ? 0x08 : 0x04;
|
||||
return ReadPointer(offset);
|
||||
}
|
||||
|
||||
/// <summary>Reads the stack limit pointer stored in the TEB.</summary>
|
||||
public IntPtr ReadStackLimit()
|
||||
{
|
||||
int offset = _memory.Is64Bit ? 0x10 : 0x08;
|
||||
return ReadPointer(offset);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_threadHandle.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr QueryTebAddress()
|
||||
{
|
||||
var info = new ThreadBasicInformation();
|
||||
int status = NativeMethods.NtQueryInformationThread(
|
||||
_threadHandle,
|
||||
0,
|
||||
ref info,
|
||||
(uint)Marshal.SizeOf<ThreadBasicInformation>(),
|
||||
out _);
|
||||
|
||||
if (status < 0 || info.TebBaseAddress == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"NtQueryInformationThread failed to retrieve the TEB (NTSTATUS {status:X8}).");
|
||||
}
|
||||
|
||||
return info.TebBaseAddress;
|
||||
}
|
||||
|
||||
private IntPtr ReadPointer(int offset)
|
||||
{
|
||||
IntPtr address = _tebAddress + offset;
|
||||
|
||||
if (_memory.Is64Bit)
|
||||
{
|
||||
ulong raw = _memory.Read<ulong>(address);
|
||||
return new IntPtr((long)raw);
|
||||
}
|
||||
|
||||
uint raw32 = _memory.Read<uint>(address);
|
||||
return new IntPtr((int)raw32);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,20 @@
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Platforms>x86;x64;AnyCPU</Platforms>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="WhiteMagicTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Optional Iced backend (task 8.x). Isolated behind IAssembler: the default
|
||||
StubAssembler path never touches Iced, keeping the common configuration free of any
|
||||
behavioral dependency on it. Only callers that construct IcedAssembler pull it in.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Iced" Version="1.21.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper around a native window handle that supports querying and mutating
|
||||
/// common window properties.
|
||||
/// </summary>
|
||||
public sealed class RemoteWindow
|
||||
{
|
||||
private const int MaxTextLength = 512;
|
||||
|
||||
/// <summary>Creates a wrapper for the specified window handle.</summary>
|
||||
public RemoteWindow(IntPtr handle)
|
||||
{
|
||||
if (handle == IntPtr.Zero)
|
||||
throw new ArgumentException("Window handle cannot be zero.", nameof(handle));
|
||||
|
||||
Handle = handle;
|
||||
}
|
||||
|
||||
/// <summary>The native window handle.</summary>
|
||||
public IntPtr Handle { get; }
|
||||
|
||||
/// <summary>The window class name.</summary>
|
||||
public string ClassName => GetClassName(Handle);
|
||||
|
||||
/// <summary>The current window text.</summary>
|
||||
public string Text => GetWindowText(Handle);
|
||||
|
||||
/// <summary>The process identifier that owns the window.</summary>
|
||||
public uint ProcessId => GetWindowProcessId(Handle);
|
||||
|
||||
/// <summary>Gets or sets the window title.</summary>
|
||||
public string Title
|
||||
{
|
||||
get => GetWindowText(Handle);
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
|
||||
if (!NativeMethods.SetWindowTextW(Handle, value))
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"SetWindowText failed for window {Handle} with error {error}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><see langword="true"/> if this window is currently the foreground window.</summary>
|
||||
public bool IsActive => NativeMethods.GetForegroundWindow() == Handle;
|
||||
|
||||
/// <summary>Moves and resizes the window.</summary>
|
||||
public bool MoveResize(int x, int y, int width, int height)
|
||||
{
|
||||
return NativeMethods.SetWindowPos(
|
||||
Handle,
|
||||
NativeMethods.HwndTop,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
NativeMethods.SwpShowWindow);
|
||||
}
|
||||
|
||||
/// <summary>Activates the window and brings it to the foreground.</summary>
|
||||
public bool Activate()
|
||||
{
|
||||
IntPtr foreground = NativeMethods.GetForegroundWindow();
|
||||
uint targetThread = NativeMethods.GetWindowThreadProcessId(Handle, out _);
|
||||
|
||||
if (targetThread == 0)
|
||||
return false;
|
||||
|
||||
// If there's no foreground window, or we're already in the foreground thread, just set it
|
||||
if (foreground == IntPtr.Zero)
|
||||
return NativeMethods.SetForegroundWindow(Handle);
|
||||
|
||||
uint foregroundThread = NativeMethods.GetWindowThreadProcessId(foreground, out _);
|
||||
|
||||
if (targetThread == foregroundThread)
|
||||
return NativeMethods.SetForegroundWindow(Handle);
|
||||
|
||||
if (!NativeMethods.AttachThreadInput(foregroundThread, targetThread, true))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
return NativeMethods.SetForegroundWindow(Handle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.AttachThreadInput(foregroundThread, targetThread, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Flashes the window in the caption and taskbar button.</summary>
|
||||
public bool Flash()
|
||||
{
|
||||
var info = new FlashWindowInfo
|
||||
{
|
||||
cbSize = (uint)Marshal.SizeOf<FlashWindowInfo>(),
|
||||
hwnd = Handle,
|
||||
dwFlags = NativeMethods.FlashwAll,
|
||||
uCount = 3,
|
||||
dwTimeout = 0,
|
||||
};
|
||||
|
||||
return NativeMethods.FlashWindowEx(ref info);
|
||||
}
|
||||
|
||||
/// <summary>Returns a string representation of this window, including its handle, class name, and title.</summary>
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("RemoteWindow(");
|
||||
sb.Append(Handle.ToString("X"));
|
||||
sb.Append(", ");
|
||||
sb.Append(ClassName);
|
||||
sb.Append(")");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetClassName(IntPtr handle)
|
||||
{
|
||||
var buffer = new char[256];
|
||||
int length = NativeMethods.GetClassNameW(handle, buffer, buffer.Length);
|
||||
if (length <= 0)
|
||||
return string.Empty;
|
||||
|
||||
return new string(buffer, 0, length);
|
||||
}
|
||||
|
||||
private static string GetWindowText(IntPtr handle)
|
||||
{
|
||||
var buffer = new char[MaxTextLength];
|
||||
int length = NativeMethods.GetWindowTextW(handle, buffer, buffer.Length);
|
||||
if (length <= 0)
|
||||
return string.Empty;
|
||||
|
||||
return new string(buffer, 0, length);
|
||||
}
|
||||
|
||||
private static uint GetWindowProcessId(IntPtr handle)
|
||||
{
|
||||
NativeMethods.GetWindowThreadProcessId(handle, out uint processId);
|
||||
return processId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.CompilerServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for enumerating and locating <see cref="RemoteWindow"/> instances.
|
||||
/// </summary>
|
||||
public static class WindowFactory
|
||||
{
|
||||
/// <summary>Enumerates all top-level windows.</summary>
|
||||
public static unsafe IEnumerable<RemoteWindow> GetWindows()
|
||||
{
|
||||
var handles = new List<IntPtr>();
|
||||
GCHandle gch = GCHandle.Alloc(handles);
|
||||
try
|
||||
{
|
||||
delegate* unmanaged[Stdcall]<IntPtr, IntPtr, int> callback = &EnumWindowsCallback;
|
||||
NativeMethods.EnumWindows((nint)callback, GCHandle.ToIntPtr(gch));
|
||||
}
|
||||
finally
|
||||
{
|
||||
gch.Free();
|
||||
}
|
||||
|
||||
return handles.Select(static h => new RemoteWindow(h));
|
||||
}
|
||||
|
||||
/// <summary>Returns all top-level windows with the specified class name.</summary>
|
||||
public static IEnumerable<RemoteWindow> GetWindowsByClassName(string className)
|
||||
{
|
||||
if (className is null)
|
||||
throw new ArgumentNullException(nameof(className));
|
||||
|
||||
return GetWindows().Where(w => w.ClassName.Equals(className, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Returns all top-level windows owned by the specified process.</summary>
|
||||
public static IEnumerable<RemoteWindow> GetWindowsByProcessId(int processId)
|
||||
{
|
||||
return GetWindows().Where(w => w.ProcessId == (uint)processId);
|
||||
}
|
||||
|
||||
/// <summary>Returns the first top-level window with the specified class name.</summary>
|
||||
public static RemoteWindow? GetWindowByClassName(string className)
|
||||
{
|
||||
return GetWindowsByClassName(className).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the main window of a process. When <see cref="Process.MainWindowHandle"/>
|
||||
/// is unavailable, falls back to the first enumerated window owned by the process.
|
||||
/// </summary>
|
||||
public static RemoteWindow? GetMainWindow(System.Diagnostics.Process process)
|
||||
{
|
||||
if (process is null)
|
||||
throw new ArgumentNullException(nameof(process));
|
||||
|
||||
if (process.MainWindowHandle != IntPtr.Zero)
|
||||
return new RemoteWindow(process.MainWindowHandle);
|
||||
|
||||
return GetWindowsByProcessId(process.Id).FirstOrDefault();
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
|
||||
private static int EnumWindowsCallback(IntPtr hWnd, IntPtr lParam)
|
||||
{
|
||||
var handles = (List<IntPtr>)GCHandle.FromIntPtr(lParam).Target!;
|
||||
handles.Add(hWnd);
|
||||
return 1; // Continue enumeration.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Linq;
|
||||
using Iced.Intel;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Hooking;
|
||||
|
||||
namespace WhiteMagicTest.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the optional <see cref="IcedAssembler"/> backend (tasks 8.1–8.3): arbitrary
|
||||
/// text assembly, origin-relative encoding, and full prologue instruction decoding.
|
||||
/// </summary>
|
||||
public class IcedAssemblerTests
|
||||
{
|
||||
private static Instruction[] Disassemble(byte[] code, int bitness, ulong origin)
|
||||
{
|
||||
var decoder = Decoder.Create(bitness, new ByteArrayCodeReader(code));
|
||||
decoder.IP = origin;
|
||||
|
||||
var result = new List<Instruction>();
|
||||
ulong end = origin + (ulong)code.Length;
|
||||
while (decoder.IP < end)
|
||||
result.Add(decoder.Decode());
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_emits_single_instruction()
|
||||
{
|
||||
var assembler = new IcedAssembler(64);
|
||||
byte[] code = assembler.Assemble("ret");
|
||||
Assert.Equal(new byte[] { 0xC3 }, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_emits_multiple_instructions_with_operands()
|
||||
{
|
||||
var assembler = new IcedAssembler(32);
|
||||
|
||||
// The scenario from the managed-assembler spec.
|
||||
byte[] code = assembler.Assemble("push 0\nadd esp, 4\nret");
|
||||
Assert.NotEmpty(code);
|
||||
|
||||
Instruction[] instructions = Disassemble(code, 32, 0);
|
||||
Assert.Equal(3, instructions.Length);
|
||||
Assert.Equal(Mnemonic.Push, instructions[0].Mnemonic);
|
||||
Assert.Equal(Mnemonic.Add, instructions[1].Mnemonic);
|
||||
Assert.Equal(Register.ESP, instructions[1].Op0Register);
|
||||
Assert.Equal(4UL, instructions[1].GetImmediate(1));
|
||||
Assert.Equal(Mnemonic.Ret, instructions[2].Mnemonic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_supports_comments_and_blank_lines()
|
||||
{
|
||||
var assembler = new IcedAssembler(64);
|
||||
byte[] code = assembler.Assemble(" ; prologue\n\nnop ; a comment\nret\n");
|
||||
|
||||
Instruction[] instructions = Disassemble(code, 64, 0);
|
||||
Assert.Equal(2, instructions.Length);
|
||||
Assert.Equal(Mnemonic.Nop, instructions[0].Mnemonic);
|
||||
Assert.Equal(Mnemonic.Ret, instructions[1].Mnemonic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_encodes_label_branch_relative_to_origin()
|
||||
{
|
||||
var assembler = new IcedAssembler(64);
|
||||
const ulong origin = 0x1_4000_1000UL;
|
||||
|
||||
// jmp forward over a nop to a label; the near-branch target must be resolved
|
||||
// against the supplied origin, not zero.
|
||||
byte[] code = assembler.Assemble("jmp done\nnop\ndone:\nret", origin);
|
||||
|
||||
Instruction[] instructions = Disassemble(code, 64, origin);
|
||||
Instruction jmp = instructions[0];
|
||||
Assert.Equal(Mnemonic.Jmp, jmp.Mnemonic);
|
||||
|
||||
// Target = origin + len(jmp) + len(nop): the address of the 'done: ret'.
|
||||
ulong expected = origin + (ulong)jmp.Length + 1;
|
||||
Assert.Equal(expected, jmp.NearBranchTarget);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mov eax, 4294967295")] // 0xFFFFFFFF — needs the uint overload, not int
|
||||
[InlineData("mov eax, 0xFFFFFFFF")] // same value, hex form
|
||||
[InlineData("mov rax, 18446744073709551615")] // ulong.MaxValue — decimal above long.MaxValue
|
||||
public void Assemble_binds_wide_unsigned_immediates(string source)
|
||||
{
|
||||
var assembler = new IcedAssembler(64);
|
||||
|
||||
byte[] code = assembler.Assemble(source);
|
||||
Assert.NotEmpty(code);
|
||||
|
||||
Instruction[] instructions = Disassemble(code, 64, 0);
|
||||
Assert.Single(instructions);
|
||||
Assert.Equal(Mnemonic.Mov, instructions[0].Mnemonic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_rejects_immediate_that_fits_no_overload_without_crashing()
|
||||
{
|
||||
var assembler = new IcedAssembler(64);
|
||||
|
||||
// -2147483649 is below int.MinValue and eax has no wider signed overload; must be a
|
||||
// clean NotSupportedException, not an OverflowException escaping from ChangeType.
|
||||
Assert.Throws<NotSupportedException>(() => assembler.Assemble("mov eax, -2147483649"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_throws_on_unsupported_operand()
|
||||
{
|
||||
var assembler = new IcedAssembler(64);
|
||||
Assert.Throws<NotSupportedException>(() => assembler.Assemble("mov rax, [rbx]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPrologueLength_decodes_prologue_the_builtin_decoder_rejects()
|
||||
{
|
||||
// 48 8B C1 = mov rax, rcx — a register-to-register mov the built-in PrologueDecoder
|
||||
// does not cover (it only recognizes the 8B FF / 8B EC forms).
|
||||
// Followed by push rbp; mov rbp,rsp; sub rsp,0x20; mov rax,rcx to exceed 14 bytes.
|
||||
byte[] prologue =
|
||||
[
|
||||
0x48, 0x8B, 0xC1, // mov rax, rcx (3)
|
||||
0x55, // push rbp (1)
|
||||
0x48, 0x8B, 0xEC, // mov rbp, rsp (3)
|
||||
0x48, 0x83, 0xEC, 0x20, // sub rsp, 0x20 (4)
|
||||
0x48, 0x8B, 0xC1 // mov rax, rcx (3) -> total 14
|
||||
];
|
||||
|
||||
// The built-in decoder refuses the very first instruction.
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
PrologueDecoder.GetWholeInstructionLength(prologue, 14, is64Bit: true));
|
||||
|
||||
// The Iced backend decodes it and returns the whole-instruction length covering
|
||||
// at least the 14 bytes a detour needs.
|
||||
var iced = new IcedAssembler();
|
||||
int length = iced.GetPrologueLength(prologue, 14, is64Bit: true);
|
||||
Assert.Equal(14, length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetourManager_prologue_resolver_defaults_to_builtin_and_is_replaceable()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var manager = new DetourManager(reader);
|
||||
|
||||
// Default resolver is the built-in decoder.
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
manager.PrologueLengthResolver(new byte[] { 0x48, 0x8B, 0xC1, 0x90, 0x90 }, 4, true));
|
||||
|
||||
// Swapping in the Iced resolver validates the same bytes.
|
||||
manager.PrologueLengthResolver = new IcedAssembler().GetPrologueLength;
|
||||
int length = manager.PrologueLengthResolver(new byte[] { 0x48, 0x8B, 0xC1, 0x90, 0x90 }, 4, true);
|
||||
Assert.True(length >= 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Discovery;
|
||||
|
||||
namespace WhiteMagicTest.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PatternScannerCache"/>.
|
||||
/// </summary>
|
||||
public class PatternScannerCacheTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCached_returns_same_result_on_second_call()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
// Create a buffer with a known pattern
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[30] = 0x11;
|
||||
buffer[31] = 0x22;
|
||||
buffer[32] = 0x33;
|
||||
buffer[33] = 0x44;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
byte[] pattern = { 0x11, 0x22, 0x33, 0x44 };
|
||||
|
||||
// First call should scan memory
|
||||
IntPtr first = cache.FindCached(pattern, null, addr, end);
|
||||
|
||||
// Second call should return cached result
|
||||
IntPtr second = cache.FindCached(pattern, null, addr, end);
|
||||
|
||||
Assert.Equal(addr + 30, first);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
// Value equality must mean the second call reused the cached entry.
|
||||
var cacheField = typeof(PatternScannerCache).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var cacheDict = cacheField.GetValue(cache)!;
|
||||
int count = (int)cacheDict.GetType().GetProperty("Count")!.GetValue(cacheDict)!;
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCached_value_equality_uses_content_not_reference()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[10] = 0xAA;
|
||||
buffer[11] = 0xBB;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
byte[] pattern1 = { 0xAA, 0xBB };
|
||||
byte[] pattern2 = { 0xAA, 0xBB };
|
||||
|
||||
IntPtr first = cache.FindCached(pattern1, null, addr, end);
|
||||
IntPtr second = cache.FindCached(pattern2, null, addr, end);
|
||||
|
||||
Assert.Equal(addr + 10, first);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
var cacheField = typeof(PatternScannerCache).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var cacheDict = cacheField.GetValue(cache)!;
|
||||
int count = (int)cacheDict.GetType().GetProperty("Count")!.GetValue(cacheDict)!;
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCached_different_ranges_are_cached_separately()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
// Create two separate buffers
|
||||
byte[] buffer1 = new byte[128];
|
||||
buffer1[10] = 0xAA;
|
||||
buffer1[11] = 0xBB;
|
||||
|
||||
byte[] buffer2 = new byte[128];
|
||||
buffer2[20] = 0xAA;
|
||||
buffer2[21] = 0xBB;
|
||||
|
||||
GCHandle pin1 = GCHandle.Alloc(buffer1, GCHandleType.Pinned);
|
||||
GCHandle pin2 = GCHandle.Alloc(buffer2, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr1 = pin1.AddrOfPinnedObject();
|
||||
IntPtr end1 = addr1 + buffer1.Length;
|
||||
|
||||
IntPtr addr2 = pin2.AddrOfPinnedObject();
|
||||
IntPtr end2 = addr2 + buffer2.Length;
|
||||
|
||||
byte[] pattern = { 0xAA, 0xBB };
|
||||
|
||||
IntPtr found1 = cache.FindCached(pattern, null, addr1, end1);
|
||||
IntPtr found2 = cache.FindCached(pattern, null, addr2, end2);
|
||||
|
||||
Assert.Equal(addr1 + 10, found1);
|
||||
Assert.Equal(addr2 + 20, found2);
|
||||
Assert.NotEqual(found1, found2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin1.Free();
|
||||
pin2.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCached_with_mask_caches_correctly()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[40] = 0x99;
|
||||
buffer[41] = 0x88; // This is wildcard
|
||||
buffer[42] = 0x77;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
byte[] pattern = { 0x99, 0x00, 0x77 };
|
||||
string mask = "x?x";
|
||||
|
||||
IntPtr first = cache.FindCached(pattern, mask, addr, end);
|
||||
IntPtr second = cache.FindCached(pattern, mask, addr, end);
|
||||
|
||||
Assert.Equal(addr + 40, first);
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_clears_cached_results()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[50] = 0xCC;
|
||||
buffer[51] = 0xDD;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
byte[] pattern = { 0xCC, 0xDD };
|
||||
|
||||
// Cache a result
|
||||
IntPtr first = cache.FindCached(pattern, null, addr, end);
|
||||
Assert.Equal(addr + 50, first);
|
||||
|
||||
// Clear the cache
|
||||
cache.Clear();
|
||||
|
||||
// This should rescan (not return cached result)
|
||||
IntPtr second = cache.FindCached(pattern, null, addr, end);
|
||||
Assert.Equal(addr + 50, second);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindInModuleCached_caches_module_scans()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
// MZ header is always at the start of the main module
|
||||
byte[] pattern = { 0x4D, 0x5A };
|
||||
|
||||
IntPtr first = cache.FindInModuleCached(pattern, null, mainModule);
|
||||
IntPtr second = cache.FindInModuleCached(pattern, null, mainModule);
|
||||
|
||||
Assert.Equal(mainModule.BaseAddress, first);
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindInModulesCached_caches_multiple_modules()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var modules = currentProcess.Modules.Cast<System.Diagnostics.ProcessModule>().ToList();
|
||||
|
||||
Assert.NotEmpty(modules);
|
||||
|
||||
// MZ header should be present in at least one module
|
||||
byte[] pattern = { 0x4D, 0x5A };
|
||||
|
||||
IntPtr first = cache.FindInModulesCached(pattern, null, modules);
|
||||
IntPtr second = cache.FindInModulesCached(pattern, null, modules);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, first);
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Discovery;
|
||||
using WhiteMagicTest;
|
||||
|
||||
namespace WhiteMagicTest.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PatternScanner"/>.
|
||||
/// </summary>
|
||||
public class PatternScannerTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_exact_pattern_returns_correct_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Create a buffer with known bytes
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[10] = 0xDE;
|
||||
buffer[11] = 0xAD;
|
||||
buffer[12] = 0xBE;
|
||||
buffer[13] = 0xEF;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
// Search for the exact pattern
|
||||
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
|
||||
|
||||
Assert.Equal(addr + 10, found);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_with_wildcard_mask_ignores_wildcard_bytes()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Create a buffer with known bytes
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[20] = 0x12;
|
||||
buffer[21] = 0x34; // This byte is wildcard
|
||||
buffer[22] = 0x56;
|
||||
buffer[23] = 0x78;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
// Search with wildcard mask (x = match, ? = wildcard)
|
||||
byte[] pattern = { 0x12, 0x00, 0x56, 0x78 };
|
||||
string mask = "x?xx"; // Second byte is wildcard
|
||||
IntPtr found = PatternScanner.Find(reader, pattern, mask, addr, end);
|
||||
|
||||
Assert.Equal(addr + 20, found);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_pattern_not_found_returns_zero()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Create a buffer without the target pattern
|
||||
byte[] buffer = new byte[256];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = 0xAA;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
// Search for pattern that doesn't exist
|
||||
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
|
||||
|
||||
Assert.Equal(IntPtr.Zero, found);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_empty_pattern_throws()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] pattern = Array.Empty<byte>();
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
PatternScanner.Find(reader, pattern, null, IntPtr.Zero, (IntPtr)1000));
|
||||
|
||||
Assert.Contains("Pattern cannot be empty", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_mask_length_mismatch_throws()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
string mask = "xxx"; // Wrong length
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
|
||||
|
||||
Assert.Contains("Mask length", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_invalid_mask_char_throws()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
string mask = "axxx"; // 'a' is invalid
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Find_null_mask_treats_all_as_exact()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[50] = 0xAB;
|
||||
buffer[51] = 0xCD;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
// Null mask should behave like "xx" (exact match)
|
||||
byte[] pattern = { 0xAB, 0xCD };
|
||||
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
|
||||
|
||||
Assert.Equal(addr + 50, found);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindInModule_scans_current_process_module()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Get the current process's main module
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
// MZ header is always at the start of the main module
|
||||
byte[] pattern = { 0x4D, 0x5A };
|
||||
IntPtr found = PatternScanner.FindInModule(reader, pattern, null, mainModule);
|
||||
|
||||
Assert.Equal(mainModule.BaseAddress, found);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Discovery;
|
||||
|
||||
namespace WhiteMagicTest.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PeHeaderParser"/>.
|
||||
/// </summary>
|
||||
public class PeHeaderParserTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntryPoint_returns_nonzero_for_current_module()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
|
||||
IntPtr entryPoint = parser.EntryPoint;
|
||||
|
||||
// Entry point should be a valid RVA (non-zero for a valid PE)
|
||||
Assert.NotEqual(IntPtr.Zero, entryPoint);
|
||||
|
||||
// Entry point should be less than module size
|
||||
Assert.True((nint)entryPoint < mainModule.ModuleMemorySize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_enumerates_at_least_text_section()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
|
||||
var sections = parser.Sections.ToList();
|
||||
|
||||
Assert.NotEmpty(sections);
|
||||
|
||||
// Every PE file should have a .text section (or similar)
|
||||
var textSection = sections.FirstOrDefault(s =>
|
||||
s.Name.Equals(".text", StringComparison.OrdinalIgnoreCase) ||
|
||||
s.Name.Equals("TEXT", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// May not find ".text" exactly, but should have at least some sections
|
||||
Assert.True(sections.Count >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_have_valid_properties()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
|
||||
var sections = parser.Sections.ToList();
|
||||
|
||||
foreach (var section in sections)
|
||||
{
|
||||
// Name should not be empty
|
||||
Assert.False(string.IsNullOrWhiteSpace(section.Name));
|
||||
|
||||
// Virtual address should be within module bounds
|
||||
Assert.True((nint)section.VirtualAddress < mainModule.ModuleMemorySize);
|
||||
|
||||
// Virtual size should be positive
|
||||
Assert.True(section.VirtualSize > 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_have_common_names()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
|
||||
var sections = parser.Sections.Select(s => s.Name).ToList();
|
||||
|
||||
// At least some common section names should be present
|
||||
var commonNames = new[] { ".text", ".data", ".rdata", ".bss" };
|
||||
bool hasCommonSection = commonNames.Any(name =>
|
||||
sections.Contains(name, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// This might not always be true, but for managed EXEs it usually is
|
||||
// We'll just verify sections were enumerated
|
||||
Assert.NotEmpty(sections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntryPoint_is_consistent_across_calls()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
|
||||
|
||||
IntPtr first = parser.EntryPoint;
|
||||
IntPtr second = parser.EntryPoint;
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_are_consistent_across_calls()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
|
||||
var mainModule = currentProcess.MainModule;
|
||||
Assert.NotNull(mainModule);
|
||||
|
||||
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
|
||||
|
||||
var first = parser.Sections.ToList();
|
||||
var second = parser.Sections.ToList();
|
||||
|
||||
Assert.Equal(first.Count, second.Count);
|
||||
|
||||
for (int i = 0; i < first.Count; i++)
|
||||
{
|
||||
Assert.Equal(first[i].Name, second[i].Name);
|
||||
Assert.Equal(first[i].VirtualAddress, second[i].VirtualAddress);
|
||||
Assert.Equal(first[i].VirtualSize, second[i].VirtualSize);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_on_zero_base_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
new PeHeaderParser(reader, IntPtr.Zero));
|
||||
|
||||
Assert.Contains("Base address cannot be zero", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="InProcessInvoker"/> operating in-process.
|
||||
/// </summary>
|
||||
public class InProcessInvokerTests
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int AddDelegate(int a, int b);
|
||||
|
||||
private static int NativeAdd(int a, int b) => a + b;
|
||||
|
||||
[Fact]
|
||||
public void CreateFunction_calls_known_in_process_function()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var invoker = new InProcessInvoker(reader);
|
||||
|
||||
var native = new AddDelegate(NativeAdd);
|
||||
IntPtr functionPointer = Marshal.GetFunctionPointerForDelegate(native);
|
||||
|
||||
AddDelegate callable = invoker.CreateFunction<AddDelegate>(functionPointer);
|
||||
int result = callable(5, 7);
|
||||
|
||||
Assert.Equal(12, result);
|
||||
GC.KeepAlive(native);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFunction_rejects_zero_address()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var invoker = new InProcessInvoker(reader);
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => invoker.CreateFunction<AddDelegate>(IntPtr.Zero));
|
||||
Assert.Equal("address", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vtable_helper_reads_function_pointer_slot()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var invoker = new InProcessInvoker(reader);
|
||||
|
||||
// Build a tiny fake vtable in a pinned buffer: two slots holding known pointers.
|
||||
IntPtr slot0 = Marshal.GetFunctionPointerForDelegate(new AddDelegate(NativeAdd));
|
||||
IntPtr slot1 = new IntPtr(0x12345678);
|
||||
|
||||
IntPtr[] vtable;
|
||||
if (reader.Is64Bit)
|
||||
{
|
||||
vtable = [slot0, slot1];
|
||||
}
|
||||
else
|
||||
{
|
||||
vtable = [slot0, slot1];
|
||||
}
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(vtable, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr vTableAddress = pin.AddrOfPinnedObject();
|
||||
Assert.Equal(slot0, invoker.ReadVTableFunction(vTableAddress, 0));
|
||||
Assert.Equal(slot1, invoker.ReadVTableFunction(vTableAddress, 1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Execution;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Execution;
|
||||
|
||||
public sealed class RemoteThreadExecutorTests
|
||||
{
|
||||
// x64 payloads. Live execution tests run only on x64 because the payloads use the
|
||||
// Microsoft x64 ABI (integer args in RCX, RDX, R8, R9, then stack at [rsp+0x28]).
|
||||
|
||||
// mov eax, ecx
|
||||
// add eax, edx
|
||||
// ret
|
||||
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
|
||||
|
||||
// mov eax, ecx
|
||||
// add eax, edx
|
||||
// add eax, r8d
|
||||
// add eax, r9d
|
||||
// add eax, [rsp+0x28]
|
||||
// ret
|
||||
private static readonly byte[] SumFivePayload =
|
||||
[
|
||||
0x89, 0xC8,
|
||||
0x01, 0xD0,
|
||||
0x44, 0x01, 0xC0,
|
||||
0x44, 0x01, 0xC8,
|
||||
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
|
||||
0xC3
|
||||
];
|
||||
|
||||
// Five-arg callee that also executes an alignment-sensitive SSE instruction, proving
|
||||
// the stub delivers a 16-byte-aligned stack the CPU actually accepts (movaps #GPs on a
|
||||
// misaligned address) alongside correct register+stack argument placement.
|
||||
// sub rsp, 24 ; entry rsp ≡ 8 (mod 16) -> rsp ≡ 0 (16-aligned), giving a
|
||||
// ; 16-byte aligned scratch at [rsp..rsp+16) below the saved
|
||||
// ; return address ([rsp+24]) so the store leaves it intact
|
||||
// movaps [rsp], xmm0 ; aligned 16-byte store — faults unless rsp is 16-aligned
|
||||
// add rsp, 24 ; restore
|
||||
// mov eax, ecx
|
||||
// add eax, edx
|
||||
// add eax, r8d
|
||||
// add eax, r9d
|
||||
// add eax, [rsp+0x28] ; 5th arg above the shadow space
|
||||
// ret
|
||||
private static readonly byte[] SseAlignedSumPayload =
|
||||
[
|
||||
0x48, 0x83, 0xEC, 0x18,
|
||||
0x0F, 0x29, 0x04, 0x24,
|
||||
0x48, 0x83, 0xC4, 0x18,
|
||||
0x89, 0xC8,
|
||||
0x01, 0xD0,
|
||||
0x44, 0x01, 0xC0,
|
||||
0x44, 0x01, 0xC8,
|
||||
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
|
||||
0xC3
|
||||
];
|
||||
|
||||
// xor eax, eax
|
||||
// cmp byte ptr [rcx+rax], 0
|
||||
// je done
|
||||
// inc eax
|
||||
// jmp loop
|
||||
// done: ret
|
||||
private static readonly byte[] Utf8LengthPayload =
|
||||
[
|
||||
0x31, 0xC0,
|
||||
0x80, 0x3C, 0x01, 0x00,
|
||||
0x74, 0x04,
|
||||
0xFF, 0xC0,
|
||||
0xEB, 0xF6,
|
||||
0xC3
|
||||
];
|
||||
|
||||
// mov eax, [rcx]
|
||||
// add eax, [rcx+4]
|
||||
// ret
|
||||
private static readonly byte[] PointSumPayload = [0x8B, 0x01, 0x03, 0x41, 0x04, 0xC3];
|
||||
|
||||
// Measures the callee's entry stack alignment without faulting.
|
||||
// mov eax, esp
|
||||
// add eax, 8
|
||||
// and eax, 0x0F
|
||||
// ret
|
||||
// Returns (rsp + 8) & 15, which is 0 iff callee entry rsp ≡ 8 (mod 16) — the
|
||||
// Microsoft x64 ABI guarantee the stub must deliver.
|
||||
private static readonly byte[] AlignProbePayload = [0x89, 0xE0, 0x83, 0xC0, 0x08, 0x83, 0xE0, 0x0F, 0xC3];
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Point
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_adds_two_integers()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int result = RunPayload(AddPayload, CallConvention.Cdecl, 10, 32);
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_sums_register_and_stack_arguments()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int result = RunPayload(SumFivePayload, CallConvention.Cdecl, 1, 2, 3, 4, 5);
|
||||
Assert.Equal(15, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_delivers_16byte_aligned_stack_to_callee()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Stub entry rsp ≡ 8 → sub rsp, K (K ≡ 8) → rsp ≡ 0 → call → callee entry rsp ≡ 8.
|
||||
// So (rsp + 8) & 15 == 0 when the frame math is right; a bad K (e.g. 0x20) yields 8.
|
||||
int misalign = RunPayload(AlignProbePayload, CallConvention.Cdecl);
|
||||
Assert.Equal(0, misalign);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_runs_sse_callee_with_five_args()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Correct result (150) requires BOTH the 5th arg reaching [rsp+0x28] AND the
|
||||
// aligned movaps not faulting. A broken frame size/alignment either mis-sums or
|
||||
// #GPs in the callee.
|
||||
int result = RunPayload(SseAlignedSumPayload, CallConvention.Cdecl, 10, 20, 30, 40, 50);
|
||||
Assert.Equal(150, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_marshals_string_as_utf8_pointer()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int result = RunPayload(Utf8LengthPayload, CallConvention.Cdecl, "hello");
|
||||
Assert.Equal(5, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_marshals_struct_as_pointer()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int result = RunPayload(PointSumPayload, CallConvention.Cdecl, new Point { X = 30, Y = 12 });
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_throws_when_handle_is_invalid()
|
||||
{
|
||||
var executor = new RemoteThreadExecutor(new InvalidProcessReader());
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
executor.Execute<int>((IntPtr)0x1234, CallConvention.Cdecl);
|
||||
});
|
||||
|
||||
Assert.Contains("handle", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_throws_when_address_is_zero()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var executor = new RemoteThreadExecutor(reader);
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
executor.Execute<int>(IntPtr.Zero, CallConvention.Cdecl);
|
||||
});
|
||||
|
||||
Assert.Equal("address", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InProcessReader_reports_current_process_bitness()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalReader_reports_current_process_bitness()
|
||||
{
|
||||
using var reader = new ExternalReader(Process.GetCurrentProcess());
|
||||
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_releases_allocated_remote_memory_on_write_failure()
|
||||
{
|
||||
using var reader = new WriteFailingMemoryBase();
|
||||
var executor = new RemoteThreadExecutor(reader);
|
||||
|
||||
var allocated = new List<IntPtr>();
|
||||
var freed = new List<IntPtr>();
|
||||
nint next = 0x4000_0000;
|
||||
|
||||
executor.RemoteAllocator = size => { var p = (IntPtr)(next += 0x1000); allocated.Add(p); return p; };
|
||||
executor.RemoteReleaser = p => freed.Add(p);
|
||||
|
||||
// The string arg is marshalled to remote scratch FIRST, then its write fails.
|
||||
// Pre-fix the scratch was tracked only AFTER the write, so it escaped the finally
|
||||
// free and leaked. Post-fix every allocation is released on the failure path.
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
executor.Execute<int>(new IntPtr(0x123456789ABCDEF0L), CallConvention.Stdcall, "leakme"));
|
||||
|
||||
Assert.NotEmpty(allocated); // the arg scratch was allocated
|
||||
Assert.Equal(allocated.OrderBy(x => x), freed.OrderBy(x => x)); // and every alloc freed
|
||||
}
|
||||
|
||||
private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
|
||||
{
|
||||
const nint pageSize = 4096;
|
||||
const nint blockSize = pageSize * 2;
|
||||
|
||||
using var reader = new InProcessReader();
|
||||
var executor = new RemoteThreadExecutor(reader);
|
||||
|
||||
// Allocate a single executable block. The payload lives at the start and the
|
||||
// generated call stub is written to the second page, guaranteeing that the
|
||||
// relative CALL instruction stays within its ±2 GiB range.
|
||||
IntPtr block = NativeMethods.VirtualAllocEx(
|
||||
reader.Handle,
|
||||
IntPtr.Zero,
|
||||
blockSize,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, block);
|
||||
|
||||
IntPtr stubAddress = block + pageSize;
|
||||
executor.StubAllocator = (_, size) => size <= pageSize ? stubAddress : IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
int written = reader.WriteBytes(block, payload);
|
||||
Assert.Equal(payload.Length, written);
|
||||
|
||||
return executor.Execute<int>(block, convention, args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InvalidProcessReader : MemoryBase
|
||||
{
|
||||
public override IntPtr ImageBase => IntPtr.Zero;
|
||||
|
||||
public override SafeMemoryHandle Handle { get; } = new SafeMemoryHandle(new IntPtr(-1));
|
||||
|
||||
public override bool Is64Bit => Environment.Is64BitProcess;
|
||||
|
||||
public override int ProcessId => Environment.ProcessId;
|
||||
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake reader whose WriteBytes always returns zero, forcing the executor down
|
||||
/// the failure path after it has allocated remote memory.
|
||||
/// Holds a valid handle to the current process so the executor passes its
|
||||
/// handle-validity check without performing real memory operations.
|
||||
/// </summary>
|
||||
private sealed class WriteFailingMemoryBase : MemoryBase
|
||||
{
|
||||
public override IntPtr ImageBase => IntPtr.Zero;
|
||||
public override SafeMemoryHandle Handle { get; }
|
||||
public override bool Is64Bit => Environment.Is64BitProcess;
|
||||
public override int ProcessId => Environment.ProcessId;
|
||||
|
||||
public WriteFailingMemoryBase()
|
||||
{
|
||||
Handle = NativeMethods.OpenProcess(ProcessAccess.AllAccess, false, Environment.ProcessId);
|
||||
}
|
||||
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
=> 0;
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
Handle?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Native;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the high-level facade (<see cref="Magic"/>) and <see cref="RemotePointer"/>.
|
||||
/// </summary>
|
||||
public class HighLevelTests
|
||||
{
|
||||
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
|
||||
|
||||
[Fact]
|
||||
public void OpenExternal_returns_session_for_current_process()
|
||||
{
|
||||
using var magic = Magic.Open(Process.GetCurrentProcess());
|
||||
Assert.NotNull(magic.Memory);
|
||||
Assert.False(magic.Memory.Handle.IsInvalid);
|
||||
Assert.Same(magic.Memory.PatchManager, magic.PatchManager);
|
||||
Assert.Same(magic.Memory.DetourManager, magic.DetourManager);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenInProcess_returns_session_for_self()
|
||||
{
|
||||
using var magic = Magic.OpenInProcess();
|
||||
Assert.IsType<InProcessReader>(magic.Memory);
|
||||
Assert.False(magic.Memory.Handle.IsInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_returns_remote_pointer_that_reads_and_writes_relative()
|
||||
{
|
||||
using var magic = Magic.OpenInProcess();
|
||||
byte[] slot = new byte[16];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr baseAddr = pin.AddrOfPinnedObject();
|
||||
magic[baseAddr + 4].Write(0x12345678);
|
||||
Assert.Equal(0x12345678, magic[baseAddr].Read<int>(4));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteThread_ExecuteAsync_runs_payload_and_returns_result()
|
||||
{
|
||||
if (!Environment.Is64BitProcess)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
IntPtr payload = NativeMethods.VirtualAllocEx(
|
||||
magic.Memory.Handle,
|
||||
IntPtr.Zero,
|
||||
4096,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, payload);
|
||||
|
||||
try
|
||||
{
|
||||
magic.Memory.WriteBytes(payload, AddPayload);
|
||||
int result = await magic.RemoteThread.ExecuteAsync<int>(payload, CallConvention.Cdecl, 10, 32);
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(magic.Memory.Handle, payload, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Hooking;
|
||||
using WhiteMagic.Native;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PatchManager"/>, <see cref="DetourManager"/> and
|
||||
/// <see cref="Execution.MainThreadPump"/> operating in-process.
|
||||
/// </summary>
|
||||
public class HookingTests
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int FrameFunc();
|
||||
|
||||
private const int FrameResult = 42;
|
||||
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocates a tiny executable function whose prologue is made entirely of
|
||||
/// covered instruction shapes, so detours apply cleanly in tests.
|
||||
/// </summary>
|
||||
private static IntPtr AllocateFrameStub(MemoryBase reader, out IntPtr allocationBase)
|
||||
{
|
||||
// x64: push rbp; push rdi; push rsi; push rbx; sub rsp, 0x28; sub rsp, 0x12345678;
|
||||
// mov eax, 42; add rsp, 0x12345678; add rsp, 0x28; pop rbx; pop rsi; pop rdi; pop rbp; ret
|
||||
byte[] code =
|
||||
[
|
||||
0x55, // push rbp
|
||||
0x57, // push rdi
|
||||
0x56, // push rsi
|
||||
0x53, // push rbx
|
||||
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
|
||||
0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12, // sub rsp, 0x12345678
|
||||
0xB8, 0x2A, 0x00, 0x00, 0x00, // mov eax, 42
|
||||
0x48, 0x81, 0xC4, 0x78, 0x56, 0x34, 0x12, // add rsp, 0x12345678
|
||||
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
|
||||
0x5B, // pop rbx
|
||||
0x5E, // pop rsi
|
||||
0x5F, // pop rdi
|
||||
0x5D, // pop rbp
|
||||
0xC3 // ret
|
||||
];
|
||||
|
||||
allocationBase = NativeMethods.VirtualAllocEx(
|
||||
reader.Handle,
|
||||
IntPtr.Zero,
|
||||
code.Length,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
Assert.NotEqual(IntPtr.Zero, allocationBase);
|
||||
|
||||
reader.WriteBytes(allocationBase, code);
|
||||
return allocationBase;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patch_apply_writes_bytes_and_remove_restores_original()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
byte[] slot = new byte[8];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
byte[] original = reader.ReadBytes(addr, 4);
|
||||
byte[] patchBytes = [0x90, 0x90, 0x90, 0x90];
|
||||
|
||||
Patch patch = reader.PatchManager.Create("nop", addr, patchBytes);
|
||||
Assert.False(patch.IsApplied);
|
||||
|
||||
patch.Apply();
|
||||
Assert.True(patch.IsApplied);
|
||||
Assert.Equal(patchBytes, reader.ReadBytes(addr, 4));
|
||||
|
||||
patch.Remove();
|
||||
Assert.False(patch.IsApplied);
|
||||
Assert.Equal(original, reader.ReadBytes(addr, 4));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patch_apply_on_execute_only_memory_succeeds()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = [0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3]; // mov eax, 42; ret
|
||||
IntPtr alloc = NativeMethods.VirtualAllocEx(
|
||||
reader.Handle,
|
||||
IntPtr.Zero,
|
||||
code.Length,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
Assert.NotEqual(IntPtr.Zero, alloc);
|
||||
|
||||
try
|
||||
{
|
||||
reader.WriteBytes(alloc, code);
|
||||
|
||||
// Remove write access. Without VirtualProtectEx in Patch.Apply,
|
||||
// applying a patch would fail because the page is read-only for writes.
|
||||
Assert.True(NativeMethods.VirtualProtectEx(
|
||||
reader.Handle,
|
||||
alloc,
|
||||
code.Length,
|
||||
MemoryProtectionType.ExecuteRead,
|
||||
out MemoryProtectionType _));
|
||||
|
||||
Patch patch = reader.PatchManager.Create("nop-ret", alloc, [0x90, 0x90]);
|
||||
patch.Apply();
|
||||
Assert.True(patch.IsApplied);
|
||||
|
||||
patch.Remove();
|
||||
Assert.False(patch.IsApplied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, alloc, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detour_apply_redirects_callOriginal_remove_restores()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
|
||||
try
|
||||
{
|
||||
int hookCalls = 0;
|
||||
Detour? detour = null;
|
||||
FrameFunc hook = () =>
|
||||
{
|
||||
hookCalls++;
|
||||
return (int?)detour?.CallOriginal() ?? 0;
|
||||
};
|
||||
|
||||
detour = reader.DetourManager.Create("frame", targetPtr, hook);
|
||||
detour.Apply();
|
||||
|
||||
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
|
||||
int result = routed();
|
||||
Assert.True(hookCalls > 0);
|
||||
Assert.Equal(FrameResult, result);
|
||||
|
||||
detour.Remove();
|
||||
hookCalls = 0;
|
||||
result = routed();
|
||||
Assert.Equal(0, hookCalls);
|
||||
Assert.Equal(FrameResult, result);
|
||||
|
||||
GC.KeepAlive(hook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detour_named_lookup_returns_existing_detour()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
|
||||
try
|
||||
{
|
||||
Detour detour = reader.DetourManager.Create("lookup", targetPtr, (FrameFunc)(() => FrameResult));
|
||||
Assert.Same(detour, reader.DetourManager["lookup"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detour_aligned_prologue_applies_and_unknown_prologue_rejects()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// A normal JIT-compiled function has a covered prologue shape.
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr goodAllocation);
|
||||
try
|
||||
{
|
||||
Detour good = reader.DetourManager.Create("good", targetPtr, (FrameFunc)(() => FrameResult));
|
||||
good.Apply();
|
||||
good.Remove();
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, goodAllocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
|
||||
// Allocate a small executable region whose first instruction is outside the
|
||||
// covered set. The decoder must refuse to splice it.
|
||||
IntPtr code = NativeMethods.VirtualAllocEx(
|
||||
reader.Handle,
|
||||
IntPtr.Zero,
|
||||
32,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
Assert.NotEqual(IntPtr.Zero, code);
|
||||
|
||||
try
|
||||
{
|
||||
// 0x0F 0x05 = syscall (not covered), followed by padding and a ret.
|
||||
byte[] unknown = [0x0F, 0x05, 0xC3, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC];
|
||||
reader.WriteBytes(code, unknown);
|
||||
|
||||
Detour bad = reader.DetourManager.Create("bad", code, (FrameFunc)(() => FrameResult));
|
||||
Assert.Throws<InvalidOperationException>(() => bad.Apply());
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, code, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_restores_active_patches_and_detours()
|
||||
{
|
||||
InProcessReader reader = CreateReader();
|
||||
byte[] slot = new byte[8];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
byte[] original = reader.ReadBytes(addr, 2);
|
||||
|
||||
Patch patch = reader.PatchManager.Create("dispose-patch", addr, [0x90, 0x90]);
|
||||
patch.Apply();
|
||||
|
||||
reader.Dispose();
|
||||
|
||||
// Verify with a fresh reader; the original handle was closed by Dispose.
|
||||
using var verify = CreateReader();
|
||||
Assert.Equal(original, verify.ReadBytes(addr, 2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_drains_work_on_frame_call_and_uninstalls_on_dispose()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
try
|
||||
{
|
||||
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
|
||||
pump.Install();
|
||||
|
||||
Task<int> work = pump.ExecuteAsync(() => 123);
|
||||
|
||||
// Drive the frame function manually. The detoured frame runs the pump hook on
|
||||
// this thread, drains the work queue, then calls the original frame function.
|
||||
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
|
||||
int frameResult = routed();
|
||||
|
||||
Assert.Equal(FrameResult, frameResult);
|
||||
Assert.Equal(123, await work);
|
||||
|
||||
pump.Dispose();
|
||||
|
||||
// After uninstall, calling the frame function should behave like the original.
|
||||
routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
|
||||
Assert.Equal(FrameResult, routed());
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_exception_survives_and_does_not_kill_pump()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
try
|
||||
{
|
||||
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
|
||||
pump.Install();
|
||||
|
||||
Task<int> bad = pump.ExecuteAsync<int>(() => throw new InvalidOperationException("boom"));
|
||||
Task<int> good = pump.ExecuteAsync(() => 7);
|
||||
|
||||
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
|
||||
routed();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => bad);
|
||||
Assert.Equal(7, await good);
|
||||
|
||||
pump.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_dispose_faults_pending_work()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
try
|
||||
{
|
||||
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
|
||||
pump.Install();
|
||||
|
||||
Task<int> pending = pump.ExecuteAsync(() => 42);
|
||||
|
||||
pump.Dispose();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ObjectDisposedException>(() => pending);
|
||||
Assert.Equal(nameof(WhiteMagic.Execution.MainThreadPump), ex.ObjectName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_dispose_while_execute_blocked_does_not_deadlock()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
try
|
||||
{
|
||||
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
|
||||
pump.Install();
|
||||
|
||||
// Start Execute on another thread; it will block until Dispose drains the queue.
|
||||
Task executeTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
pump.Execute<int>(() => 42);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
// Give Execute time to pass the gate and block on the TCS.
|
||||
await Task.Delay(50);
|
||||
pump.Dispose();
|
||||
|
||||
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(executeTask, completed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_concurrent_dispose_and_pump_does_not_throw()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
try
|
||||
{
|
||||
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
|
||||
pump.Install();
|
||||
|
||||
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
|
||||
|
||||
var tasks = new List<Task<int>>();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
int value = i;
|
||||
tasks.Add(pump.ExecuteAsync(() => value));
|
||||
}
|
||||
|
||||
// Drive the detoured frame while disposing from another thread.
|
||||
// This stresses the race between PumpHook completing work and
|
||||
// Dispose faulting still-queued work.
|
||||
Task drive = Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
try { routed(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(10);
|
||||
pump.Dispose();
|
||||
await drive;
|
||||
|
||||
// Any faulted task must have been cancelled by Dispose; no
|
||||
// unhandled exceptions should escape from the pump itself.
|
||||
foreach (Task<int> task in tasks)
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
Assert.IsType<ObjectDisposedException>(task.Exception!.InnerException);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using WhiteMagic.Hooking;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PrologueDecoder"/> covering the accepted x86/x64 prologue
|
||||
/// shapes and rejection of opcodes outside the covered set.
|
||||
/// </summary>
|
||||
public class PrologueDecoderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, new byte[] { 0x55 }, 1)] // push rbp
|
||||
[InlineData(false, new byte[] { 0x53 }, 1)] // push rbx
|
||||
[InlineData(false, new byte[] { 0x8B, 0xFF }, 2)] // mov edi, edi
|
||||
[InlineData(false, new byte[] { 0x8B, 0xEC }, 2)] // mov ebp, esp
|
||||
[InlineData(false, new byte[] { 0x83, 0xEC, 0x20 }, 3)] // sub esp, 0x20
|
||||
[InlineData(false, new byte[] { 0x81, 0xEC, 0x00, 0x01, 0x00, 0x00 }, 6)] // sub esp, 0x100
|
||||
[InlineData(true, new byte[] { 0x48, 0x8B, 0xEC }, 3)] // mov rbp, rsp
|
||||
[InlineData(true, new byte[] { 0x48, 0x83, 0xEC, 0x28 }, 4)] // sub rsp, 0x28
|
||||
[InlineData(true, new byte[] { 0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12 }, 7)] // sub rsp, 0x12345678
|
||||
public void Decodes_covered_prologue_shapes(bool is64Bit, byte[] bytes, int expectedLength)
|
||||
{
|
||||
int length = PrologueDecoder.GetInstructionLength(bytes, is64Bit);
|
||||
Assert.Equal(expectedLength, length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, new byte[] { 0x83, 0x05, 0x39, 0x00, 0x00, 0x00, 0x01 })] // add [rip+0x39], 1 — wrong modrm
|
||||
[InlineData(false, new byte[] { 0x83, 0x3D, 0x00, 0x00, 0x00, 0x00, 0x01 })] // cmp [rip], 1 — wrong modrm
|
||||
[InlineData(false, new byte[] { 0x81, 0x05, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00 })] // add [rip], 1 — wrong modrm
|
||||
[InlineData(false, new byte[] { 0x0F, 0x05 })] // syscall
|
||||
[InlineData(false, new byte[] { 0x90, 0x90 })] // nop
|
||||
public void Rejects_unsafe_or_uncovered_shapes(bool is64Bit, byte[] bytes)
|
||||
{
|
||||
int length = PrologueDecoder.GetInstructionLength(bytes, is64Bit);
|
||||
Assert.Equal(-1, length);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => PrologueDecoder.GetWholeInstructionLength(bytes, requiredBytes: 2, is64Bit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.ComponentModel;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Injection;
|
||||
using WhiteMagic.Memory;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CodeInjector"/>.
|
||||
/// </summary>
|
||||
public class CodeInjectorTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_writes_code_to_specified_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Allocate a buffer to write to
|
||||
byte[] buffer = new byte[32];
|
||||
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
|
||||
buffer,
|
||||
System.Runtime.InteropServices.GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = handle.AddrOfPinnedObject();
|
||||
|
||||
// Simple x64 payload: mov eax, 42; ret
|
||||
// B8 2A 00 00 00 C3
|
||||
byte[] code = { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
|
||||
|
||||
if (Environment.Is64BitProcess)
|
||||
{
|
||||
// 64-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3)
|
||||
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
|
||||
}
|
||||
else
|
||||
{
|
||||
// 32-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3) - same encoding
|
||||
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
|
||||
}
|
||||
|
||||
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
|
||||
|
||||
Assert.Equal(addr, result);
|
||||
Assert.Equal(code, buffer.Take(code.Length).ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_throws_on_empty_code()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = Array.Empty<byte>();
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
|
||||
|
||||
Assert.Contains("Code cannot be empty", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_throws_on_zero_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0x90, 0x90, 0xC3 }; // nop; nop; ret
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
|
||||
|
||||
Assert.Contains("Address cannot be zero", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_allocates_and_writes_code()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Simple x64 payload: ret (C3)
|
||||
byte[] code = { 0xC3 };
|
||||
|
||||
using var allocated = CodeInjector.Inject(reader, code);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
Assert.Equal(code.Length, allocated.Size);
|
||||
|
||||
// Verify the code was written
|
||||
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
|
||||
Assert.Equal(code, readBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_with_execute_read_write_protection()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0xC3 }; // ret
|
||||
|
||||
using var allocated = CodeInjector.Inject(
|
||||
reader,
|
||||
code,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_with_read_only_protection()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0xC3 }; // ret
|
||||
|
||||
using var allocated = CodeInjector.Inject(
|
||||
reader,
|
||||
code,
|
||||
MemoryProtectionType.ExecuteRead);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_throws_on_empty_code()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = Array.Empty<byte>();
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
CodeInjector.Inject(reader, code));
|
||||
|
||||
Assert.Contains("Code cannot be empty", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_returns_allocated_memory_that_can_be_freed()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0xC3 }; // ret
|
||||
|
||||
var allocated = CodeInjector.Inject(reader, code);
|
||||
|
||||
Assert.NotNull(allocated);
|
||||
|
||||
// Dispose should free the memory
|
||||
allocated.Dispose();
|
||||
|
||||
// No exception should be thrown during disposal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_writes_all_bytes()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Allocate a buffer
|
||||
byte[] buffer = new byte[128];
|
||||
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
|
||||
buffer,
|
||||
System.Runtime.InteropServices.GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = handle.AddrOfPinnedObject();
|
||||
|
||||
// Create a larger payload
|
||||
byte[] code = new byte[64];
|
||||
for (int i = 0; i < code.Length; i++)
|
||||
code[i] = (byte)(i & 0xFF);
|
||||
|
||||
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
|
||||
|
||||
Assert.Equal(addr, result);
|
||||
|
||||
// Verify all bytes were written
|
||||
byte[] readBack = reader.ReadBytes(addr, code.Length);
|
||||
Assert.Equal(code, readBack);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_with_complex_payload()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// mov eax, 12345678h; ret
|
||||
// x64: B8 78 56 34 12 C3
|
||||
// x86: B8 78 56 34 12 C3 (same)
|
||||
byte[] code = { 0xB8, 0x78, 0x56, 0x34, 0x12, 0xC3 };
|
||||
|
||||
using var allocated = CodeInjector.Inject(reader, code);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
|
||||
// Verify the exact payload was written
|
||||
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
|
||||
Assert.Equal(code, readBack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Injection;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DllInjector"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Real injection tests exercise the current process, because it is always available
|
||||
/// and the injected DLLs are ordinary system modules that are already loaded.
|
||||
/// </remarks>
|
||||
public class DllInjectorTests
|
||||
{
|
||||
private static string GetExistingSystemDll()
|
||||
{
|
||||
// user32.dll exists on every Windows system and matches the host bitness.
|
||||
string path = Path.Combine(Environment.SystemDirectory, "user32.dll");
|
||||
Assert.True(File.Exists(path), $"{path} must exist for the test.");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
|
||||
public void InjectWithRemoteThread_loads_system_dll_in_current_process()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var injector = new DllInjector(reader);
|
||||
|
||||
IntPtr moduleBase = injector.InjectWithRemoteThread(GetExistingSystemDll());
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, moduleBase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectWithRemoteThread_throws_for_missing_dll()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var injector = new DllInjector(reader);
|
||||
string missingPath = Path.Combine(Path.GetTempPath(), $"wm-missing-{Guid.NewGuid()}.dll");
|
||||
|
||||
Assert.False(File.Exists(missingPath));
|
||||
Assert.Throws<FileNotFoundException>(() => injector.InjectWithRemoteThread(missingPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectWithRemoteThread_rejects_bitness_mismatch()
|
||||
{
|
||||
// A fake reader that reports the opposite bitness from the current process.
|
||||
using var reader = new FakeBitnessMemoryBase(!Environment.Is64BitProcess);
|
||||
var injector = new DllInjector(reader);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => injector.InjectWithRemoteThread(GetExistingSystemDll()));
|
||||
|
||||
Assert.Contains("bitness", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
|
||||
public void InjectWithThreadHijack_loads_system_dll_and_restores_context()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var injector = new DllInjector(reader);
|
||||
|
||||
using var stopEvent = new ManualResetEventSlim(false);
|
||||
using var startedEvent = new ManualResetEventSlim(false);
|
||||
|
||||
int osThreadId = 0;
|
||||
Exception? threadError = null;
|
||||
|
||||
var helper = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
osThreadId = (int)NativeMethods.GetCurrentThreadId();
|
||||
startedEvent.Set();
|
||||
|
||||
// Loop with short sleeps so the thread can be hijacked safely and can
|
||||
// also be stopped once its original context is restored.
|
||||
while (!stopEvent.IsSet)
|
||||
{
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
threadError = ex;
|
||||
}
|
||||
});
|
||||
helper.IsBackground = true;
|
||||
helper.Start();
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(startedEvent.Wait(TimeSpan.FromSeconds(5)), "Helper thread did not start.");
|
||||
Assert.NotEqual(0, osThreadId);
|
||||
|
||||
IntPtr moduleBase = injector.InjectWithThreadHijack(osThreadId, GetExistingSystemDll());
|
||||
Assert.NotEqual(IntPtr.Zero, moduleBase);
|
||||
|
||||
// Tell the helper thread to exit. If the original context was restored correctly,
|
||||
// the thread will return to its loop and observe the stop event.
|
||||
stopEvent.Set();
|
||||
Assert.True(helper.Join(TimeSpan.FromSeconds(5)), "Helper thread did not exit after context restore.");
|
||||
Assert.Null(threadError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopEvent.Set();
|
||||
helper.Join(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="MemoryBase"/> whose only job is to report a chosen bitness.
|
||||
/// Reads and writes are not expected to be called by the rejection path.
|
||||
/// </summary>
|
||||
private sealed class FakeBitnessMemoryBase : MemoryBase
|
||||
{
|
||||
public FakeBitnessMemoryBase(bool is64Bit)
|
||||
{
|
||||
Is64Bit = is64Bit;
|
||||
}
|
||||
|
||||
public override IntPtr ImageBase => IntPtr.Zero;
|
||||
|
||||
public override SafeMemoryHandle Handle => new(IntPtr.Zero);
|
||||
|
||||
public override bool Is64Bit { get; }
|
||||
|
||||
public override int ProcessId => Environment.ProcessId;
|
||||
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Diagnostics;
|
||||
using WhiteMagic.Input;
|
||||
using WhiteMagic.Windows;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.Input;
|
||||
|
||||
public sealed class InputSimulatorTests
|
||||
{
|
||||
[Fact(Skip = "Interactive input test - requires a visible window")]
|
||||
public void SendKeys_to_current_window_does_not_throw()
|
||||
{
|
||||
using var process = Process.GetCurrentProcess();
|
||||
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
|
||||
? process.MainWindowHandle
|
||||
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
return;
|
||||
|
||||
var simulator = new InputSimulator();
|
||||
bool result = simulator.SendKeys(handle, "ab");
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Interactive input test - requires a visible window")]
|
||||
public void SendMouseClick_to_current_window_does_not_throw()
|
||||
{
|
||||
using var process = Process.GetCurrentProcess();
|
||||
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
|
||||
? process.MainWindowHandle
|
||||
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
return;
|
||||
|
||||
var simulator = new InputSimulator();
|
||||
bool result = simulator.SendMouseClick(handle, 10, 20, MouseButton.Left);
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.ComponentModel;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Memory;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AllocatedMemory"/>.
|
||||
/// </summary>
|
||||
public class AllocatedMemoryTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_allocates_memory_with_execute_read_write_protection()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
Assert.Equal(4096, allocated.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_with_custom_protection()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096, MemoryProtectionType.ReadOnly);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_on_negative_size()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new AllocatedMemory(reader, -1));
|
||||
|
||||
Assert.Equal("size", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_on_zero_size()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new AllocatedMemory(reader, 0));
|
||||
|
||||
Assert.Equal("size", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRegion_adds_named_region()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("test", 100);
|
||||
|
||||
Assert.Equal(100, allocated.AddressOf("test") - allocated.BaseAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRegion_throws_on_duplicate_name()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("test", 100);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
allocated.AddRegion("test", 200));
|
||||
|
||||
Assert.Contains("already exists", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRegion_throws_on_negative_offset()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
allocated.AddRegion("test", -1));
|
||||
|
||||
Assert.Equal("offset", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRegion_throws_on_offset_exceeding_size()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
allocated.AddRegion("test", 4096));
|
||||
|
||||
Assert.Equal("offset", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressOf_returns_correct_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("region1", 0);
|
||||
allocated.AddRegion("region2", 100);
|
||||
allocated.AddRegion("region3", 200);
|
||||
|
||||
Assert.Equal(allocated.BaseAddress, allocated.AddressOf("region1"));
|
||||
Assert.Equal(allocated.BaseAddress + 100, allocated.AddressOf("region2"));
|
||||
Assert.Equal(allocated.BaseAddress + 200, allocated.AddressOf("region3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressOf_throws_on_unknown_region()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
allocated.AddressOf("unknown"));
|
||||
|
||||
Assert.Contains("does not exist", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_and_Read_int_roundtrip()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("value", 0);
|
||||
|
||||
int original = unchecked((int)0xDEADBEEF);
|
||||
Assert.True(allocated.Write("value", original));
|
||||
|
||||
int read = allocated.Read<int>("value");
|
||||
Assert.Equal(original, read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_and_Read_long_roundtrip()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("value", 8);
|
||||
|
||||
long original = 0x123456789ABCDEF0;
|
||||
Assert.True(allocated.Write("value", original));
|
||||
|
||||
long read = allocated.Read<long>("value");
|
||||
Assert.Equal(original, read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteBytes_and_ReadBytes_roundtrip()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("buffer", 0);
|
||||
|
||||
byte[] original = { 0x01, 0x02, 0x03, 0x04, 0x05 };
|
||||
int written = allocated.WriteBytes("buffer", original);
|
||||
Assert.Equal(original.Length, written);
|
||||
|
||||
byte[] read = allocated.ReadBytes("buffer", original.Length);
|
||||
Assert.Equal(original, read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_frees_memory()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var allocated = new AllocatedMemory(reader, 4096);
|
||||
IntPtr baseAddr = allocated.BaseAddress;
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, baseAddr);
|
||||
|
||||
allocated.Dispose();
|
||||
|
||||
// After dispose, accessing properties should throw ObjectDisposedException
|
||||
Assert.Throws<ObjectDisposedException>(() =>
|
||||
allocated.AddressOf("any"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_regions_independent_access()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
allocated.AddRegion("a", 0);
|
||||
allocated.AddRegion("b", 4);
|
||||
allocated.AddRegion("c", 8);
|
||||
|
||||
Assert.True(allocated.Write("a", 0x11111111));
|
||||
Assert.True(allocated.Write("b", 0x22222222));
|
||||
Assert.True(allocated.Write("c", 0x33333333));
|
||||
|
||||
Assert.Equal(0x11111111, allocated.Read<int>("a"));
|
||||
Assert.Equal(0x22222222, allocated.Read<int>("b"));
|
||||
Assert.Equal(0x33333333, allocated.Read<int>("c"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_to_unknown_region_throws()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
allocated.Write("unknown", 42));
|
||||
|
||||
Assert.Contains("does not exist", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_from_unknown_region_throws()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 4096);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
allocated.Read<int>("unknown"));
|
||||
|
||||
Assert.Contains("does not exist", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_T_throws_when_value_exceeds_allocation()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 100);
|
||||
allocated.AddRegion("boundary", 99);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
allocated.Read<int>("boundary"));
|
||||
|
||||
Assert.Equal("name", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_T_throws_when_value_exceeds_allocation()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 100);
|
||||
allocated.AddRegion("boundary", 99);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
allocated.Write("boundary", 42));
|
||||
|
||||
Assert.Equal("name", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadBytes_throws_when_count_exceeds_allocation()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 100);
|
||||
allocated.AddRegion("boundary", 99);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
allocated.ReadBytes("boundary", 2));
|
||||
|
||||
Assert.Equal("count", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteBytes_throws_when_span_exceeds_allocation()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 100);
|
||||
allocated.AddRegion("boundary", 99);
|
||||
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
allocated.WriteBytes("boundary", new byte[2]));
|
||||
|
||||
Assert.Equal("bytes", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_and_Write_at_exact_allocation_boundary_succeed()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
using var allocated = new AllocatedMemory(reader, 100);
|
||||
allocated.AddRegion("boundary", 96);
|
||||
|
||||
const int expected = unchecked((int)0xDEADBEEF);
|
||||
Assert.True(allocated.Write("boundary", expected));
|
||||
Assert.Equal(expected, allocated.Read<int>("boundary"));
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,15 @@ public class MemoryHardeningTests
|
||||
Assert.False(reader.Handle.IsInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalReader_throws_when_query_information_access_missing()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
new ExternalReader(Process.GetCurrentProcess(), ProcessAccess.VmRead));
|
||||
|
||||
Assert.Equal("desiredAccess", ex.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="MemoryBase"/> that serves bytes from an in-memory buffer and
|
||||
/// caps every read to <c>maxChunk</c> bytes, to exercise partial-read handling.
|
||||
@@ -160,6 +169,8 @@ public class MemoryHardeningTests
|
||||
{
|
||||
public override IntPtr ImageBase => IntPtr.Zero;
|
||||
public override SafeMemoryHandle Handle => null!;
|
||||
public override bool Is64Bit => Environment.Is64BitProcess;
|
||||
public override int ProcessId => Environment.ProcessId;
|
||||
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Assembly;
|
||||
using WhiteMagic.Native;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RemoteModule"/> / <see cref="RemoteFunction"/> resolution and
|
||||
/// execution through the <see cref="Magic"/> facade (task 7.2).
|
||||
/// </summary>
|
||||
public class ModuleFunctionTests
|
||||
{
|
||||
// Ensure a module is loaded in this process before resolving it.
|
||||
private static IntPtr Load(string module)
|
||||
{
|
||||
IntPtr handle = NativeMethods.LoadLibrary(module);
|
||||
Assert.NotEqual(IntPtr.Zero, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Module_indexer_resolves_base_address()
|
||||
{
|
||||
IntPtr handle = Load("kernel32.dll");
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
RemoteModule module = magic["kernel32"];
|
||||
|
||||
// The module handle returned by LoadLibrary is the module's base address.
|
||||
Assert.Equal(handle, module.BaseAddress);
|
||||
Assert.Equal("KERNEL32.DLL", module.Name, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Function_indexer_resolves_direct_export()
|
||||
{
|
||||
IntPtr handle = Load("user32.dll");
|
||||
IntPtr expected = NativeMethods.GetProcAddress(handle, "MessageBoxA");
|
||||
Assert.NotEqual(IntPtr.Zero, expected);
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
RemoteFunction fn = magic["user32"]["MessageBoxA"];
|
||||
|
||||
Assert.Equal(expected, fn.Address);
|
||||
Assert.Equal("MessageBoxA", fn.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Function_indexer_follows_export_forwarder()
|
||||
{
|
||||
// kernel32!HeapAlloc is a classic forwarder to NTDLL.RtlAllocateHeap. Whatever the
|
||||
// OS loader resolves it to, our parser must reach the same final address.
|
||||
IntPtr handle = Load("kernel32.dll");
|
||||
IntPtr expected = NativeMethods.GetProcAddress(handle, "HeapAlloc");
|
||||
Assert.NotEqual(IntPtr.Zero, expected);
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
RemoteFunction fn = magic["kernel32"]["HeapAlloc"];
|
||||
|
||||
Assert.Equal(expected, fn.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Module_indexer_throws_for_unloaded_module()
|
||||
{
|
||||
using var magic = Magic.OpenInProcess();
|
||||
Assert.Throws<DllNotFoundException>(() => magic["definitely-not-loaded-xyz.dll"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Function_indexer_throws_for_unknown_export()
|
||||
{
|
||||
Load("kernel32.dll");
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
Assert.Throws<InvalidOperationException>(() => magic["kernel32"]["NoSuchExport_ZZZ"]);
|
||||
}
|
||||
|
||||
private delegate uint GetCurrentProcessIdDelegate();
|
||||
|
||||
[Fact]
|
||||
public void CreateDelegate_throws_for_external_session()
|
||||
{
|
||||
Load("kernel32.dll");
|
||||
|
||||
// External reader (even to self): the address is not treated as host-mapped, so a
|
||||
// delegate to it is rejected rather than handed back to AV on invocation.
|
||||
using var magic = Magic.Open(Process.GetCurrentProcess());
|
||||
RemoteFunction fn = magic["kernel32"]["GetCurrentProcessId"];
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => fn.CreateDelegate<GetCurrentProcessIdDelegate>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDelegate_invokes_function_in_process()
|
||||
{
|
||||
Load("kernel32.dll");
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
var getPid = magic["kernel32"]["GetCurrentProcessId"].CreateDelegate<GetCurrentProcessIdDelegate>();
|
||||
|
||||
Assert.Equal((uint)Process.GetCurrentProcess().Id, getPid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolved_function_executes_via_remote_thread()
|
||||
{
|
||||
Load("kernel32.dll");
|
||||
|
||||
using var magic = Magic.OpenInProcess();
|
||||
RemoteFunction getPid = magic["kernel32"]["GetCurrentProcessId"];
|
||||
|
||||
// GetCurrentProcessId takes no args and is thread-agnostic; a remote thread in our
|
||||
// own process must report our PID.
|
||||
uint pid = getPid.Execute<uint>(CallConvention.Stdcall);
|
||||
Assert.Equal((uint)Process.GetCurrentProcess().Id, pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Diagnostics;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.ProcessEnvironment;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.ProcessEnvironment;
|
||||
|
||||
public sealed class ManagedPebTests
|
||||
{
|
||||
[Fact]
|
||||
public void Read_current_process_peb_fields_returns_plausible_values()
|
||||
{
|
||||
using var magic = Magic.OpenInProcess();
|
||||
var peb = new ManagedPeb(magic.Memory);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, peb.ReadPebAddress());
|
||||
Assert.NotEqual(IntPtr.Zero, peb.ReadImageBaseAddress());
|
||||
|
||||
byte beingDebugged = peb.ReadBeingDebugged();
|
||||
Assert.True(beingDebugged == 0 || beingDebugged == 1);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, peb.ReadLdrAddress());
|
||||
|
||||
// The in-process test process is native to the host architecture, so
|
||||
// it is not running under WOW64.
|
||||
Assert.False(peb.ReadIsWow64Process());
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,61 @@ public class StringReadWriteTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UTF-16 null terminator split across the 64-byte chunk boundary must still be found.
|
||||
/// The first chunk ends at byte 63, so the null bytes at 64/65 are in the second chunk.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReadString_utf16_null_across_chunk_boundary_is_found()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[256];
|
||||
|
||||
// 32 'A' UTF-16 chars = 64 bytes, no embedded null.
|
||||
byte[] text = Encoding.Unicode.GetBytes(new string('A', 32));
|
||||
Assert.Equal(64, text.Length);
|
||||
text.CopyTo(slot, 0);
|
||||
|
||||
// Null terminator at bytes 64/65.
|
||||
slot[64] = 0x00;
|
||||
slot[65] = 0x00;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 256);
|
||||
Assert.Equal(new string('A', 32), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A byte sequence that looks like a null at a misaligned offset must not stop the scan.
|
||||
/// "A" + U+4200 produces bytes 41 00 00 42 00 00; bytes 1-2 are an aligned-position null
|
||||
/// only if scanned byte-by-byte. The aligned UTF-16 scan must see the real terminator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReadString_utf16_does_not_stop_at_misaligned_null()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = Encoding.Unicode.GetBytes("A\u4200\0");
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 64);
|
||||
Assert.Equal("A\u4200", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_empty_string_writes_only_null()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Native;
|
||||
using WhiteMagic.ThreadEnvironment;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.ThreadEnvironment;
|
||||
|
||||
public sealed class ManagedTebTests
|
||||
{
|
||||
[Fact]
|
||||
public void Read_current_thread_teb_fields_returns_plausible_values()
|
||||
{
|
||||
using var magic = Magic.OpenInProcess();
|
||||
using var teb = new ManagedTeb(magic.Memory, (int)NativeMethods.GetCurrentThreadId());
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
|
||||
Assert.NotEqual(IntPtr.Zero, teb.ReadStackBase());
|
||||
Assert.NotEqual(IntPtr.Zero, teb.ReadStackLimit());
|
||||
|
||||
// The stack grows down, so the base is above the limit on x86/x64.
|
||||
Assert.True((nuint)teb.ReadStackBase() > (nuint)teb.ReadStackLimit());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Windows;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.Windows;
|
||||
|
||||
public sealed class WindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetWindows_returns_at_least_one_top_level_window()
|
||||
{
|
||||
var windows = WindowFactory.GetWindows().ToList();
|
||||
Assert.NotEmpty(windows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWindowsByClassName_filters_to_matching_classes()
|
||||
{
|
||||
var all = WindowFactory.GetWindows().ToList();
|
||||
if (all.Count == 0)
|
||||
return;
|
||||
|
||||
string firstClass = all[0].ClassName;
|
||||
if (string.IsNullOrEmpty(firstClass))
|
||||
return;
|
||||
|
||||
var filtered = WindowFactory.GetWindowsByClassName(firstClass).ToList();
|
||||
Assert.All(filtered, w => Assert.Equal(firstClass, w.ClassName));
|
||||
Assert.True(filtered.Count <= all.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteWindow_can_query_and_manipulate_a_test_window()
|
||||
{
|
||||
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
// The xUnit runner may not expose a main window; skip destructive
|
||||
// manipulation but still validate factory enumeration above.
|
||||
return;
|
||||
}
|
||||
|
||||
var window = new RemoteWindow(handle);
|
||||
Assert.Equal(handle, window.Handle);
|
||||
|
||||
string text = window.Text;
|
||||
Assert.NotNull(text);
|
||||
|
||||
string originalTitle = window.Title;
|
||||
Assert.Equal(text, originalTitle);
|
||||
|
||||
// Move and resize, then restore the original position.
|
||||
bool moved = window.MoveResize(10, 10, 400, 300);
|
||||
Assert.True(moved);
|
||||
|
||||
bool flashed = window.Flash();
|
||||
Assert.True(flashed);
|
||||
|
||||
// Restore a sensible size without asserting exact title restoration;
|
||||
// terminal windows often ignore SetWindowText.
|
||||
bool restored = window.MoveResize(0, 0, 800, 600);
|
||||
Assert.True(restored);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"metadata": [
|
||||
{
|
||||
"src": [
|
||||
{
|
||||
"files": ["WhiteMagic/**/*.csproj"],
|
||||
"exclude": ["**/bin/**", "**/obj/**"]
|
||||
}
|
||||
],
|
||||
"dest": "api",
|
||||
"includePrivateMembers": false,
|
||||
"monikers": ["net8.0-windows"]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"content": [
|
||||
{
|
||||
"files": [
|
||||
"docs/**/*.md",
|
||||
"toc.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"api/**.yml",
|
||||
"api/index.md"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resource": [
|
||||
{
|
||||
"files": [
|
||||
"images/**",
|
||||
"styles/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"overwrite": [
|
||||
{
|
||||
"files": [
|
||||
"apidoc/**.md"
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalMetadata": {
|
||||
"_appName": "WhiteMagic",
|
||||
"_appTitle": "WhiteMagic API Reference",
|
||||
"_enableSearch": true,
|
||||
"_disableContribution": false,
|
||||
"pdf": false
|
||||
},
|
||||
"fileMetadata": {},
|
||||
"template": [
|
||||
"default"
|
||||
],
|
||||
"dest": "_site",
|
||||
"force": false,
|
||||
"keepFileLink": false,
|
||||
"warningLevel": "warning"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
# WhiteMagic Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
WhiteMagic is built as a layered architecture that provides multiple levels of abstraction over Windows process-introspection APIs. This design allows consumers to choose the right level of control for their use case, from low-level memory operations to high-level ergonomic APIs.
|
||||
|
||||
## Layer Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Magic (Facade) │
|
||||
│ High-level entry point: Magic.Open(), Magic.OpenInProcess() │
|
||||
└───────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ │
|
||||
┌───────▼─────────┐ ┌────────▼────────┐
|
||||
│ MemoryBase │ │ High-Level API │
|
||||
│ (Abstract) │ │ │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ ExternalReader │ │ RemotePointer │
|
||||
│ InProcessReader │ │ RemoteModule │
|
||||
│ │ │ RemoteFunction │
|
||||
│ ┌──────────────┐│ │ ManagedPeb/TEB │
|
||||
│ │ Hooking ││ │ Window/Input │
|
||||
│ │ DetourMgr ││ └─────────────────┘
|
||||
│ │ PatchMgr ││
|
||||
│ └──────────────┘│
|
||||
└─────────┬───────┘
|
||||
│
|
||||
┌─────▼─────┬───────────┬───────────┐
|
||||
│ │ │ │
|
||||
┌───▼────┐ ┌────▼──┐ ┌──────▼──┐ ┌──────▼───┐
|
||||
│Native │ │Discov.│ │Execution│ │Assembly │
|
||||
│P/Invoke│ │Scan/PE│ │3-tier │ │IAssembler│
|
||||
└────────┘ └───────┘ └─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Core Layer
|
||||
|
||||
### MemoryBase (Abstract)
|
||||
|
||||
The foundation of WhiteMagic is the `MemoryBase` abstract class, which defines the contract for all memory operations:
|
||||
|
||||
**Key responsibilities:**
|
||||
- Abstract memory read/write operations
|
||||
- Host for `MarshalCache<T>` optimization
|
||||
- Owner of `PatchManager` and `DetourManager`
|
||||
- Relative/absolute addressing support
|
||||
|
||||
**Design decision (D1):** Dual-mode readers share the same abstract interface, allowing high-level code (pattern scanning, patching, etc.) to work in both external and in-process modes without changes.
|
||||
|
||||
### ExternalReader
|
||||
|
||||
Implements `MemoryBase` for out-of-process operations:
|
||||
|
||||
**Mechanism:** Uses `ReadProcessMemory`/`WriteProcessMemory` via P/Invoke
|
||||
**Handle:** `SafeMemoryHandle` to target process
|
||||
**Use case:** Primary mode for automation hosts
|
||||
|
||||
### InProcessReader
|
||||
|
||||
Implements `MemoryBase` for injected code:
|
||||
|
||||
**Mechanism:** Uses `ReadProcessMemory`/`WriteProcessMemory` on self-handle
|
||||
**Handle:** `SafeMemoryHandle` to current process
|
||||
**Use case:** Once a managed DLL is injected, enables delegate calls and detours
|
||||
|
||||
**Design decision (D1 revision):** InProcessReader uses RPM-on-self instead of unsafe direct pointer deref because .NET cannot catch `AccessViolationException`, so a bad deref kills the host with no soft-failure path. The in-process speed win moves to the delegate-call and detour paths, not the reader.
|
||||
|
||||
## Discovery Layer
|
||||
|
||||
### PatternScanner
|
||||
|
||||
Scans target memory for byte patterns with wildcard support:
|
||||
|
||||
**Features:**
|
||||
- IDA-style hex patterns (`48 8B ? ? ? ? ?`)
|
||||
- Optional caching to avoid repeated scans
|
||||
- Module-relative or absolute addressing
|
||||
|
||||
### PeHeaderParser
|
||||
|
||||
Parses PE headers to extract export information:
|
||||
|
||||
**Features:**
|
||||
- Export table parsing
|
||||
- Forwarder resolution (e.g., `kernel32!HeapAlloc` → `NTDLL.RtlAllocateHeap`)
|
||||
- Ordinal export support
|
||||
|
||||
## Execution Layer (Three-Tier Model)
|
||||
|
||||
### RemoteThreadExecutor
|
||||
|
||||
**Mechanism:** `CreateRemoteThread` + hand-assembled convention stubs
|
||||
**Use for:** Thread-agnostic payloads (pure WinAPI, self-contained code, DLL injection)
|
||||
**Risk:** NOT crash-safe for single-threaded target state
|
||||
|
||||
### MainThreadPump
|
||||
|
||||
**Mechanism:** Detour on per-frame function + work queue
|
||||
**Use for:** State-sensitive calls (default for target-state access)
|
||||
**Safety:** Crash-safe — runs on target's own thread
|
||||
|
||||
**How it works:**
|
||||
1. Installs a detour on a per-frame function (e.g., D3D `EndScene`)
|
||||
2. Each frame, the hook drains a thread-safe queue
|
||||
3. Work items run synchronously in target's context
|
||||
4. Results/exceptions returned via `TaskCompletionSource`
|
||||
|
||||
### InProcessInvoker
|
||||
|
||||
**Mechanism:** `Marshal.GetDelegateForFunctionPointer`
|
||||
**Use for:** In-process delegates after injection
|
||||
**Benefit:** Zero thread-crossing overhead
|
||||
|
||||
## Hooking Layer
|
||||
|
||||
### DetourManager
|
||||
|
||||
**Scope:** In-process only
|
||||
**Features:**
|
||||
- Inline `E9 jmp` detours over function prologues
|
||||
- `CallOriginal` support via trampoline
|
||||
- `Apply`/`Remove` operations
|
||||
- Auto-restore on `Dispose`
|
||||
|
||||
**Safety:** Prologue validation via `IAssembler.GetPrologueLength` (optional Iced backend)
|
||||
|
||||
### PatchManager
|
||||
|
||||
**Scope:** Both external and in-process
|
||||
**Features:**
|
||||
- Named byte patches
|
||||
- `Apply`/`Remove`/`IsApplied`
|
||||
- Auto-restore on `Dispose`
|
||||
|
||||
## Assembly Layer
|
||||
|
||||
### IAssembler Seam
|
||||
|
||||
Abstracts text → machine code generation:
|
||||
|
||||
**Implementations:**
|
||||
- `StubAssembler` (default): Hand-emits calling-convention stubs, no dependency
|
||||
- `IcedAssembler` (optional): Wraps Iced for arbitrary assembly
|
||||
|
||||
**Design decision (D3):** Keeps default configuration dependency-free; only callers needing arbitrary asm opt into Iced.
|
||||
|
||||
### Convention Stubs
|
||||
|
||||
`StubAssembler` emits x86/x64 calling-convention trampolines:
|
||||
|
||||
**Conventions supported:** cdecl, stdcall, thiscall, fastcall, x64 (Microsoft)
|
||||
**Encoding:** Deterministic byte emission, no parsing required
|
||||
|
||||
## High-Level Layer
|
||||
|
||||
### RemotePointer
|
||||
|
||||
Indexer-based pointer arithmetic:
|
||||
|
||||
```csharp
|
||||
var ptr = magic[baseAddress];
|
||||
int value = ptr.Read<int>(offset);
|
||||
ptr.Write(999, offset);
|
||||
```
|
||||
|
||||
### RemoteModule/RemoteFunction
|
||||
|
||||
Module and export resolution:
|
||||
|
||||
```csharp
|
||||
var module = magic["user32"];
|
||||
var fn = module["MessageBoxA"];
|
||||
int result = fn.Execute<int>(CallConvention.Stdcall, args...);
|
||||
```
|
||||
|
||||
### ManagedPeb/ManagedTeb
|
||||
|
||||
Managed views of Process Environment Block and Thread Environment Block:
|
||||
|
||||
**Features:**
|
||||
- Typed field access
|
||||
- No manual structure marshalling
|
||||
|
||||
### Window/Input
|
||||
|
||||
Window mutation and input simulation:
|
||||
|
||||
**Features:**
|
||||
- Move/resize/title/activate/flash windows
|
||||
- Keyboard/mouse input without focus (where supported)
|
||||
|
||||
## Optimization Layer
|
||||
|
||||
### MarshalCache<T>
|
||||
|
||||
Per-type metadata caching:
|
||||
|
||||
**Cached data:**
|
||||
- `Size` (managed blittable width)
|
||||
- `MarshalSize` (unmanaged interop width)
|
||||
- `TypeRequiresMarshal` (needs `Marshal.PtrToStructure`)
|
||||
- `IsIntPtr` (for special handling)
|
||||
|
||||
**Performance:** Avoids per-call reflection and `Marshal.SizeOf` overhead
|
||||
|
||||
## Thread Safety
|
||||
|
||||
### ExternalReader
|
||||
|
||||
**Thread-safe:** Yes (RPM/WPM are thread-safe)
|
||||
**Synchronization:** None required
|
||||
|
||||
### InProcessReader
|
||||
|
||||
**Thread-safe:** Yes (RPM-on-self is thread-safe)
|
||||
**Synchronization:** None required
|
||||
|
||||
### MainThreadPump
|
||||
|
||||
**Thread-safe:** Yes (uses `ConcurrentQueue`)
|
||||
**Synchronization:** Lock-free queue + `TaskCompletionSource`
|
||||
|
||||
### DetourManager/PatchManager
|
||||
|
||||
**Thread-safe:** No
|
||||
**Synchronization:** Caller must synchronize
|
||||
|
||||
## Lifetime Management
|
||||
|
||||
All disposable resources follow RAII:
|
||||
|
||||
```csharp
|
||||
using var magic = Magic.Open(process);
|
||||
// All handles, patches, and detours auto-restore on disposal
|
||||
```
|
||||
|
||||
**Resources cleaned up:**
|
||||
- Process handles (`SafeMemoryHandle`)
|
||||
- Applied patches (`PatchManager`)
|
||||
- Active detours (`DetourManager`)
|
||||
- Allocated memory (`AllocatedMemory`)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Explicit failures, silent success
|
||||
|
||||
- **Read operations:** Throw on failure (Win32Exception)
|
||||
- **Write operations:** Return `false` on failure
|
||||
- **String reads:** Return `string.Empty` on failure
|
||||
- **Invalid handles:** Throw `InvalidOperationException`
|
||||
|
||||
**Rationale:** Writes to another process can legitimately fail (protection changed, process exited); silent retry is often the right strategy. Reads are typically expected to succeed, so exceptions surface the problem immediately.
|
||||
|
||||
## Extensibility Points
|
||||
|
||||
### IAssembler
|
||||
|
||||
Plug in custom assemblers:
|
||||
|
||||
```csharp
|
||||
public interface IAssembler
|
||||
{
|
||||
byte[] Assemble(string assemblyText, ulong origin = 0);
|
||||
}
|
||||
```
|
||||
|
||||
### DetourManager.PrologueLengthResolver
|
||||
|
||||
Custom prologue validation:
|
||||
|
||||
```csharp
|
||||
magic.DetourManager.PrologueLengthResolver = (address, minBytes) =>
|
||||
// Custom validation logic
|
||||
return requiredLength;
|
||||
```
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Memory Access
|
||||
|
||||
| Operation | Cost | Notes |
|
||||
|-----------|------|-------|
|
||||
| `Read<T>` (blittable) | Low | `MemoryMarshal.Read`, no alloc |
|
||||
| `Read<T>` (marshalled) | Medium | `Marshal.PtrToStructure` |
|
||||
| `Read<T>` (array) | Medium-High | Per-element marshalling |
|
||||
| `ReadBytes` | Low | Direct buffer copy |
|
||||
| `ReadString` | Medium | Encoding + allocation |
|
||||
|
||||
### Execution
|
||||
|
||||
| Method | Latency | Safety |
|
||||
|--------|---------|--------|
|
||||
| `RemoteThreadExecutor` | ~1-2ms | Thread-agnostic only |
|
||||
| `MainThreadPump` | ~1 frame (16-33ms) | Crash-safe |
|
||||
| `InProcessInvoker` | <1μs | In-process only |
|
||||
|
||||
### Pattern Scanning
|
||||
|
||||
| Mode | Cost | Notes |
|
||||
|------|------|-------|
|
||||
| Uncached | High | Scans entire module |
|
||||
| Cached | Low | O(1) after first scan |
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Safety by default:** MainThreadPump prevents crashes from thread-affinity violations
|
||||
2. **Bitness-agnostic:** Works on x86 and x64 without code changes
|
||||
3. **No native dependencies:** Default configuration is pure managed
|
||||
4. **Explicit operations:** Clear failure modes, no hidden retries
|
||||
5. **Resource safety:** RAII-based cleanup prevents leaks
|
||||
6. **Extensibility:** Seam points for assemblers, validators
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Execution Models](./execution-models.md) — Deep dive on the three-tier execution strategy
|
||||
- [Memory Access](./memory-access.md) — MemoryBase readers and MarshalCache optimization
|
||||
- [Function Hooking](./hooking.md) — DetourManager and PatchManager internals
|
||||
- [Assembly Seam](./assembly-seam.md) — IAssembler abstraction and Iced integration
|
||||
@@ -0,0 +1,333 @@
|
||||
# Execution Models in WhiteMagic
|
||||
|
||||
WhiteMagic provides three distinct execution strategies, each designed for specific payload safety requirements. Choosing the right model is critical for avoiding crashes and ensuring reliable operation.
|
||||
|
||||
## The Problem: Thread-Affinity Crashes
|
||||
|
||||
When automating or debugging a target application, the most common failure mode is calling target functions on the wrong thread. Many applications (especially games, UI apps, and applications with scripting engines) have **single-threaded state**:
|
||||
|
||||
- Scripting VMs (Lua, Python, custom engines)
|
||||
- Render contexts (Direct3D, OpenGL)
|
||||
- Object models and state machines
|
||||
- UI message pumps
|
||||
|
||||
If you call these functions from a thread you created via `CreateRemoteThread`, they race the target's main thread → memory corruption → crash.
|
||||
|
||||
**WhiteMagic's solution:** Split execution by payload safety, making the crash-safe path the default.
|
||||
|
||||
---
|
||||
|
||||
## Model 1: RemoteThreadExecutor (CreateRemoteThread)
|
||||
|
||||
### Mechanism
|
||||
|
||||
Creates a new thread in the target process via `CreateRemoteThread`, executes a call stub, and waits for the exit code.
|
||||
|
||||
### Use Cases ✅
|
||||
|
||||
**SAFE for thread-agnostic payloads:**
|
||||
- Pure WinAPI calls (`GetTickCount`, `GetCurrentProcessId`)
|
||||
- Self-contained computations
|
||||
- `LoadLibrary` (DLL injection)
|
||||
- Code that touches only memory you own
|
||||
|
||||
### Avoid ❌
|
||||
|
||||
**UNSAFE for single-threaded target state:**
|
||||
- Scripting engine entry points
|
||||
- Render operations (Direct3D calls)
|
||||
- Game state queries/modifications
|
||||
- UI interactions
|
||||
- Object model traversal
|
||||
|
||||
### Usage Example
|
||||
|
||||
```csharp
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Assembly;
|
||||
|
||||
using var magic = Magic.Open(targetProcess);
|
||||
|
||||
// Example: Call GetTickCount (thread-safe WinAPI)
|
||||
var getTickCount = magic["kernel32"]["GetTickCount"];
|
||||
uint ticks = getTickCount.Execute<uint>(CallConvention.Stdcall);
|
||||
|
||||
// Example: Call a function that doesn't touch thread-local state
|
||||
int result = magic.RemoteThread.Execute<int>(
|
||||
functionAddress,
|
||||
CallConvention.Cdecl,
|
||||
arg1, arg2, arg3
|
||||
);
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
- **Latency:** ~1-2ms (thread creation + execution + join)
|
||||
- **Throughput:** Limited by thread creation overhead
|
||||
- **Best for:** One-shot calls, initialization, DLL injection
|
||||
|
||||
### Risks
|
||||
|
||||
- **Crash risk:** HIGH if touching single-threaded state
|
||||
- **Detection:** Easily detected by anti-cheat (new thread creation)
|
||||
- **Overhead:** Thread creation is not cheap
|
||||
|
||||
---
|
||||
|
||||
## Model 2: MainThreadPump (Crash-Safe) ⭐
|
||||
|
||||
### Mechanism
|
||||
|
||||
Installs a detour on a **per-frame function** (a function called every frame, like D3D's `EndScene`) and drains a thread-safe work queue there each frame.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Detour installation:** Hook a per-frame function at `frameAddress`
|
||||
2. **Queue enqueue:** Caller enqueues a delegate via `Enqueue<T>()`
|
||||
3. **Frame execution:** Target's main thread runs the hook each frame
|
||||
4. **Queue drain:** Hook checks queue, executes pending work items
|
||||
5. **Result return:** `TaskCompletionSource` delivers result/exception
|
||||
|
||||
### Use Cases ✅
|
||||
|
||||
**SAFE for state-sensitive calls:**
|
||||
- Game state modifications (health, position, inventory)
|
||||
- Scripting engine calls
|
||||
- UI interactions
|
||||
- Render operations
|
||||
- Object model traversal
|
||||
- **Any function that assumes main-thread context**
|
||||
|
||||
### Usage Example
|
||||
|
||||
```csharp
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Execution;
|
||||
|
||||
using var magic = Magic.Open(targetProcess);
|
||||
|
||||
// Create a pump that hooks a per-frame function
|
||||
// (e.g., D3D9 EndScene, or any per-frame handler)
|
||||
var pump = magic.CreateMainThreadPump(frameAddress);
|
||||
|
||||
// Enqueue work that runs on the target's main thread
|
||||
int health = await pump.Enqueue(() =>
|
||||
{
|
||||
// Safe to touch game state here
|
||||
return magic.Memory.Read<int>(healthAddress);
|
||||
});
|
||||
|
||||
// Modify game state safely
|
||||
await pump.Enqueue(() =>
|
||||
{
|
||||
magic.Memory.Write(healthAddress, 999);
|
||||
});
|
||||
|
||||
// Call a function that requires main-thread context
|
||||
var result = await pump.Enqueue(() =>
|
||||
{
|
||||
var fn = magic["target"]["ProcessInput"];
|
||||
return fn.Execute<int>(CallConvention.ThisCall, inputPtr);
|
||||
});
|
||||
```
|
||||
|
||||
### Finding a Frame Function
|
||||
|
||||
**Common per-frame functions:**
|
||||
- Direct3D 9: `EndScene` (device + 0x44 vtable entry)
|
||||
- Direct3D 11: Present callbacks
|
||||
- OpenGL: SwapBuffers callbacks
|
||||
- Custom: Many games have a `Update` or `Render` function per frame
|
||||
|
||||
**Helper for D3D9:**
|
||||
```csharp
|
||||
// Find D3D9 device and resolve EndScene
|
||||
IntPtr d3dDevice = FindD3D9Device(magic);
|
||||
IntPtr endScene = magic.Memory.Read<IntPtr>(d3dDevice + 0x44); // VTable
|
||||
|
||||
var pump = magic.CreateMainThreadPump(endScene);
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
- **Latency:** ~1 frame (16-33ms at 30-60 FPS)
|
||||
- **Throughput:** Limited by frame rate and work item duration
|
||||
- **Best for:** Repeated state-sensitive calls, game mods, automation
|
||||
|
||||
### Safety Features
|
||||
|
||||
- **Crash-safe:** Runs on target's own thread
|
||||
- **Exception propagation:** Exceptions in work items propagate to caller
|
||||
- **Timeout handling:** Can detect wedged work items
|
||||
- **Queue bounded:** Prevents unlimited queue growth
|
||||
|
||||
### Risks
|
||||
|
||||
- **Frame overhead:** Hook adds per-frame overhead (keep work items short)
|
||||
- **Wedged work item:** A stuck work item stalls the frame (detectable via timeout)
|
||||
- **Frame function needed:** Requires finding a per-frame function (application-specific)
|
||||
|
||||
---
|
||||
|
||||
## Model 3: InProcessInvoker (Direct Delegates)
|
||||
|
||||
### Mechanism
|
||||
|
||||
Once a managed DLL is injected into the target, creates native delegates via `Marshal.GetDelegateForFunctionPointer` and calls them directly.
|
||||
|
||||
### Use Cases ✅
|
||||
|
||||
**When injected in-process:**
|
||||
- Direct function calls with zero thread crossing
|
||||
- High-performance repeated calls
|
||||
- Full .NET interop capabilities
|
||||
|
||||
### Usage Example
|
||||
|
||||
```csharp
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Execution;
|
||||
|
||||
// Only works when injected in-process
|
||||
using var magic = Magic.OpenInProcess();
|
||||
|
||||
// Create a delegate to a native function
|
||||
var getName = magic.Memory.CreateFunction<GetNameDelegate>(
|
||||
getNameAddress
|
||||
);
|
||||
|
||||
// Call directly as a delegate
|
||||
string name = getName(12345);
|
||||
|
||||
// Delegate signature
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate string GetNameDelegate(int id);
|
||||
```
|
||||
|
||||
### When to Use
|
||||
|
||||
- **After injection:** Your managed DLL is already in the target
|
||||
- **Performance-critical:** Need sub-microsecond call latency
|
||||
- **Complex interop:** Need to pass complex structures or callbacks
|
||||
|
||||
### Performance
|
||||
|
||||
- **Latency:** <1μs (direct function call)
|
||||
- **Throughput:** Highest (no thread crossing)
|
||||
- **Best for:** In-process tools, profilers, injected helpers
|
||||
|
||||
### Limitations
|
||||
|
||||
- **In-process only:** Requires managed DLL injection
|
||||
- **No crash-safety benefit:** Still subject to thread-affinity issues
|
||||
- **Requires loader:** Need a CLR host or injection bootstrapper
|
||||
|
||||
---
|
||||
|
||||
## Choosing the Right Model
|
||||
|
||||
### Decision Flowchart
|
||||
|
||||
```
|
||||
Are you injected in-process?
|
||||
├─ Yes → Use InProcessInvoker (direct delegates)
|
||||
└─ No → Does the call touch single-threaded state?
|
||||
├─ Yes → Use MainThreadPump (crash-safe)
|
||||
└─ No → Use RemoteThreadExecutor (CreateRemoteThread)
|
||||
```
|
||||
|
||||
### Practical Guidelines
|
||||
|
||||
| Scenario | Model | Reason |
|
||||
|----------|-------|--------|
|
||||
| DLL injection | RemoteThreadExecutor | `LoadLibrary` is thread-safe |
|
||||
| Read health bar | MainThreadPump | Game state is main-thread-affine |
|
||||
| Call GetTickCount | RemoteThreadExecutor | WinAPI, no thread affinity |
|
||||
| Script engine call | MainThreadPump | Script VM is main-thread-only |
|
||||
| Injected profiling | InProcessInvoker | Already in-process, max performance |
|
||||
| Window mutation | RemoteThreadExecutor | WinAPI `SetWindowPos` is thread-safe |
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
❌ **Wrong:** Using `RemoteThreadExecutor` for game state reads
|
||||
```csharp
|
||||
// UNSAFE: Will crash in most games
|
||||
int health = magic.RemoteThread.Execute<int>(
|
||||
readHealthFn,
|
||||
CallConvention.Cdecl
|
||||
);
|
||||
```
|
||||
|
||||
✅ **Right:** Using `MainThreadPump` for game state
|
||||
```csharp
|
||||
// SAFE: Runs on game's main thread
|
||||
int health = await pump.Enqueue(() =>
|
||||
magic.Memory.Read<int>(healthAddress)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison Summary
|
||||
|
||||
| Feature | RemoteThreadExecutor | MainThreadPump | InProcessInvoker |
|
||||
|---------|---------------------|----------------|------------------|
|
||||
| **Safety** | Thread-agnostic only | Crash-safe | In-process context |
|
||||
| **Latency** | ~1-2ms | ~1 frame (16-33ms) | <1μs |
|
||||
| **Detection risk** | High (new thread) | Low (detour) | None (in-process) |
|
||||
| **Best for** | One-shot calls, DLL injection | State-sensitive calls | In-process tools |
|
||||
| **Setup cost** | Low | Medium (need frame fn) | High (need injection) |
|
||||
| **Throughput** | Low | Medium | Highest |
|
||||
|
||||
---
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Bypassing Anti-Cheat
|
||||
|
||||
**RemoteThreadExecutor** is easily detected (new thread creation). For stealth:
|
||||
|
||||
1. **Use MainThreadPump:** Detours are harder to detect than thread creation
|
||||
2. **Thread hijacking:** For one-shot calls, hijack an existing thread (see `CodeInjector`)
|
||||
|
||||
### Combining Models
|
||||
|
||||
```csharp
|
||||
// Use RemoteThreadExecutor to inject DLL
|
||||
magic.RemoteThread.Execute<IntPtr>(
|
||||
loadLibraryAddress,
|
||||
CallConvention.Stdcall,
|
||||
dllPathPtr
|
||||
);
|
||||
|
||||
// Now injected, switch to InProcessInvoker
|
||||
using var inProcess = Magic.OpenInProcess();
|
||||
var fn = inProcess.Memory.CreateFunction<MyDelegate>(address);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
int result = await pump.Enqueue(() =>
|
||||
magic.Memory.Read<int>(address)
|
||||
);
|
||||
}
|
||||
catch (AccessViolationException)
|
||||
{
|
||||
// Address not readable
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Work item wedged (stuck the frame)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture](./architecture.md) — Overall system design
|
||||
- [Function Hooking](./hooking.md) — DetourManager internals
|
||||
- [Memory Access](./memory-access.md) — MemoryBase and MarshalCache
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
# Function Hooking in WhiteMagic
|
||||
|
||||
WhiteMagic provides two types of runtime code modification: **inline detours** (function hooking) and **byte patches**. Both are reversible and automatically restored on disposal.
|
||||
|
||||
## Overview
|
||||
|
||||
| Feature | DetourManager | PatchManager |
|
||||
|---------|---------------|--------------|
|
||||
| **Scope** | In-process only | External + In-process |
|
||||
| **Mechanism** | Inline `jmp` over prologue | Named byte patch |
|
||||
| **Reversible** | ✅ Yes | ✅ Yes |
|
||||
| **Auto-restore** | ✅ Yes | ✅ Yes |
|
||||
| **Original call** | ✅ Via trampoline | ❌ No |
|
||||
|
||||
## DetourManager (Inline Function Hooking)
|
||||
|
||||
### What is a Detour?
|
||||
|
||||
An inline detour overwrites the first bytes of a function's prologue with a jump instruction (`jmp` or `push/ret`) that redirects execution to your hook delegate. The original bytes are saved in a **trampoline** that can call the original function.
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
Original function:
|
||||
│ push ebp
|
||||
│ mov ebp, esp
|
||||
│ sub esp, 0x10
|
||||
│ ... rest of function
|
||||
|
||||
After detour:
|
||||
│ jmp [hook_address] ← Overwrites prologue
|
||||
Trampoline:
|
||||
│ push ebp ← Saved original bytes
|
||||
│ mov ebp, esp
|
||||
│ jmp [original+5] ← Jumps back to original function
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **API interception:** Hook WinAPI functions (e.g., `CreateFileW` to monitor file access)
|
||||
- **Function replacement:** Replace a game function with your own logic
|
||||
- **Behavior modification:** Change parameters or return values
|
||||
- **Profiling/instrumentation:** Count calls, measure execution time
|
||||
|
||||
### Usage Example
|
||||
|
||||
```csharp
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Hooking;
|
||||
|
||||
// In-process only
|
||||
using var magic = Magic.OpenInProcess();
|
||||
|
||||
// Define your hook delegate
|
||||
[DllImport("kernel32.dll")]
|
||||
public delegate void SleepDelegate(uint dwMilliseconds);
|
||||
|
||||
public static void MySleep(uint ms)
|
||||
{
|
||||
Console.WriteLine($"Sleep called with {ms}ms");
|
||||
// Optionally call original
|
||||
// originalSleep(ms);
|
||||
}
|
||||
|
||||
// Apply the detour
|
||||
IntPtr sleepAddr = magic["kernel32"]["Sleep"].Address;
|
||||
var detour = magic.DetourManager.Detour(
|
||||
sleepAddr,
|
||||
(SleepDelegate)MySleep
|
||||
);
|
||||
|
||||
detour.Apply();
|
||||
|
||||
// ... use the hook
|
||||
|
||||
// Remove and restore original
|
||||
detour.Remove();
|
||||
```
|
||||
|
||||
### CallOriginal (Trampoline)
|
||||
|
||||
To call the original function from your hook:
|
||||
|
||||
```csharp
|
||||
static MyDetourDelegate Original = null!;
|
||||
|
||||
static void MyHook(int arg1, float arg2)
|
||||
{
|
||||
// Do something before
|
||||
Console.WriteLine($"Before: {arg1}, {arg2}");
|
||||
|
||||
// Call original
|
||||
int result = Original(arg1, arg2);
|
||||
|
||||
// Do something after
|
||||
Console.WriteLine($"After: {result}");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create detour with original
|
||||
var detour = magic.DetourManager.Detour(
|
||||
targetAddress,
|
||||
(MyDetourDelegate)MyHook,
|
||||
out var original
|
||||
);
|
||||
|
||||
Original = original;
|
||||
detour.Apply();
|
||||
```
|
||||
|
||||
### Prologue Safety
|
||||
|
||||
Before splicing a detour, WhiteMagic validates the prologue:
|
||||
|
||||
**Default (StubAssembler):**
|
||||
- Covers common x86/x64 prologue shapes:
|
||||
- `push ebp; mov ebp, esp`
|
||||
- `mov edi, edi` (hot-patch padding)
|
||||
- `sub rsp, XX` (x64 stack allocation)
|
||||
- Single-byte instructions (`nop`, `int3`)
|
||||
|
||||
**Optional (IcedAssembler):**
|
||||
- Full disassembly validation via Iced
|
||||
- Handles arbitrary prologues
|
||||
- Ensures splice lands on instruction boundaries
|
||||
|
||||
### Safety Considerations
|
||||
|
||||
**Risks:**
|
||||
- **Mid-instruction splice:** Crashes if prologue validation fails
|
||||
- **Race conditions:** Patching while code is running can crash
|
||||
- **Anti-cheat:** Detours are easily detected
|
||||
|
||||
**Mitigations:**
|
||||
- Validate prologue before patching
|
||||
- Pause threads during application (use `DetourManager.Apply(options)`)
|
||||
- Use `MainThreadPump` for crash-safe execution
|
||||
- Auto-restore on `Dispose`
|
||||
|
||||
### Thread Safety
|
||||
|
||||
**DetourManager is NOT thread-safe.**
|
||||
|
||||
```csharp
|
||||
// WRONG: Concurrent modifications
|
||||
Task.Run(() => detour1.Apply());
|
||||
Task.Run(() => detour2.Apply());
|
||||
|
||||
// RIGHT: Synchronize
|
||||
lock (magic.DetourManager)
|
||||
{
|
||||
detour1.Apply();
|
||||
detour2.Apply();
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Impact
|
||||
|
||||
- **Hook overhead:** ~5-10 CPU cycles (single jmp)
|
||||
- **Trampoline call:** ~20-30 cycles (jmp + trampoline execution)
|
||||
- **Best practice:** Keep hook delegates short
|
||||
|
||||
---
|
||||
|
||||
## PatchManager (Named Byte Patches)
|
||||
|
||||
### What is a Patch?
|
||||
|
||||
A patch is a named byte buffer that overwrites a region of memory. Unlike detours, patches can be any bytes and don't include a trampoline mechanism.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Patching constants:** Change a hardcoded value (e.g., max HP cap)
|
||||
- **NOP-ing code:** Remove instructions (e.g., bypass a check)
|
||||
- **Restoring bytes:** Undo anti-cheat modifications
|
||||
- **Hot-patching:** Patch the hot-patch region (common in WinAPI)
|
||||
|
||||
### Usage Example
|
||||
|
||||
```csharp
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Hooking;
|
||||
|
||||
using var magic = Magic.Open(targetProcess);
|
||||
|
||||
// Create a patch to NOP 5 bytes
|
||||
var patch = magic.PatchManager.Create(
|
||||
"MaxHealthCapPatch",
|
||||
address,
|
||||
new byte[] { 0x90, 0x90, 0x90, 0x90, 0x90 } // NOP x5
|
||||
);
|
||||
|
||||
patch.Apply();
|
||||
|
||||
// Check status
|
||||
if (patch.IsApplied)
|
||||
{
|
||||
Console.WriteLine("Patch applied");
|
||||
}
|
||||
|
||||
// Remove and restore
|
||||
patch.Remove();
|
||||
```
|
||||
|
||||
### VirtualProtect Dance
|
||||
|
||||
When applying a patch, `PatchManager` automatically:
|
||||
|
||||
1. Calls `VirtualProtectEx` to make region writable
|
||||
2. Writes the patch bytes
|
||||
3. Calls `VirtualProtectEx` to restore original protection
|
||||
|
||||
### Relative Addressing
|
||||
|
||||
Patches support relative addressing:
|
||||
|
||||
```csharp
|
||||
// Apply relative to module base
|
||||
var patch = magic.PatchManager.Create(
|
||||
"DataPatch",
|
||||
address, // Interpreted as relative if isRelative=true
|
||||
patchBytes,
|
||||
isRelative: true
|
||||
);
|
||||
```
|
||||
|
||||
### Thread Safety
|
||||
|
||||
**PatchManager is NOT thread-safe.** Synchronize concurrent modifications.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Management
|
||||
|
||||
Both managers auto-restore on disposal:
|
||||
|
||||
```csharp
|
||||
// All applied detours and patches auto-restore
|
||||
using (var magic = Magic.OpenInProcess())
|
||||
{
|
||||
var detour = magic.DetourManager.Detour(addr, hook);
|
||||
detour.Apply();
|
||||
|
||||
var patch = magic.PatchManager.Create("Patch", addr, bytes);
|
||||
patch.Apply();
|
||||
|
||||
// End of using block: detour.Remove() and patch.Remove() called automatically
|
||||
}
|
||||
```
|
||||
|
||||
### Explicit Disposal
|
||||
|
||||
```csharp
|
||||
var detour = magic.DetourManager.Detour(addr, hook);
|
||||
detour.Apply();
|
||||
|
||||
// Later
|
||||
detour.Remove(); // Restores original bytes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Memory Protection
|
||||
|
||||
### Handling Read-Only Memory
|
||||
|
||||
If the target region is protected (e.g., `.text` section), both managers use:
|
||||
|
||||
```csharp
|
||||
VirtualProtectEx(handle, address, size, PAGE_EXECUTE_READWRITE, out oldProt);
|
||||
// Write bytes
|
||||
VirtualProtectEx(handle, address, size, oldProt, out _);
|
||||
```
|
||||
|
||||
### Risks
|
||||
|
||||
- **Anti-cheat:** May detect protection changes
|
||||
- **Race conditions:** Other threads might execute during patch window
|
||||
- **Crashes:** If code executes during the brief writable window
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Conditional Patching
|
||||
|
||||
```csharp
|
||||
public class ConditionalPatch
|
||||
{
|
||||
private readonly Patch _patch;
|
||||
private bool _enabled;
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => _enabled;
|
||||
set
|
||||
{
|
||||
if (_enabled == value) return;
|
||||
|
||||
if (value)
|
||||
_patch.Apply();
|
||||
else
|
||||
_patch.Remove();
|
||||
|
||||
_enabled = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Chain Hooking
|
||||
|
||||
Multiple hooks on the same function:
|
||||
|
||||
```csharp
|
||||
// First hook
|
||||
var detour1 = magic.DetourManager.Detour(addr, Hook1);
|
||||
detour1.Apply(out var trampoline1);
|
||||
|
||||
// Second hook (trampoline from first)
|
||||
var detour2 = magic.DetourManager.Detour(
|
||||
trampoline1.TrampolineAddress,
|
||||
Hook2
|
||||
);
|
||||
detour2.Apply();
|
||||
|
||||
// Execution flow: Original → Hook2 → Hook1 → Trampoline1 → Original+5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison to Other Libraries
|
||||
|
||||
| Library | Detours | Patches | Auto-restore | In-process |
|
||||
|---------|---------|---------|---------------|------------|
|
||||
| **WhiteMagic** | ✅ | ✅ | ✅ | ✅ Required |
|
||||
| BlackMagic | ❌ | ❌ | ❌ | N/A |
|
||||
| MemorySharp | ❌ (planned) | ❌ (planned) | N/A | N/A |
|
||||
| GreyMagic | ✅ | ✅ | ✅ | ✅ Required |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Detour failed: prologue too short"
|
||||
|
||||
**Cause:** Function prologue is shorter than minimum required (5 bytes for x86 `jmp`, 14 bytes for x64 `push/ret`).
|
||||
|
||||
**Solution:**
|
||||
- Use a different function
|
||||
- Patch deeper into the function (after prologue)
|
||||
- For x86, use hot-patch area (`mov edi, edi` padding)
|
||||
|
||||
### "Patch failed: access violation"
|
||||
|
||||
**Cause:** Memory region is protected or invalid.
|
||||
|
||||
**Solution:**
|
||||
- Verify address is valid (use `Magic.Memory.CanRead`)
|
||||
- Ensure process has `PROCESS_VM_OPERATION` access
|
||||
- Check if anti-cheat is blocking writes
|
||||
|
||||
### "Crash after detour"
|
||||
|
||||
**Cause:** Prologue validation failed or mid-instruction splice.
|
||||
|
||||
**Solution:**
|
||||
- Enable Iced backend for full validation
|
||||
- Pause threads during application
|
||||
- Check if target is using code obfuscation
|
||||
|
||||
### "Hook not called"
|
||||
|
||||
**Cause:** Wrong function address or hook installed after target already called it.
|
||||
|
||||
**Solution:**
|
||||
- Verify address with debugger
|
||||
- Install hook early (before target uses function)
|
||||
- Check if target is using a different implementation (e.g., forwarded export)
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture](./architecture.md) — Hooking layer design
|
||||
- [Execution Models](./execution-models.md) — Safe hook execution
|
||||
- [Memory Access](./memory-access.md) — Reading/writing memory
|
||||
@@ -0,0 +1,481 @@
|
||||
# Memory Access in WhiteMagic
|
||||
|
||||
WhiteMagic provides a dual memory-access model through an abstract `MemoryBase` class, supporting both external (out-of-process) and in-process readers with optimized typed I/O.
|
||||
|
||||
## MemoryBase Architecture
|
||||
|
||||
### Abstract Interface
|
||||
|
||||
`MemoryBase` defines the contract for all memory operations:
|
||||
|
||||
```csharp
|
||||
public abstract class MemoryBase : IDisposable
|
||||
{
|
||||
// Abstract raw I/O
|
||||
public abstract byte[] ReadBytes(IntPtr address, int count, bool isRelative = false);
|
||||
public abstract int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false);
|
||||
|
||||
// Typed I/O
|
||||
public T Read<T>(IntPtr address, bool isRelative = false) where T : struct;
|
||||
public bool Write<T>(IntPtr address, T value, bool isRelative = false) where T : struct;
|
||||
|
||||
// Array I/O
|
||||
public T[] Read<T>(IntPtr address, int count, bool isRelative = false) where T : struct;
|
||||
public bool Write<T>(IntPtr address, T[] values, bool isRelative = false) where T : struct;
|
||||
|
||||
// String I/O
|
||||
public string ReadString(IntPtr address, Encoding encoding, int maxLength = 512);
|
||||
public bool WriteString(IntPtr address, string value, Encoding encoding);
|
||||
}
|
||||
```
|
||||
|
||||
### Concrete Implementations
|
||||
|
||||
#### ExternalReader
|
||||
|
||||
**Mechanism:** `ReadProcessMemory`/`WriteProcessMemory` via P/Invoke
|
||||
**Handle:** `SafeMemoryHandle` to target process
|
||||
**Use case:** Primary mode for automation hosts
|
||||
|
||||
```csharp
|
||||
using var magic = Magic.Open(targetProcess);
|
||||
// Uses ExternalReader internally
|
||||
```
|
||||
|
||||
#### InProcessReader
|
||||
|
||||
**Mechanism:** `ReadProcessMemory`/`WriteProcessMemory` on self-handle
|
||||
**Handle:** `SafeMemoryHandle` to current process
|
||||
**Use case:** Injected code, delegate calls, detours
|
||||
|
||||
**Design decision:** Uses RPM-on-self instead of unsafe direct pointer deref because .NET cannot catch `AccessViolationException`, so a bad deref kills the host with no soft-failure path. The in-process speed win moves to the delegate-call and detour paths, not the reader.
|
||||
|
||||
```csharp
|
||||
using var magic = Magic.OpenInProcess();
|
||||
// Uses InProcessReader internally
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typed I/O with MarshalCache<T>
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
`MarshalCache<T>` eliminates per-call reflection overhead by caching type metadata once:
|
||||
|
||||
```csharp
|
||||
public static class MarshalCache<T>
|
||||
{
|
||||
// Cached at static-constructor time
|
||||
public static readonly int Size; // Managed blittable width
|
||||
public static readonly int MarshalSize; // Unmanaged interop width
|
||||
public static readonly bool TypeRequiresMarshal; // Needs PtrToStructure
|
||||
public static readonly bool IsIntPtr; // Special handling for IntPtr
|
||||
}
|
||||
```
|
||||
|
||||
### Blittable vs Marshalled Types
|
||||
|
||||
**Blittable types** (no marshaling needed):
|
||||
- Primitives: `int`, `byte`, `float`, `double`, `bool` (1 byte managed)
|
||||
- Enums (if underlying type is blittable)
|
||||
- Structs containing only blittable fields
|
||||
- **Performance:** `MemoryMarshal.Read<T>` — zero allocation
|
||||
|
||||
**Marshalled types** (require `Marshal.PtrToStructure`):
|
||||
- `bool` (4 bytes in Win32 interop)
|
||||
- `char` (2 bytes ANSI marshaling)
|
||||
- Structs with `[MarshalAs]` attributes
|
||||
- Structs with inline `ByValTStr`/`ByValArray`
|
||||
- **Performance:** `Marshal.PtrToStructure` — allocates temporary copy
|
||||
|
||||
### Size vs MarshalSize
|
||||
|
||||
For most types, `Size == MarshalSize`. They differ when:
|
||||
|
||||
```csharp
|
||||
// Example: bool field
|
||||
struct MyStruct
|
||||
{
|
||||
public bool Flag; // 1 byte managed, 4 bytes Win32 BOOL
|
||||
public int Value;
|
||||
}
|
||||
|
||||
MarshalCache<MyStruct>.Size == 5; // Managed layout
|
||||
MarshalCache<MyStruct>.MarshalSize == 8; // Win32 BOOL is 4 bytes
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```csharp
|
||||
using var magic = Magic.Open(process);
|
||||
|
||||
// Blittable read (fast path)
|
||||
int health = magic.Memory.Read<int>(address);
|
||||
|
||||
// Marshalled read (slow path)
|
||||
var gameState = magic.Memory.Read<MyStruct>(address);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Addressing Modes
|
||||
|
||||
### Absolute Addressing (Default)
|
||||
|
||||
```csharp
|
||||
// Read at absolute address 0x12345678
|
||||
int value = magic.Memory.Read<int>(0x12345678);
|
||||
```
|
||||
|
||||
### Relative Addressing
|
||||
|
||||
Relative to module base (useful for ASRR):
|
||||
|
||||
```csharp
|
||||
// Read at module_base + 0x1000
|
||||
int value = magic.Memory.Read<int>(0x1000, isRelative: true);
|
||||
// Equivalent to:
|
||||
int value = magic.Memory.Read<int>(magic.Memory.ImageBase + 0x1000);
|
||||
```
|
||||
|
||||
### Using RemotePointer
|
||||
|
||||
The `RemotePointer` indexer provides fluent relative addressing:
|
||||
|
||||
```csharp
|
||||
var basePtr = magic[moduleBase];
|
||||
int offset1 = basePtr.Read<int>(0x1000);
|
||||
int offset2 = basePtr.Read<int>(0x2000);
|
||||
|
||||
// Chained offsets
|
||||
var nestedPtr = magic[basePtr.Read<IntPtr>(0x1000)];
|
||||
int value = nestedPtr.Read<int>(0x50);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## String I/O
|
||||
|
||||
### Reading Strings
|
||||
|
||||
```csharp
|
||||
// Read null-terminated ANSI string
|
||||
string ansi = magic.Memory.ReadString(
|
||||
address,
|
||||
Encoding.ASCII,
|
||||
maxLength: 256
|
||||
);
|
||||
|
||||
// Read null-terminated UTF-16 string
|
||||
string unicode = magic.Memory.ReadString(
|
||||
address,
|
||||
Encoding.Unicode,
|
||||
maxLength: 512
|
||||
);
|
||||
```
|
||||
|
||||
**Implementation:** Reads byte-by-byte until null terminator or `maxLength`.
|
||||
|
||||
### Writing Strings
|
||||
|
||||
```csharp
|
||||
// Write null-terminated string
|
||||
bool success = magic.Memory.WriteString(
|
||||
address,
|
||||
"Hello World",
|
||||
Encoding.ASCII
|
||||
);
|
||||
```
|
||||
|
||||
**Implementation:** Writes bytes + null terminator.
|
||||
|
||||
---
|
||||
|
||||
## Array I/O
|
||||
|
||||
### Reading Arrays
|
||||
|
||||
```csharp
|
||||
// Read 10 integers
|
||||
int[] values = magic.Memory.Read<int>(address, 10);
|
||||
|
||||
// Read struct array
|
||||
var enemies = magic.Memory.Read<EnemyStruct>(enemyListPtr, 50);
|
||||
```
|
||||
|
||||
**Performance:**
|
||||
- Blittable: `MemoryMarshal.Read<T>` in a loop (fast)
|
||||
- Marshalled: `Marshal.PtrToStructure<T>` per element (slow)
|
||||
|
||||
### Writing Arrays
|
||||
|
||||
```csharp
|
||||
// Write integer array
|
||||
int[] values = { 1, 2, 3, 4, 5 };
|
||||
magic.Memory.Write(address, values);
|
||||
|
||||
// Write struct array
|
||||
EnemyStruct[] enemies = GetEnemies();
|
||||
magic.Memory.Write(enemyListPtr, enemies);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raw Byte I/O
|
||||
|
||||
### Reading Bytes
|
||||
|
||||
```csharp
|
||||
// Read 100 bytes
|
||||
byte[] buffer = magic.Memory.ReadBytes(address, 100);
|
||||
|
||||
// Read with relative addressing
|
||||
byte[] code = magic.Memory.ReadBytes(offset, count, isRelative: true);
|
||||
```
|
||||
|
||||
### Writing Bytes
|
||||
|
||||
```csharp
|
||||
// Write byte array
|
||||
byte[] patchBytes = { 0x90, 0x90, 0x90 }; // NOP x3
|
||||
int written = magic.Memory.WriteBytes(address, patchBytes);
|
||||
|
||||
// Write with relative addressing
|
||||
int written = magic.Memory.WriteBytes(
|
||||
offset,
|
||||
new byte[] { 0x01, 0x02, 0x03 },
|
||||
isRelative: true
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Read Operations
|
||||
|
||||
**Strategy:** Explicit failures → throw exceptions
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
int value = magic.Memory.Read<int>(address);
|
||||
}
|
||||
catch (Win32Exception ex)
|
||||
{
|
||||
// ReadProcessMemory failed (access violation, process exited, etc.)
|
||||
Console.WriteLine($"Read failed: {ex.Message}");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Process handle is closed
|
||||
Console.WriteLine($"Process not open: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `default(T)` if fewer bytes read than expected (e.g., partial read).
|
||||
|
||||
### Write Operations
|
||||
|
||||
**Strategy:** Silent failures → return `false`
|
||||
|
||||
```csharp
|
||||
bool success = magic.Memory.Write(address, 999);
|
||||
if (!success)
|
||||
{
|
||||
// Handle failure (retry, log, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
**Reason:** Writes to another process can legitimately fail (protection changed, process exited); silent retry is often the right strategy.
|
||||
|
||||
### String Operations
|
||||
|
||||
**Read:** Returns `string.Empty` on failure.
|
||||
|
||||
```csharp
|
||||
string text = magic.Memory.ReadString(address, Encoding.ASCII);
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
// Read failed or string is empty
|
||||
}
|
||||
```
|
||||
|
||||
**Write:** Returns `false` on failure.
|
||||
|
||||
---
|
||||
|
||||
## Thread Safety
|
||||
|
||||
### ExternalReader
|
||||
|
||||
**Thread-safe:** Yes
|
||||
|
||||
`ReadProcessMemory`/`WriteProcessMemory` are thread-safe at the OS level. No synchronization required.
|
||||
|
||||
### InProcessReader
|
||||
|
||||
**Thread-safe:** Yes
|
||||
|
||||
RPM-on-self is thread-safe. No synchronization required.
|
||||
|
||||
### High-Level Access
|
||||
|
||||
**Thread-safe:** Depends on usage
|
||||
|
||||
```csharp
|
||||
// SAFE: Concurrent reads from multiple threads
|
||||
int v1 = magic.Memory.Read<int>(addr1);
|
||||
int v2 = magic.Memory.Read<int>(addr2);
|
||||
|
||||
// SAFE: Same address, concurrent reads (no race, just inconsistent value)
|
||||
int v3 = magic.Memory.Read<int>(addr);
|
||||
int v4 = magic.Memory.Read<int>(addr);
|
||||
|
||||
// UNSAFE: Concurrent writes (last write wins, no atomicity)
|
||||
magic.Memory.Write(addr, 1); // Thread 1
|
||||
magic.Memory.Write(addr, 2); // Thread 2 (may win)
|
||||
```
|
||||
|
||||
For atomic read-modify-write, use `MainThreadPump` or implement locking.
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
| Operation | Cost | Notes |
|
||||
|-----------|------|-------|
|
||||
| `Read<T>` (blittable) | Low | `MemoryMarshal.Read`, no alloc |
|
||||
| `Read<T>` (marshalled) | Medium | `Marshal.PtrToStructure` + alloc |
|
||||
| `Read<T>` (array, blittable) | Medium | Per-element `MemoryMarshal.Read` |
|
||||
| `Read<T>` (array, marshalled) | High | Per-element marshalling + alloc |
|
||||
| `ReadBytes` | Low | Direct buffer copy |
|
||||
| `ReadString` | Medium | Byte-by-byte + encoding + alloc |
|
||||
| `Write<T>` (blittable) | Low | `MemoryMarshal.Write` + buffer alloc |
|
||||
| `Write<T>` (marshalled) | Medium | `Marshal.StructureToPtr` + buffer alloc |
|
||||
| `WriteBytes` | Low | Direct buffer copy |
|
||||
| `WriteString` | Medium | Encoding + null terminator + buffer alloc |
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Prefer blittable types:** Use `int` instead of `bool` where possible
|
||||
2. **Batch reads:** Read arrays instead of individual elements
|
||||
3. **Reuse buffers:** For repeated reads, reuse byte arrays
|
||||
4. **Cache offsets:** Compute addresses once, reuse them
|
||||
5. **Use MarshalCache:** Automatic via `Read<T>`/`Write<T>`
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Reading a Nested Structure
|
||||
|
||||
```csharp
|
||||
// Assume structure: GameManager -> PlayerList -> Player[i] -> Health
|
||||
IntPtr gameManagerPtr = magic.Memory.ImageBase + 0x1000;
|
||||
IntPtr playerListPtr = magic.Memory.Read<IntPtr>(gameManagerPtr + 0x20);
|
||||
IntPtr playerPtr = magic.Memory.Read<IntPtr>(playerListPtr + (playerIndex * 8));
|
||||
int health = magic.Memory.Read<int>(playerPtr + 0x4);
|
||||
```
|
||||
|
||||
**Using RemotePointer:**
|
||||
|
||||
```csharp
|
||||
int health = magic[gameManagerPtr]
|
||||
.Read<IntPtr>(0x20) // PlayerList
|
||||
.Let(ptr => magic[ptr]
|
||||
.Read<IntPtr>(playerIndex * 8)) // Player
|
||||
.Let(ptr => magic[ptr]
|
||||
.Read<int>(0x4)); // Health
|
||||
```
|
||||
|
||||
### Pattern: Scanning for a Value
|
||||
|
||||
```csharp
|
||||
// Scan memory region for a specific value
|
||||
IntPtr found = IntPtr.Zero;
|
||||
byte[] region = magic.Memory.ReadBytes(baseAddr, size);
|
||||
|
||||
for (int i = 0; i < region.Length - 4; i++)
|
||||
{
|
||||
int value = BitConverter.ToInt32(region, i);
|
||||
if (value == targetValue)
|
||||
{
|
||||
found = baseAddr + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Better:** Use `PatternScanner` (see [Discovery](./discovery.md)).
|
||||
|
||||
### Pattern: Safe Retry Loop
|
||||
|
||||
```csharp
|
||||
// Retry write with exponential backoff
|
||||
int attempts = 0;
|
||||
bool success = false;
|
||||
while (attempts < 5 && !success)
|
||||
{
|
||||
success = magic.Memory.Write(address, value);
|
||||
if (!success)
|
||||
{
|
||||
attempts++;
|
||||
Thread.Sleep(100 * (1 << attempts)); // 100ms, 200ms, 400ms, ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Read returns default value"
|
||||
|
||||
**Possible causes:**
|
||||
1. Address is invalid
|
||||
2. Process has exited
|
||||
3. Memory protection doesn't allow read
|
||||
4. Fewer bytes read than expected (partial read)
|
||||
|
||||
**Solutions:**
|
||||
- Verify address with debugger
|
||||
- Check `magic.Memory.Handle.IsInvalid`
|
||||
- Use `CanRead` helper (if available)
|
||||
- Validate `ReadBytes` length
|
||||
|
||||
### "Write returns false"
|
||||
|
||||
**Possible causes:**
|
||||
1. Memory protection is read-only
|
||||
2. Process has exited
|
||||
3. Address is invalid
|
||||
4. Anti-cheat blocking writes
|
||||
|
||||
**Solutions:**
|
||||
- Verify address with debugger
|
||||
- Check memory protection (`VirtualQueryEx`)
|
||||
- Ensure process has `PROCESS_VM_WRITE` access
|
||||
- Retry after `VirtualProtectEx` (if you have rights)
|
||||
|
||||
### "Performance is slow"
|
||||
|
||||
**Possible causes:**
|
||||
1. Reading individual elements in a loop
|
||||
2. Using marshalled types extensively
|
||||
3. Small reads/writes (not batching)
|
||||
|
||||
**Solutions:**
|
||||
- Read arrays instead of loops
|
||||
- Use blittable types where possible
|
||||
- Batch reads/writes
|
||||
- Cache frequently accessed values
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture](./architecture.md) — MemoryBase layer design
|
||||
- [Discovery](./discovery.md) — Pattern scanning and PE parsing
|
||||
- [Execution Models](./execution-models.md) — Safe execution in target process
|
||||
@@ -74,7 +74,7 @@ WhiteMagic (facade — BM-old ergonomics)
|
||||
├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
|
||||
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
|
||||
│ ├─ ExternalReader (RPM/WPM)
|
||||
│ └─ InProcessReader (direct deref, injected)
|
||||
│ └─ InProcessReader (RPM/WPM on self-handle, injected)
|
||||
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
|
||||
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
|
||||
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
|
||||
@@ -91,3 +91,16 @@ WhiteMagic (facade — BM-old ergonomics)
|
||||
```
|
||||
|
||||
**Net result**: BM's modern, FASM-free, x64 core + GreyMagic's dual-mode / detour / patch / marshal-cache engine + MemorySharp's high-level ergonomics — with a three-tier execution model whose *default* for state-sensitive calls is the crash-safe main-thread pump, while `CreateRemoteThread` stays available for the payloads it is genuinely safe for.
|
||||
|
||||
### Deviations discovered during implementation
|
||||
|
||||
The design held, but building it surfaced corrections worth recording (each is detailed against its task in `openspec/changes/whitemagic-foundation/tasks.md`):
|
||||
|
||||
- **`InProcessReader` reads via RPM/WPM on a self-handle, not `unsafe` direct deref** — .NET cannot catch `AccessViolationException`, so a bad direct deref kills the host with no soft-failure path. The in-process speed win moves to the delegate-call and detour paths, not the reader (design decision D1, revised mid-Phase 2).
|
||||
- **`MarshalCache<T>` splits `Size` (managed, blittable) from `MarshalSize` (`Marshal.SizeOf`, marshal path)** — a single size mis-sized structs whose unmanaged width differs (a `bool` field is managed-1 / unmanaged-4; inline `ByValTStr`/`ByValArray` under-sized the marshal buffer and corrupted the heap on write). `MemoryBase` picks per `TypeRequiresMarshal` at every IO site.
|
||||
- **x64 call stub is fully MS-x64-ABI compliant** — 32-byte shadow space, 16-byte alignment at the inner `call`, full `imm64` register loads (no >4 GiB pointer truncation), stack args above the shadow window. Proven at runtime by a live SSE callee whose aligned `movaps` faults on any misalignment (task 3.8), not just by byte-level encoding tests.
|
||||
- **`RemoteModule`/`RemoteFunction` follow PE export forwarders** — `kernel32!HeapAlloc` → `NTDLL.RtlAllocateHeap` and similar resolve into the real target module; ordinal and API-set forwarders throw `NotSupportedException` rather than returning a wrong address (task 7.2).
|
||||
- **Detour prologue safety is tiered** — the default `StubAssembler` length-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set (zero dependency); the optional `IcedAssembler.GetPrologueLength` decodes arbitrary prologues and is plugged in via `DetourManager.PrologueLengthResolver` when full validation is wanted (tasks 4.6, 8.3).
|
||||
- **Iced has no text parser** — the design assumed arbitrary text assembly could be delegated to Iced, but Iced ships only a *fluent* code assembler and a decoder. `IcedAssembler.Assemble` bridges Intel-syntax text onto the fluent API by reflection (registers, immediates, labels; memory operands unsupported), rather than depending on a parser that does not exist (task 8.2).
|
||||
- **Injection bitness corrections** — the thread-hijack injector enforces matching host/target bitness, so the 32-bit path always runs from a 32-bit caller and uses native `GetThreadContext`/`SetThreadContext`; the WOW64 context APIs (for 64-bit callers inspecting WOW64 targets) never apply here and were removed. `ExternalReader` validates `QueryInformation`/`QueryLimitedInformation` access and surfaces `IsWow64Process` failures instead of silently assuming host bitness.
|
||||
- **Bounds and protection hardening** — `AllocatedMemory` range-checks typed IO against region size; `Patch` mirrors the detour's `VirtualProtectEx` dance; `MainThreadPump` guards the completion race on an already-completed `TaskCompletionSource`.
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
This guide covers common issues, errors, and solutions when using WhiteMagic.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Process Access Issues](#process-access-issues)
|
||||
- [Memory Operation Failures](#memory-operation-failures)
|
||||
- [Execution Errors](#execution-errors)
|
||||
- [Hooking Problems](#hooking-problems)
|
||||
- [Pattern Scanning Issues](#pattern-scanning-issues)
|
||||
- [Performance Problems](#performance-problems)
|
||||
- [Crash and Stability Issues](#crash-and-stability-issues)
|
||||
- [Build and Compilation Errors](#build-and-compilation-errors)
|
||||
|
||||
---
|
||||
|
||||
## Process Access Issues
|
||||
|
||||
### "Process is not open for read/write"
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
System.InvalidOperationException: Process is not open for read/write.
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. Process has exited
|
||||
2. Process handle is closed
|
||||
3. Insufficient permissions
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Check process state
|
||||
if (magic.Memory.Handle.IsInvalid || magic.Memory.Handle.IsClosed)
|
||||
{
|
||||
Console.WriteLine("Process handle is invalid or closed");
|
||||
}
|
||||
|
||||
// Re-open the process
|
||||
using var magic = Magic.Open(process);
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
- Keep `Process` object alive
|
||||
- Don't dispose `Magic` while using it
|
||||
- Use `using` statement for automatic cleanup
|
||||
|
||||
### "OpenProcess failed: Access Denied"
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
Process.Open returns false, or Magic.Open throws Win32Exception
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. Insufficient privileges (not running as administrator)
|
||||
2. Target process is protected (anti-cheat, system process)
|
||||
3. 32-bit/64-bit mismatch
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Run as administrator
|
||||
// Right-click → Run as Administrator
|
||||
|
||||
// Check process bitness matches host
|
||||
if (Environment.Is64BitProcess != Is64BitProcess(targetProcess))
|
||||
{
|
||||
Console.WriteLine("Bitness mismatch between host and target");
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
- Run with admin privileges
|
||||
- Check target process protection level
|
||||
- Ensure bitness compatibility
|
||||
|
||||
---
|
||||
|
||||
## Memory Operation Failures
|
||||
|
||||
### "Read returns default value"
|
||||
|
||||
**Symptom:**
|
||||
```csharp
|
||||
int value = magic.Memory.Read<int>(address);
|
||||
// value is 0 (or default), but expected different value
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. Address is invalid
|
||||
2. Memory protection prevents reading
|
||||
3. Fewer bytes read than expected
|
||||
4. Process has exited
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Verify address with debugger
|
||||
// Check if address is readable
|
||||
byte[] test = magic.Memory.ReadBytes(address, 4);
|
||||
if (test.Length < 4)
|
||||
{
|
||||
Console.WriteLine("Cannot read from address");
|
||||
}
|
||||
|
||||
// Validate handle
|
||||
if (magic.Memory.Handle.IsInvalid)
|
||||
{
|
||||
Console.WriteLine("Process handle is invalid");
|
||||
}
|
||||
```
|
||||
|
||||
### "Write returns false"
|
||||
|
||||
**Symptom:**
|
||||
```csharp
|
||||
bool success = magic.Memory.Write(address, value);
|
||||
if (!success) { /* Write failed */ }
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. Memory is read-only (e.g., `.text` section)
|
||||
2. Address is invalid
|
||||
3. Process has exited
|
||||
4. Anti-cheat blocking writes
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Retry with delay
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (magic.Memory.Write(address, value))
|
||||
break;
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
|
||||
// Check if memory is protected
|
||||
// Use VirtualProtectEx if you have rights (advanced)
|
||||
```
|
||||
|
||||
**For code patches, use `PatchManager` instead:**
|
||||
```csharp
|
||||
var patch = magic.PatchManager.Create("Patch", address, bytes);
|
||||
patch.Apply(); // Handles VirtualProtect automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Errors
|
||||
|
||||
### "CreateRemoteThread failed"
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
Win32Exception: CreateRemoteThread failed
|
||||
```
|
||||
|
||||
**Causes:**
|
||||
1. Target process is protected
|
||||
2. Insufficient permissions
|
||||
3. Target process has exited
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Ensure process is still running
|
||||
if (process.HasExited)
|
||||
{
|
||||
Console.WriteLine("Process has exited");
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
// Run as administrator
|
||||
```
|
||||
|
||||
### "Crash when calling target function via RemoteThreadExecutor"
|
||||
|
||||
**Symptom:** Target application crashes after `RemoteThreadExecutor.Execute<T>`
|
||||
|
||||
**Cause:** Calling single-threaded function from remote thread (thread-affinity violation)
|
||||
|
||||
**Solution:** Use `MainThreadPump` instead:
|
||||
|
||||
```csharp
|
||||
// WRONG (crashes game):
|
||||
int health = magic.RemoteThread.Execute<int>(
|
||||
readHealthFn,
|
||||
CallConvention.Cdecl
|
||||
);
|
||||
|
||||
// RIGHT (crash-safe):
|
||||
var pump = magic.CreateMainThreadPump(frameAddress);
|
||||
int health = await pump.Enqueue(() =>
|
||||
magic.Memory.Read<int>(healthAddress)
|
||||
);
|
||||
```
|
||||
|
||||
**Explanation:** Game state, scripting engines, and render contexts are often main-thread-only. Use `MainThreadPump` for these.
|
||||
|
||||
### "MainThreadPump work item wedged"
|
||||
|
||||
**Symptom:** `Enqueue<T>()` hangs or times out
|
||||
|
||||
**Cause:** Work item threw exception or entered infinite loop
|
||||
|
||||
**Solution:**
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
int result = await pump.Enqueue(() =>
|
||||
{
|
||||
// Keep work items short!
|
||||
return magic.Memory.Read<int>(address);
|
||||
}, TimeSpan.FromSeconds(1)); // Add timeout
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Console.WriteLine("Work item wedged (stuck the frame)");
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
- Keep pump work items short (< 1ms)
|
||||
- Avoid blocking calls in work items
|
||||
- Use timeout on `Enqueue<T>`
|
||||
|
||||
---
|
||||
|
||||
## Hooking Problems
|
||||
|
||||
### "Detour failed: prologue too short"
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
InvalidOperationException: Prologue is too short for detour (minimum 5 bytes required)
|
||||
```
|
||||
|
||||
**Cause:** Function prologue is shorter than minimum required for jmp instruction
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Option 1: Hook a different function
|
||||
var detour = magic.DetourManager.Detour(alternativeAddress, hook);
|
||||
|
||||
// Option 2: Patch deeper into function (after prologue)
|
||||
// (Advanced, risky)
|
||||
|
||||
// Option 3: For WinAPI, use hot-patch area
|
||||
if (IsHotPatchPadded(functionAddress))
|
||||
{
|
||||
// Can use 2-byte jmp at [address-2]
|
||||
}
|
||||
```
|
||||
|
||||
### "Patch failed: access violation"
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
AccessViolationException when applying patch
|
||||
```
|
||||
|
||||
**Cause:** Memory is protected or invalid
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Verify address is valid
|
||||
byte[] test = magic.Memory.ReadBytes(address, 1);
|
||||
|
||||
// Use PatchManager (handles VirtualProtect automatically)
|
||||
var patch = magic.PatchManager.Create("Patch", address, bytes);
|
||||
patch.Apply();
|
||||
```
|
||||
|
||||
### "Hook not called"
|
||||
|
||||
**Symptom:** Detour applied successfully, but hook delegate never executes
|
||||
|
||||
**Causes:**
|
||||
1. Wrong function address
|
||||
2. Target already called function before hook installed
|
||||
3. Target is using a different implementation (e.g., forwarded export)
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Verify address with debugger
|
||||
// Install hook early (before target uses function)
|
||||
// Check for forwarded exports
|
||||
var fn = magic["module"]["function"];
|
||||
Console.WriteLine($"Function address: {fn.Address}");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern Scanning Issues
|
||||
|
||||
### "FindPattern returns IntPtr.Zero"
|
||||
|
||||
**Symptom:** Pattern scan finds no matches
|
||||
|
||||
**Causes:**
|
||||
1. Pattern is incorrect
|
||||
2. Module not loaded
|
||||
3. Memory layout changed (ASLR, update)
|
||||
4. Pattern wildcards too broad
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Verify pattern syntax
|
||||
// Use IDA-style: "48 8B ? ? ? ? ?"
|
||||
// ? = wildcard byte
|
||||
|
||||
// Narrow search range
|
||||
// Scan only relevant module, not entire memory
|
||||
|
||||
// Check if module is loaded
|
||||
var module = magic["moduleName"];
|
||||
if (module.BaseAddress == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine("Module not loaded");
|
||||
}
|
||||
|
||||
// Test with known pattern
|
||||
var test = magic.Memory.FindPattern("48 8B 05 ? ? ? ?", module.BaseAddress, 0x1000);
|
||||
```
|
||||
|
||||
### "Pattern scanner is slow"
|
||||
|
||||
**Symptom:** `FindPattern` takes several seconds
|
||||
|
||||
**Cause:** Scanning large memory regions without cache
|
||||
|
||||
**Solution:**
|
||||
```csharp
|
||||
// Use pattern cache (if available in your version)
|
||||
// Or cache results manually
|
||||
private static readonly Dictionary<string, IntPtr> PatternCache = new();
|
||||
|
||||
IntPtr FindPatternCached(string pattern, IntPtr baseAddr, int size)
|
||||
{
|
||||
string key = $"{pattern}_{baseAddr}_{size}";
|
||||
if (PatternCache.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
IntPtr result = magic.Memory.FindPattern(pattern, baseAddr, size);
|
||||
PatternCache[key] = result;
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Problems
|
||||
|
||||
### "Memory reads are slow"
|
||||
|
||||
**Symptom:** `Read<T>` takes several milliseconds
|
||||
|
||||
**Causes:**
|
||||
1. Reading individual elements in a loop
|
||||
2. Using marshalled types extensively
|
||||
3. Target process is heavily loaded
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// WRONG: Slow loop
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
values[i] = magic.Memory.Read<int>(baseAddr + i * 4);
|
||||
}
|
||||
|
||||
// RIGHT: Batch read
|
||||
values = magic.Memory.Read<int>(baseAddr, 1000);
|
||||
|
||||
// RIGHT: Use blittable types
|
||||
// Use int instead of bool where possible
|
||||
```
|
||||
|
||||
### "High CPU usage"
|
||||
|
||||
**Symptom:** WhiteMagic causes high CPU in target process
|
||||
|
||||
**Cause:** Polling loops, tight read loops in pump
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// WRONG: Tight loop in pump
|
||||
while (true)
|
||||
{
|
||||
int health = magic.Memory.Read<int>(healthAddr);
|
||||
if (health == 0) break;
|
||||
}
|
||||
|
||||
// RIGHT: Event-driven or throttled
|
||||
// Use MainThreadPump with delays
|
||||
// Or poll at reasonable interval (e.g., 60 Hz)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Crash and Stability Issues
|
||||
|
||||
### "Target crashes when attached"
|
||||
|
||||
**Symptom:** Target application crashes shortly after `Magic.Open`
|
||||
|
||||
**Cause:** Thread-affinity violation (calling function from wrong thread)
|
||||
|
||||
**Solution:** Use `MainThreadPump` for state-sensitive calls:
|
||||
|
||||
```csharp
|
||||
// Identify the crash-safe execution model
|
||||
var pump = magic.CreateMainThreadPump(frameAddress);
|
||||
|
||||
// All state-sensitive calls go through pump
|
||||
await pump.Enqueue(() => { /* safe code */ });
|
||||
```
|
||||
|
||||
### "Random crashes during operation"
|
||||
|
||||
**Symptom:** Intermittent crashes, hard to reproduce
|
||||
|
||||
**Causes:**
|
||||
1. Race conditions (concurrent memory access)
|
||||
2. Anti-cheat interference
|
||||
3. Memory protection changes mid-operation
|
||||
|
||||
**Solutions:**
|
||||
```csharp
|
||||
// Add synchronization
|
||||
lock (syncLock)
|
||||
{
|
||||
magic.Memory.Write(address, value);
|
||||
}
|
||||
|
||||
// Handle access violations gracefully
|
||||
try
|
||||
{
|
||||
magic.Memory.Read<int>(address);
|
||||
}
|
||||
catch (AccessViolationException)
|
||||
{
|
||||
// Retry or handle gracefully
|
||||
}
|
||||
|
||||
// Check for anti-cheat
|
||||
// Some anti-cheat tools detect and block memory manipulation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build and Compilation Errors
|
||||
|
||||
### "XML documentation errors"
|
||||
|
||||
**Symptom:** Build fails with `CS1591` or `CS0419` errors
|
||||
|
||||
**Cause:** Missing XML comments or ambiguous cref references
|
||||
|
||||
**Solution:**
|
||||
```xml
|
||||
<!-- In WhiteMagic.csproj -->
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn> <!-- Suppress missing warnings -->
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Or add missing XML comments (see [Documentation Best Practices](../README.md#documentation)).
|
||||
|
||||
### "Type or namespace not found"
|
||||
|
||||
**Symptom:** `WhiteMagic` namespace not found after adding reference
|
||||
|
||||
**Cause:** Project not referencing `WhiteMagic.dll`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
dotnet add reference ../WhiteMagic/WhiteMagic.csproj
|
||||
# OR
|
||||
dotnet add package WhiteMagic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Enable Detailed Logging
|
||||
|
||||
```csharp
|
||||
// Add diagnostic logging
|
||||
using System.Diagnostics;
|
||||
|
||||
Debug.WriteLine($"Reading from 0x{address:X}");
|
||||
int value = magic.Memory.Read<int>(address);
|
||||
Debug.WriteLine($"Read result: {value}");
|
||||
```
|
||||
|
||||
### Verify with External Tools
|
||||
|
||||
- **Cheat Engine:** Verify memory addresses and values
|
||||
- **x64dbg/windbg:** Verify function addresses and disassembly
|
||||
- **Process Hacker:** Check process handles and permissions
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
1. ❌ **Forgetting `isRelative` for module-relative addresses**
|
||||
```csharp
|
||||
// WRONG
|
||||
int value = magic.Memory.Read<int>(0x1000);
|
||||
|
||||
// RIGHT (if 0x1000 is module-relative)
|
||||
int value = magic.Memory.Read<int>(0x1000, isRelative: true);
|
||||
```
|
||||
|
||||
2. ❌ **Using `RemoteThreadExecutor` for game state**
|
||||
```csharp
|
||||
// WRONG (crashes)
|
||||
int health = magic.RemoteThread.Execute<int>(fn, CallConvention.Cdecl);
|
||||
|
||||
// RIGHT (crash-safe)
|
||||
int health = await pump.Enqueue(() => magic.Memory.Read<int>(addr));
|
||||
```
|
||||
|
||||
3. ❌ **Not disposing `Magic`**
|
||||
```csharp
|
||||
// WRONG (leaks handles)
|
||||
var magic = Magic.Open(process);
|
||||
// ... use magic
|
||||
// Forgot to dispose!
|
||||
|
||||
// RIGHT
|
||||
using (var magic = Magic.Open(process))
|
||||
{
|
||||
// ... use magic
|
||||
} // Auto-disposes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you're still stuck:
|
||||
|
||||
1. **Check documentation:**
|
||||
- [Architecture](./architecture.md)
|
||||
- [Execution Models](./execution-models.md)
|
||||
- [Memory Access](./memory-access.md)
|
||||
- [Function Hooking](./hooking.md)
|
||||
|
||||
2. **Search issues:** Check existing GitHub issues
|
||||
|
||||
3. **Create minimal reproduction:**
|
||||
```csharp
|
||||
// Minimal code that reproduces the issue
|
||||
using var magic = Magic.Open(process);
|
||||
int value = magic.Memory.Read<int>(address);
|
||||
// What happens vs. what you expect
|
||||
```
|
||||
|
||||
4. **Include system info:**
|
||||
- WhiteMagic version
|
||||
- .NET version
|
||||
- Target process (if applicable)
|
||||
- Windows version
|
||||
- x86 or x64
|
||||
|
||||
---
|
||||
|
||||
## Common Error Codes
|
||||
|
||||
| Win32 Error | Meaning | Solution |
|
||||
|-------------|---------|----------|
|
||||
| `ERROR_ACCESS_DENIED` (5) | Insufficient permissions | Run as administrator |
|
||||
| `ERROR_INVALID_HANDLE` (6) | Handle is invalid/closed | Re-open process |
|
||||
| `ERROR_NOT_ENOUGH_MEMORY` (8) | Insufficient memory | Reduce buffer size |
|
||||
| `ERROR_NOACCESS` (998) | Invalid access | Check memory protection |
|
||||
| `ERROR_PARTIAL_COPY` (299) | Partial read/write | Retry or check address |
|
||||
|
||||
---
|
||||
|
||||
*For more information, see the main [README](../README.md) and [architecture documentation](./architecture.md).*
|
||||
@@ -17,7 +17,7 @@
|
||||
- [x] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag)
|
||||
- [x] 2.8 Implement addressing helpers to pass 2.7
|
||||
- [x] 2.9 Add tests + implementation for `InProcessReader` (RPM/WPM on a self-handle — see D1 deviation note; direct deref rejected because .NET cannot catch `AccessViolationException`); verify shared `MemoryBase` API works for both readers
|
||||
- [ ] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (single-byte encodings). Fix: align the scan to the encoding's code-unit width and carry the last `(nullLen-1)` bytes across chunks. Add a UTF-16 test.
|
||||
- [x] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (single-byte encodings). Fix: align the scan to the encoding's code-unit width and carry the last `(nullLen-1)` bytes across chunks. Add a UTF-16 test.
|
||||
|
||||
## 3. Managed Assembler (spec: managed-assembler)
|
||||
|
||||
@@ -28,60 +28,60 @@
|
||||
- [x] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
|
||||
- [x] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
|
||||
- [x] 3.7 Add tests for x64 stub argument-register placement and call
|
||||
- [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Byte-level tests only — a live-execution test (5-arg + SSE callee via `CreateRemoteThread`) is still needed to prove the ABI at runtime.**
|
||||
- [x] 3.8 Implement x64 stub to pass 3.7. **Deviation (review):** `BuildCallStub` takes `nuint[]` (was `uint[]`). x64 stub is Microsoft-x64-ABI compliant: allocates 32-byte shadow space, keeps 16-byte stack alignment at the inner `call` (frame `K ≡ 8 (mod 16)`, `K ≥ 0x20 + 8·stackArgs`), loads RCX/RDX/R8/R9 with full 64-bit `imm64` (no >4 GiB pointer truncation), and writes stack args above the shadow window (no return-address clobber). x86 rejects args > `uint.MaxValue`. Argument count bounded by `MaxArguments` (256) to keep frame arithmetic overflow-free. **Runtime ABI now proven:** live-execution tests via `CreateRemoteThread` cover 5-arg register+stack delivery (`Execute_sums_register_and_stack_arguments`), 16-byte entry alignment arithmetically (`Execute_delivers_16byte_aligned_stack_to_callee`), and a hardware-alignment-sensitive SSE callee (`Execute_runs_sse_callee_with_five_args`: aligned `movaps` that #GPs unless the stub delivers a 16-byte-aligned stack, combined with a 5th stack arg).
|
||||
- [x] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references)
|
||||
|
||||
## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking)
|
||||
|
||||
- [ ] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
|
||||
- [ ] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
|
||||
- [ ] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
|
||||
- [ ] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
|
||||
- [ ] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
|
||||
- [ ] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
|
||||
- [ ] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
|
||||
- [ ] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
|
||||
- [ ] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
|
||||
- [ ] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
|
||||
- [ ] 4.11 Add tests for `RemoteThreadExecutor.Execute<T>` (convention stub + wait + typed exit; no-process failure is deterministic)
|
||||
- [ ] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
|
||||
- [x] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
|
||||
- [x] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
|
||||
- [x] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
|
||||
- [x] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
|
||||
- [x] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
|
||||
- [x] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety. **Resolved (8.3):** `DetourManager.PrologueLengthResolver` now accepts `IcedAssembler.GetPrologueLength` for full instruction-boundary validation of arbitrary prologues; the built-in decoder remains the zero-dependency default.
|
||||
- [x] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
|
||||
- [x] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
|
||||
- [x] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
|
||||
- [x] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
|
||||
- [x] 4.11 Add tests for `RemoteThreadExecutor.Execute<T>` (convention stub + wait + typed exit; no-process failure is deterministic)
|
||||
- [x] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
|
||||
|
||||
## 5. Injection & Discovery (spec: dll-injection, memory-discovery)
|
||||
|
||||
- [ ] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
|
||||
- [ ] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
|
||||
- [ ] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
|
||||
- [ ] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
|
||||
- [ ] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
|
||||
- [ ] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
|
||||
- [ ] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
|
||||
- [ ] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
|
||||
- [x] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
|
||||
- [x] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
|
||||
- [x] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
|
||||
- [x] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
|
||||
- [x] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
|
||||
- [x] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
|
||||
- [x] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
|
||||
- [x] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
|
||||
|
||||
## 6. In-Process Tier (spec: remote-execution)
|
||||
|
||||
- [ ] 6.1 Add tests for `InProcessInvoker.CreateFunction<TDelegate>` calling a known in-process function directly
|
||||
- [ ] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
|
||||
- [ ] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
|
||||
- [x] 6.1 Add tests for `InProcessInvoker.CreateFunction<TDelegate>` calling a known in-process function directly
|
||||
- [x] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
|
||||
- [x] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
|
||||
|
||||
## 7. High-Level Ergonomics (spec: high-level-api)
|
||||
|
||||
- [ ] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
|
||||
- [ ] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy
|
||||
- [ ] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
|
||||
- [ ] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
|
||||
- [ ] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
|
||||
- [ ] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
|
||||
- [ ] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
|
||||
- [x] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
|
||||
- [x] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy. **Deviation:** export resolution added to `PeHeaderParser.GetExportAddress` (PE32/PE32+ export directory walk) and **follows export forwarders** (e.g. `kernel32!HeapAlloc` → `NTDLL.RtlAllocateHeap`) into other loaded modules; ordinal forwarders and unresolvable API-set targets throw `NotSupportedException`. `RemoteModule` resolves the base via `Process.Modules` (name match tolerant of `.dll`/case). `RemoteFunction.Execute<T>` defaults to the always-available `RemoteThreadExecutor`; `Address` is exposed for pump routing and `CreateDelegate<T>` for the in-process tier. Tests cross-check resolved addresses against the OS `GetProcAddress` (direct export + forwarder) and execute `kernel32!GetCurrentProcessId` end-to-end.
|
||||
- [x] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
|
||||
- [x] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
|
||||
- [x] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
|
||||
- [x] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
|
||||
- [x] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
|
||||
|
||||
## 8. Optional Iced Backend (spec: managed-assembler)
|
||||
|
||||
- [ ] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free
|
||||
- [ ] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding
|
||||
- [ ] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present
|
||||
- [x] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free. Iced 1.21.0 added to `WhiteMagic.csproj`; only constructing `IcedAssembler` pulls it into a behavioral path. `StubAssembler` never references it.
|
||||
- [x] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding. **Deviation:** Iced ships a *fluent* code assembler and a decoder but **no text parser**, so `Assemble` bridges Intel-syntax text onto Iced's `Assembler` by reflection — the mnemonic selects the matching fluent method and operands bind to registers (reflected from `AssemblerRegisters`), immediates, or labels; origin-relative encoding via `Assembler.Assemble(writer, origin)`. Register/immediate/label operands and label-relative branches are supported; **memory operands (`[reg+disp]`) throw `NotSupportedException`** (a caller needing those emits bytes directly). Tests round-trip via Iced's decoder and assert origin-relative branch targets.
|
||||
- [x] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present. `IcedAssembler.GetPrologueLength` decodes arbitrary instructions via Iced's `Decoder`; `DetourManager.PrologueLengthResolver` (a `PrologueLengthResolver` delegate) defaults to the built-in `PrologueDecoder` and is swappable to the Iced resolver, threaded into each `Detour`. Tests prove Iced resolves a prologue (`mov rax,rcx` = `48 8B C1`) the built-in decoder rejects.
|
||||
|
||||
## 9. Verification
|
||||
|
||||
- [ ] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass
|
||||
- [ ] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
|
||||
- [ ] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green
|
||||
- [ ] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation
|
||||
- [x] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass (180 pass, 4 integration/interactive skipped)
|
||||
- [x] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
|
||||
- [x] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green. WhiteMagic is a separate, git-ignored project under `reference/` sharing no source or build with BlackMagic; `dotnet test reference/Blackmagic/BlackMagic.slnx` = 17 passing, 0 failing (only pre-existing XML-doc warnings).
|
||||
- [x] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation. Added a "Deviations discovered during implementation" subsection (D1 RPM-on-self, MarshalCache size split, x64 ABI + SSE proof, export forwarders, partial prologue safety, injection bitness, bounds/protection hardening) and corrected the `InProcessReader` line in the architecture diagram.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"caveman": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/caveman/SKILL.md",
|
||||
"computedHash": "b0e7741886c0c32270cedc1a2fc21c2879d90964d3ce00f24952de7064613404"
|
||||
},
|
||||
"fuck-slop": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/fuck-slop/SKILL.md",
|
||||
"computedHash": "3332e0e40b1611ff8833111f2aa9bfc35095a60b6f201a63b9a5771e6046d552"
|
||||
},
|
||||
"grill-me": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/grill-me/SKILL.md",
|
||||
"computedHash": "084fb183af7cd0000bdf8719601190d9f5113b19289da61a315e684f693717ee"
|
||||
},
|
||||
"interface-kit": {
|
||||
"source": "JuliusBrussee/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/interface-kit/SKILL.md",
|
||||
"computedHash": "3990d2fa28800ba93aa32ebfbb2b7277e0bd368111b85363bfe943fd19bb3b3a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# WhiteMagic Documentation
|
||||
|
||||
## [Home](index.md)
|
||||
|
||||
## Getting Started
|
||||
- [Installation](docs/installation.md)
|
||||
- [Quick Start](docs/quick-start.md)
|
||||
|
||||
## Conceptual Guides
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Memory Access](docs/memory-access.md)
|
||||
- [Execution Models](docs/execution-models.md)
|
||||
- [Function Hooking](docs/hooking.md)
|
||||
|
||||
## Examples
|
||||
- [Example 1: Basic Memory Operations](WhiteMagic.Examples/README.md#example-1-basic-memory-operations)
|
||||
- [Example 2: Pattern Scanning](WhiteMagic.Examples/README.md#example-2-pattern-scanning)
|
||||
- [Example 3: Execution Models](WhiteMagic.Examples/README.md#example-3-execution-models)
|
||||
- [Example 4: Function Hooking](WhiteMagic.Examples/README.md#example-4-function-hooking)
|
||||
- [Example 5: High-Level API](WhiteMagic.Examples/README.md#example-5-high-level-api)
|
||||
|
||||
## API Reference
|
||||
- [API Documentation](api/index.md)
|
||||
|
||||
## Reference & Comparison
|
||||
- [Library Comparison](docs/memory-library-comparison.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
## Contributing
|
||||
- [Contributing Guidelines](CONTRIBUTING.md)
|
||||
Reference in New Issue
Block a user