1 Commits
Author SHA1 Message Date
kbe 56d95e687b Initial commit 2026-07-21 20:28:59 +00:00
151 changed files with 1 additions and 26141 deletions
-48
View File
@@ -1,48 +0,0 @@
# 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
-78
View File
@@ -1,78 +0,0 @@
---
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.
-86
View File
@@ -1,86 +0,0 @@
---
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 (subjectverbelaboration).
- **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 introthree-pointsconclusion 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).
@@ -1,171 +0,0 @@
# 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 1824-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 34 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.
@@ -1,61 +0,0 @@
# 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.
-209
View File
@@ -1,209 +0,0 @@
---
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.
-582
View File
@@ -1,582 +0,0 @@
---
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.
@@ -1,425 +0,0 @@
# 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)?
@@ -1,545 +0,0 @@
# 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?
@@ -1,604 +0,0 @@
# 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.
@@ -1,204 +0,0 @@
# 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 | | |
-156
View File
@@ -1,156 +0,0 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
-161
View File
@@ -1,161 +0,0 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
-175
View File
@@ -1,175 +0,0 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
-110
View File
@@ -1,110 +0,0 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
-144
View File
@@ -1,144 +0,0 @@
---
name: "OPSX: Sync"
description: Sync delta specs from a change to main specs
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, specs, experimental]
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
-82
View File
@@ -1,82 +0,0 @@
---
name: "OPSX: Update"
description: Update a change - revise existing planning artifacts and keep them coherent (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:update` (e.g., `/opsx:update add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Get the change's artifacts**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
4. **Read and reconcile**
- Read the artifact(s) the request touches and the change's other existing artifacts.
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
- Note everything that is now inconsistent, missing, or contradictory.
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx:continue` to create them.
- If the change is already coherent, say so and make no edits.
5. **Confirm and apply, one artifact at a time**
- Show each proposed revision and why. Write only after the user confirms.
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
- Artifacts still missing -> suggest `/opsx:continue` to create them.
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx:apply` to carry the delta into code.
- Everything done and implemented -> suggest `/opsx:archive`.
**Output**
After each invocation, show:
- Which artifacts were revised (and which proposed revisions were rejected)
- Anything deferred to `/opsx:continue` (not-yet-created artifacts or files)
- Where the change stands and the recommended next command
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx:apply`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx:continue`'s job.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic).
-48
View File
@@ -1,48 +0,0 @@
# 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
-78
View File
@@ -1,78 +0,0 @@
---
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.
-86
View File
@@ -1,86 +0,0 @@
---
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 (subjectverbelaboration).
- **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 introthree-pointsconclusion 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).
@@ -1,171 +0,0 @@
# 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 1824-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 34 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.
@@ -1,61 +0,0 @@
# 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.
-209
View File
@@ -1,209 +0,0 @@
---
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.
-582
View File
@@ -1,582 +0,0 @@
---
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.
@@ -1,425 +0,0 @@
# 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)?
@@ -1,545 +0,0 @@
# 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?
@@ -1,604 +0,0 @@
# 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.
@@ -1,204 +0,0 @@
# 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 | | |
@@ -1,160 +0,0 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -1,118 +0,0 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
-290
View File
@@ -1,290 +0,0 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
-114
View File
@@ -1,114 +0,0 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
-148
View File
@@ -1,148 +0,0 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
@@ -1,86 +0,0 @@
---
name: openspec-update-change
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Get the change's artifacts**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
4. **Read and reconcile**
- Read the artifact(s) the request touches and the change's other existing artifacts.
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
- Note everything that is now inconsistent, missing, or contradictory.
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx:continue` to create them.
- If the change is already coherent, say so and make no edits.
5. **Confirm and apply, one artifact at a time**
- Show each proposed revision and why. Write only after the user confirms.
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
- Artifacts still missing -> suggest `/opsx:continue` to create them.
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx:apply` to carry the delta into code.
- Everything done and implemented -> suggest `/opsx:archive`.
**Output**
After each invocation, show:
- Which artifacts were revised (and which proposed revisions were rejected)
- Anything deferred to `/opsx:continue` (not-yet-created artifacts or files)
- Where the change stands and the recommended next command
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx:apply`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx:continue`'s job.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic).
-16
View File
@@ -1,16 +0,0 @@
# Reference libraries — studied, not built here (each has its own upstream repo)
reference/
# .NET build output
[Bb]in/
[Oo]bj/
.vs/
*.user
# Scratch
*.tmp
*.log
# Test run artifacts
WhiteMagicTest/TestResults/
**/TestResults/
-153
View File
@@ -1,153 +0,0 @@
---
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Provided arguments**: $@
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
-158
View File
@@ -1,158 +0,0 @@
---
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Provided arguments**: $@
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
-172
View File
@@ -1,172 +0,0 @@
---
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
**Provided arguments**: $@
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
-107
View File
@@ -1,107 +0,0 @@
---
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Provided arguments**: $@
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
-141
View File
@@ -1,141 +0,0 @@
---
description: Sync delta specs from a change to main specs
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx-sync` (e.g., `/opsx-sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Provided arguments**: $@
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
-79
View File
@@ -1,79 +0,0 @@
---
description: Update a change - revise existing planning artifacts and keep them coherent (Experimental)
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx-update` (e.g., `/opsx-update add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Provided arguments**: $@
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Get the change's artifacts**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
4. **Read and reconcile**
- Read the artifact(s) the request touches and the change's other existing artifacts.
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
- Note everything that is now inconsistent, missing, or contradictory.
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx-continue` to create them.
- If the change is already coherent, say so and make no edits.
5. **Confirm and apply, one artifact at a time**
- Show each proposed revision and why. Write only after the user confirms.
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
- Artifacts still missing -> suggest `/opsx-continue` to create them.
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx-apply` to carry the delta into code.
- Everything done and implemented -> suggest `/opsx-archive`.
**Output**
After each invocation, show:
- Which artifacts were revised (and which proposed revisions were rejected)
- Anything deferred to `/opsx-continue` (not-yet-created artifacts or files)
- Where the change stands and the recommended next command
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx-apply`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx-continue`'s job.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx-new` (the "Update vs. Start Fresh" heuristic).
-160
View File
@@ -1,160 +0,0 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -1,118 +0,0 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
-290
View File
@@ -1,290 +0,0 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx-explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
-114
View File
@@ -1,114 +0,0 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
-148
View File
@@ -1,148 +0,0 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
@@ -1,86 +0,0 @@
---
name: openspec-update-change
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.6.0"
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Get the change's artifacts**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
4. **Read and reconcile**
- Read the artifact(s) the request touches and the change's other existing artifacts.
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
- Note everything that is now inconsistent, missing, or contradictory.
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx-continue` to create them.
- If the change is already coherent, say so and make no edits.
5. **Confirm and apply, one artifact at a time**
- Show each proposed revision and why. Write only after the user confirms.
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
- Artifacts still missing -> suggest `/opsx-continue` to create them.
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx-apply` to carry the delta into code.
- Everything done and implemented -> suggest `/opsx-archive`.
**Output**
After each invocation, show:
- Which artifacts were revised (and which proposed revisions were rejected)
- Anything deferred to `/opsx-continue` (not-yet-created artifacts or files)
- Where the change stands and the recommended next command
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx-apply`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx-continue`'s job.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx-new` (the "Update vs. Start Fresh" heuristic).
-110
View File
@@ -1,110 +0,0 @@
# AGENTS.md
This file gives rules to an AI agent that works in this repository.
The text uses ASD-STE100 Simplified Technical English.
Write short sentences. Use the active voice. Give one instruction in each sentence.
## 1. Purpose
WhiteMagic is a .NET 8 process-introspection library for Windows.
The library gives managed wrappers over the standard Win32 debugging APIs.
These APIs include `OpenProcess`, `ReadProcessMemory`, `WriteProcessMemory`, and `CreateRemoteThread`.
Debuggers, profilers, diagnostic tools, and CLR hosts use the same APIs.
Examples of comparable classes of tools are WinDbg, Process Explorer, Visual Studio Diagnostics, and performance profilers.
The consumer of the library is a diagnostic and automation client.
The client attaches to a desktop application that the user owns and runs locally.
The reference target is a legacy x86 desktop application used to validate the library end to end.
The target CPU architecture is x86 or x64.
The library is a general-purpose interop layer.
It does the same class of work as a debugger extension:
read and write memory in an attached process, marshal managed types to raw bytes,
generate small trampoline routines, and invoke routines in the attached process.
All operations need the same access rights that a debugger needs.
All operations run on the local machine with the consent of the user.
## 2. Repository layout
- `WhiteMagic/` — the library. Write the product code here.
- `WhiteMagicTest/` — the xUnit test project. Write the tests here.
- `openspec/` — the design documents. Read these before you write code.
- `docs/` — the reference study `memory-library-comparison.md`.
- `reference/` — four older libraries. Git ignores this folder. Read these for study only. Do not build these projects. Do not reference these assemblies.
## 3. Build and test
Run each command from the repository root.
- Build the solution: `dotnet build WhiteMagic.slnx`
- Run the tests: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj`
The build must show zero errors and zero warnings.
`TreatWarningsAsErrors` is on. A warning stops the build.
## 4. Code rules
- Target framework: `net8.0-windows`.
- Enable nullable reference types. Do not disable nullable.
- Enable unsafe blocks only where a pointer dereference needs them.
- Keep the library bitness-agnostic. Support x86 and x64.
- Use `LibraryImport` for a new P/Invoke declaration. Do not use `DllImport` for new code.
- Match the style of the code near your change.
## 5. Test-first rule
Write the test before you write the product code.
Do these steps in order:
1. Write a test that fails.
2. Write the smallest code that makes the test pass.
3. Clean the code. Keep the test green.
Test all pure logic. Pure logic includes the assembler bytes, the marshal cache, the pattern matcher, the stub builder, and the pump queue.
A test that needs a live process is an integration test. Gate an integration test on an available target.
## 6. Design source
The plan lives in `openspec/changes/whitemagic-foundation/`.
- Read `proposal.md` for the goal.
- Read `design.md` for the decisions.
- Read `tasks.md` for the ordered task list.
- Read the file in `specs/` that matches your feature. Each requirement uses SHALL. Each scenario uses WHEN and THEN.
Make the code agree with the specification.
If you must change the plan, update the specification first.
Validate the change: `openspec validate whitemagic-foundation --strict`.
## 7. Branch and review workflow
The team builds one feature at a time.
Obey these rules:
1. Make one branch for one feature. Name the branch `feature/<short-name>`.
2. Start the branch from `master`.
3. Write the tests and the code on the branch.
4. Keep the build green on the branch.
5. Request a review before a merge.
6. Do not merge your own feature without a review.
7. Merge to `develop` only after the review passes.
8. Delete the feature branch after the merge.
`master` must always build. `master` must always pass the tests.
## 8. Commit rules
- Write a clear commit message. Use the present tense.
- Describe what the commit changes. Describe why the commit changes it.
- Make a small commit for one logical change.
- Do not commit build output. Git ignores `bin/` and `obj/`.
## 9. Scope limits
- Do not add application-specific constants to the library. The consumer holds the offsets.
- Do not add automation or application-specific logic to the library. The library stays a general interop layer.
- Keep the library's operation transparent. Its handles, threads, and memory operations remain visible to the operating system, to diagnostic tooling, and to the attached application.
- Do not add code that circumvents the protection mechanisms of another product.
- Add the optional Iced backend only behind the `IAssembler` seam. Keep the default backend free of a third-party dependency.
+1 -188
View File
@@ -1,190 +1,3 @@
# WhiteMagic
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.
Wite Magic is a C# library to read, write and execute remote code into a target process for analysis, debuging and mod creation.
@@ -1,321 +0,0 @@
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!");
}
}
@@ -1,461 +0,0 @@
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!");
}
}
@@ -1,401 +0,0 @@
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!");
}
}
@@ -1,410 +0,0 @@
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");
}
}
@@ -1,531 +0,0 @@
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!");
}
}
-142
View File
@@ -1,142 +0,0 @@
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();
}
}
}
}
-245
View File
@@ -1,245 +0,0 @@
# 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.
@@ -1,14 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
<Solution>
<Project Path="WhiteMagic.Examples/WhiteMagic.Examples.csproj" />
<Project Path="WhiteMagic/WhiteMagic.csproj" />
<Project Path="WhiteMagicTest/WhiteMagicTest.csproj" />
</Solution>
-21
View File
@@ -1,21 +0,0 @@
namespace WhiteMagic.Assembly;
/// <summary>
/// x86/x86-64 calling conventions for call-stub generation.
/// Named <c>CallConvention</c> (not <c>CallingConvention</c>) to avoid ambiguity with
/// <see cref="System.Runtime.InteropServices.CallingConvention"/>.
/// </summary>
public enum CallConvention
{
/// <summary>Caller pushes args right-to-left and cleans the stack (x86).</summary>
Cdecl,
/// <summary>Caller pushes args right-to-left; callee cleans the stack (x86).</summary>
Stdcall,
/// <summary>ECX receives the <c>this</c> pointer; remaining args on stack right-to-left; callee cleans (x86).</summary>
Thiscall,
/// <summary>ECX/EDX receive the first two args; remaining on stack right-to-left; callee cleans (x86).</summary>
Fastcall,
}
-22
View File
@@ -1,22 +0,0 @@
namespace WhiteMagic.Assembly;
/// <summary>
/// Abstraction over an x86/x64 assembler. The default <see cref="StubAssembler"/>
/// hand-emits calling-convention trampolines (no parsing, zero dep). An optional
/// <see cref="IcedAssembler"/> (Phase 8) handles arbitrary mnemonics via the Iced
/// library.
/// </summary>
/// <remarks>
/// This seam covers text assembly only (<see cref="Assemble"/>). Call-stub building
/// (<c>BuildCallStub</c>, <c>EmitU8</c>/<c>EmitU32</c>/<c>EmitU64</c>) is a
/// <see cref="StubAssembler"/> capability — not all backends need it.
/// </remarks>
public interface IAssembler
{
/// <summary>
/// Assembles text mnemonics into machine code.
/// </summary>
/// <param name="assemblyText">The assembly text (Intel syntax).</param>
/// <param name="origin">The base address for relative encodings.</param>
byte[] Assemble(string assemblyText, ulong origin = 0);
}
-333
View File
@@ -1,333 +0,0 @@
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);
}
}
-310
View File
@@ -1,310 +0,0 @@
namespace WhiteMagic.Assembly;
/// <summary>
/// The default <see cref="IAssembler"/> backend. Hand-emits calling-convention
/// trampolines and remote-execution stubs using deterministic byte emitters
/// (<see cref="EmitU8"/>, <see cref="EmitU32"/>, <see cref="EmitU64"/>). Has
/// no native or third-party dependency — no FASM, no Iced.
/// </summary>
/// <remarks>
/// <see cref="Assemble"/> is not supported by this backend (it is a parse-free
/// emitter, not a text assembler). Use <see cref="IcedAssembler"/> (Phase 8) for
/// arbitrary mnemonics.
/// </remarks>
public sealed class StubAssembler : IAssembler
{
/// <summary>
/// The maximum number of call arguments a single stub may pass. Far above any real
/// calling convention; exists only to keep frame-size and stack-offset math from
/// overflowing <see cref="int"/>.
/// </summary>
public const int MaxArguments = 256;
/// <inheritdoc />
public byte[] Assemble(string assemblyText, ulong origin = 0)
{
throw new NotSupportedException(
"StubAssembler does not parse text assembly. " +
"Use IcedAssembler (Phase 8) for arbitrary mnemonics.");
}
// ── 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);
buffer.Add((byte)(value >> 8));
buffer.Add((byte)(value >> 16));
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);
EmitU32(buffer, (uint)(value >> 32));
}
// ── Call-stub builders ─────────────────────────────────────────────────
/// <summary>
/// Builds a calling-convention call stub for x86 or x64.
/// </summary>
/// <param name="stubAddress">Where the stub lands (for E8 rel32 encoding).</param>
/// <param name="targetAddress">Function to call.</param>
/// <param name="arguments">Argument values. For x86 each element holds a 32-bit argument;
/// for x64 each element holds the full 64-bit pointer-sized argument.</param>
/// <param name="pointerSize">4 (x86) or 8 (x64).</param>
/// <param name="convention">Calling convention (ignored on x64; Windows has a single ABI).</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="pointerSize"/> is not 4 or 8,
/// or <paramref name="convention"/> is not known, or <paramref name="arguments"/> exceeds
/// <see cref="MaxArguments"/>, or the distance between stub and target exceeds the E8 rel32
/// range.</exception>
public byte[] BuildCallStub(IntPtr stubAddress, IntPtr targetAddress,
nuint[] arguments, int pointerSize, CallConvention convention)
{
ArgumentNullException.ThrowIfNull(arguments);
// Bound the argument count. No real calling convention passes anywhere near this
// many; the cap keeps the frame-size and stack-offset arithmetic (0x20 + 8*stackArgs)
// well inside int range, so it can never overflow into a bogus/negative frame.
ArgumentOutOfRangeException.ThrowIfGreaterThan(arguments.Length, MaxArguments, nameof(arguments));
var buffer = new List<byte>(96);
if (pointerSize == 4)
{
// X86 args are 32-bit. Truncate nuint down to uint — callers must pass values
// that fit in 32 bits on x86 targets.
uint[] args32 = new uint[arguments.Length];
for (int i = 0; i < arguments.Length; i++)
{
ulong v = arguments[i];
if (v > uint.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(arguments),
$"Argument {i} = 0x{v:X} does not fit in 32 bits (x86 target).");
}
args32[i] = (uint)v;
}
BuildX86Stub(buffer, checked((uint)stubAddress), checked((uint)targetAddress),
args32, convention);
}
else if (pointerSize == 8)
{
// Windows x64 uses a single ABI — the convention parameter is unused.
BuildX64Stub(buffer, (ulong)(nint)stubAddress, (ulong)(nint)targetAddress, arguments);
}
else
{
throw new ArgumentOutOfRangeException(nameof(pointerSize), pointerSize,
$"Expected 4 (x86) or 8 (x64), got {pointerSize}.");
}
return buffer.ToArray();
}
private void BuildX86Stub(List<byte> buffer, uint stubAddr,
uint target, uint[] args, CallConvention convention)
{
uint current = stubAddr;
int argIndex = 0;
switch (convention)
{
case CallConvention.Thiscall when args.Length - argIndex >= 1:
EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
argIndex++;
break;
case CallConvention.Fastcall:
if (args.Length - argIndex >= 1)
{
EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
argIndex++;
}
if (args.Length - argIndex >= 1)
{
EmitMovRegImm32(buffer, 0xBA, args[argIndex], ref current); // mov edx, arg1
argIndex++;
}
break;
case CallConvention.Cdecl:
case CallConvention.Stdcall:
break;
default:
throw new ArgumentOutOfRangeException(nameof(convention), convention,
$"Unsupported calling convention: {convention}.");
}
// Push remaining args in reverse order (right-to-left)
for (int i = args.Length - 1; i >= argIndex; i--)
{
current += 5;
buffer.Add(0x68); // push imm32
EmitU32(buffer, args[i]);
}
// call rel32
long distance = (long)target - (long)(current + 5);
if (distance < int.MinValue || distance > int.MaxValue)
{
throw new ArgumentOutOfRangeException(
$"target (0x{target:X}) is >2 GiB from stub (0x{stubAddr:X}); " +
"E8 rel32 cannot encode this distance. Place the stub closer to the target.");
}
buffer.Add(0xE8);
EmitU32(buffer, (uint)distance);
current += 5;
// Caller cleanup (cdecl only)
int stackCount = args.Length - argIndex;
if (convention == CallConvention.Cdecl && stackCount > 0)
{
int cleanup = stackCount * 4;
if (cleanup <= 127)
{
buffer.Add(0x83); // add esp, imm8
buffer.Add(0xC4);
buffer.Add((byte)cleanup);
}
else
{
buffer.Add(0x81); // add esp, imm32
buffer.Add(0xC4);
EmitU32(buffer, (uint)cleanup);
}
}
buffer.Add(0xC3); // ret
}
/// <summary>
/// Builds a Windows x64 call stub that conforms to the Microsoft x64 ABI:
/// first 4 integer/pointer args in RCX, RDX, R8, R9 (64-bit loads); stack args
/// above a 32-byte shadow space; 16-byte stack alignment at the inner <c>call</c>.
/// </summary>
/// <remarks>
/// <para>Frame derivation. The ABI requires the <em>inner</em> <c>call</c> site to
/// land with call-site rsp ≡ 0 (mod 16), so that <c>call</c> pushes 8 bytes and the
/// callee sees entry rsp ≡ 8 — the value an MSVC prologue (<c>push rbp; sub rsp, 0x20</c>)
/// expects, and the only value for which locals land 16-aligned (SSE-safe).</para>
/// <list type="bullet">
/// <item>Stub entry: rsp ≡ 8 (mod 16).</item>
/// <item>Need post-sub rsp ≡ 0 → sub operand K satisfies K ≡ 8 (mod 16).</item>
/// <item>Frame must hold shadow space (0x20) + stack args (8 bytes each for args 4+).
/// Choose the smallest such K: <c>K = frameBytes + ((8 frameBytes) mod 16 + 16) mod 16</c>.
/// For 05 args, K ∈ {0x28, 0x38}; pattern scales linearly.</item>
/// </list>
/// <code>
/// sub rsp, K ; K ≡ 8 (mod 16), K ≥ 0x20 + 8·stackArgs
/// mov rcx, arg0 ; REX.W + imm64 (10 bytes)
/// mov rdx, arg1 ; REX.W + imm64 (10 bytes)
/// mov r8, arg2 ; REX.WB+ imm64 (10 bytes, REX.R)
/// mov r9, arg3 ; REX.WB+ imm64 (10 bytes, REX.R)
/// mov rax, arg[N] ; REX.W + imm64 (10 bytes)
/// mov [rsp + 0x20 + 8*(N-4)], rax (5/8 bytes)
/// call target (rel32) ( 5 bytes)
/// add rsp, K ( 7 bytes)
/// ret ( 1 byte)
/// </code>
/// </remarks>
private void BuildX64Stub(List<byte> buffer, ulong stubAddr,
ulong target, nuint[] args)
{
ulong current = stubAddr;
// Compute frame size K. K ≡ 8 (mod 16) so that the inner call sees
// post-sub rsp ≡ 0 and delivers target entry rsp ≡ 8 (mod 16).
int stackArgs = Math.Max(0, args.Length - 4);
int frameBytes = 0x20 + 8 * stackArgs;
int k = frameBytes + ((8 - (frameBytes % 16) + 16) % 16);
// sub rsp, imm32 (always imm32 form — constant 7 bytes regardless of K).
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC);
EmitU32(buffer, (uint)k);
current += 7;
// 64-bit register loads for args 0..3. All encodings are exactly 10 bytes:
// REX.W (0x48) + 0xB9 + imm64 → mov rcx, imm64
// REX.W (0x48) + 0xBA + imm64 → mov rdx, imm64
// REX.WB(0x49) + 0xB8 + imm64 → mov r8, imm64 (REX.R for r8)
// REX.WB(0x49) + 0xB9 + imm64 → mov r9, imm64 (REX.R)
byte[][] regMoves =
[
[0x48, 0xB9],
[0x48, 0xBA],
[0x49, 0xB8],
[0x49, 0xB9],
];
int regCount = Math.Min(args.Length, 4);
for (int i = 0; i < regCount; i++)
{
byte[] prefix = regMoves[i];
buffer.Add(prefix[0]);
buffer.Add(prefix[1]);
EmitU64(buffer, args[i]);
current += (uint)(prefix.Length + 8);
}
// Stack args: written at [post-sub-rsp + 0x20 + 8*(i-4)], i.e. above the
// shadow window, where the inner call's callee expects them.
for (int i = 4; i < args.Length; i++)
{
int offset = 0x20 + (i - 4) * 8;
buffer.Add(0x48); buffer.Add(0xB8); // mov rax, imm64
EmitU64(buffer, args[i]);
current += 10;
buffer.Add(0x48); buffer.Add(0x89); // mov [rsp + disp], rax
if (offset <= 127)
{
buffer.Add(0x44); buffer.Add(0x24); // ModRM: [rsp + disp8]
buffer.Add((byte)offset);
current += 5;
}
else
{
buffer.Add(0x84); buffer.Add(0x24); // ModRM: [rsp + disp32]
EmitU32(buffer, (uint)offset);
current += 8;
}
}
// call rel32
long distance = (long)target - (long)(current + 5);
if (distance is < int.MinValue or > int.MaxValue)
{
throw new ArgumentOutOfRangeException(
"target and stub are >2 GiB apart; E8 rel32 cannot encode this distance.");
}
buffer.Add(0xE8);
EmitU32(buffer, (uint)distance);
current += 5;
// Tear down the frame symmetrically.
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4);
EmitU32(buffer, (uint)k);
buffer.Add(0xC3);
}
// ── Instruction helpers ────────────────────────────────────────────────
/// <summary>Emit <c>mov reg32, imm32</c> and advances <paramref name="ip"/> by 5.</summary>
private static void EmitMovRegImm32(List<byte> buffer, byte opcode, uint imm32, ref uint ip)
{
buffer.Add(opcode);
buffer.Add((byte)imm32);
buffer.Add((byte)(imm32 >> 8));
buffer.Add((byte)(imm32 >> 16));
buffer.Add((byte)(imm32 >> 24));
ip += 5;
}
}
-201
View File
@@ -1,201 +0,0 @@
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;
}
}
-180
View File
@@ -1,180 +0,0 @@
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();
}
}
}
-396
View File
@@ -1,396 +0,0 @@
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]);
}
}
-79
View File
@@ -1,79 +0,0 @@
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);
}
}
-191
View File
@@ -1,191 +0,0 @@
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.
}
}
}
}
@@ -1,517 +0,0 @@
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);
}
}
-119
View File
@@ -1,119 +0,0 @@
using System.Diagnostics;
using Process = System.Diagnostics.Process;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// Out-of-process memory reader that accesses the target's memory through
/// <see cref="NativeMethods.ReadProcessMemory"/> and
/// <see cref="NativeMethods.WriteProcessMemory"/>.
/// </summary>
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>
/// The default access rights: enough to read, write, allocate, query, run a remote
/// thread, and wait on it. This deliberately omits <see cref="ProcessAccess.AllAccess"/>,
/// which over-requests and makes <c>OpenProcess</c> fail on protected processes where
/// these narrower rights would succeed.
/// </summary>
public const ProcessAccess DefaultAccess =
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
| ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize;
/// <summary>
/// Opens a process for external memory access.
/// </summary>
/// <param name="process">The target process.</param>
/// <param name="desiredAccess">The access rights to request. Defaults to
/// <see cref="DefaultAccess"/>.</param>
public ExternalReader(System.Diagnostics.Process process, ProcessAccess desiredAccess = DefaultAccess)
{
_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 {_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.
try
{
_imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero;
}
catch (System.ComponentModel.Win32Exception)
{
_imageBase = IntPtr.Zero;
}
}
/// <inheritdoc />
public override IntPtr ImageBase => _imageBase;
/// <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)
{
if (isRelative)
address = GetAbsolute(address);
return RpmHelper.ReadBytes(_handle, address, count);
}
/// <inheritdoc />
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
return RpmHelper.WriteBytes(_handle, address, bytes);
}
/// <inheritdoc />
public override void Dispose()
{
if (!_disposed)
{
_disposed = true;
base.Dispose();
}
}
}
-291
View File
@@ -1,291 +0,0 @@
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;
}
}
-64
View File
@@ -1,64 +0,0 @@
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();
}
}
}
-124
View File
@@ -1,124 +0,0 @@
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();
}
}
-49
View File
@@ -1,49 +0,0 @@
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();
}
}
}
-99
View File
@@ -1,99 +0,0 @@
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;
}
}
-99
View File
@@ -1,99 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// In-process memory reader that accesses the owning process's memory through
/// <see cref="NativeMethods.ReadProcessMemory"/> and
/// <see cref="NativeMethods.WriteProcessMemory"/> on a handle to the current
/// process. Unlike the unsafe-deref approach, this fails softly (returns
/// empty / zero bytes) on invalid or protected addresses instead of crashing
/// the host process with an <see cref="AccessViolationException"/>.
/// </summary>
/// <remarks>
/// This is functionally equivalent to <see cref="ExternalReader"/> opened on the current
/// process. It exists as a distinct type because the design (see
/// <c>openspec/changes/whitemagic-foundation/design.md</c> D1) treats "injected in-process"
/// as a separate mode from "external". The two modes will diverge further once the
/// <c>InProcessInvoker</c> delegate-call path lands.
/// </remarks>
public sealed class InProcessReader : MemoryBase
{
private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase;
private readonly int _processId;
private bool _disposed;
/// <summary>
/// Creates an in-process reader for the current process.
/// </summary>
public InProcessReader()
{
Process current = Process.GetCurrentProcess();
_processId = current.Id;
_handle = NativeMethods.OpenProcess(
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
| ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize,
false,
current.Id);
if (_handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"OpenProcess failed for PID {current.Id}: error {error}");
}
// Process.MainModule rarely throws on the current process, but guard it
// nonetheless for parity with ExternalReader.
try
{
_imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
}
catch (System.ComponentModel.Win32Exception)
{
_imageBase = IntPtr.Zero;
}
}
/// <inheritdoc />
public override IntPtr ImageBase => _imageBase;
/// <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)
{
if (isRelative)
address = GetAbsolute(address);
return RpmHelper.ReadBytes(_handle, address, count);
}
/// <inheritdoc />
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
return RpmHelper.WriteBytes(_handle, address, bytes);
}
/// <inheritdoc />
public override void Dispose()
{
if (!_disposed)
{
_disposed = true;
base.Dispose();
}
}
}
-85
View File
@@ -1,85 +0,0 @@
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;
}
}
-547
View File
@@ -1,547 +0,0 @@
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));
}
}
-74
View File
@@ -1,74 +0,0 @@
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));
}
}
-66
View File
@@ -1,66 +0,0 @@
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();
}
}
-107
View File
@@ -1,107 +0,0 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace WhiteMagic;
/// <summary>
/// Caches the widths marshalling decisions for type <typeparamref name="T"/>
/// 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>
public static class MarshalCache<T>
{
/// <summary>
/// The blittable (managed layout) width of <typeparamref name="T"/>. This is
/// what <see cref="MemoryMarshal.Read{T}"/> / <see cref="MemoryMarshal.Write{T}"/>
/// actually consume. Equals <see cref="Unsafe.SizeOf{T}"/> in the general case,
/// with fixed-width overrides for <see cref="bool"/>, <see cref="char"/>, and
/// enums so the cache value matches the primitive layout width used by those
/// paths.
/// </summary>
public static readonly int Size;
/// <summary>
/// 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
/// default Win32 BOOL marshaling vs 1 byte managed), etc. For types that do
/// not go through the marshal path, this field is still populated but unused
/// by MemoryBase.
/// </summary>
/// <remarks>
/// <c>Marshal.SizeOf</c> throws for some reference-containing shapes (e.g. a
/// bare <see cref="string"/>). When that happens, we fall back to
/// <see cref="Size"/> — the fallback path is unreachable from production code
/// because types with a reference field always have
/// <see cref="TypeRequiresMarshal"/> true, so MemoryBase reads this field only
/// when it is known to be populated.
/// </remarks>
public static readonly int MarshalSize;
/// <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(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
/// (<see cref="RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
/// </summary>
/// <remarks>
/// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not
/// detected. Reference-containing nested structs are still caught, because the
/// reference check propagates through nested value types.
/// </remarks>
public static readonly bool TypeRequiresMarshal;
static MarshalCache()
{
if (typeof(T) == typeof(bool))
{
Size = 1;
}
else if (typeof(T) == typeof(char))
{
// Marshal.SizeOf<char> reports 1 (ANSI char), but the blittable
// MemoryMarshal path reads/writes a char as a 2-byte UTF-16 code unit.
Size = 2;
}
else if (typeof(T).IsEnum)
{
Size = Marshal.SizeOf(typeof(T).GetEnumUnderlyingType());
}
else
{
// The blittable path goes through MemoryMarshal, which uses the CLR
// managed layout. Use Unsafe.SizeOf<T> so Size agrees with that
// layout — Marshal.SizeOf<T> disagrees when a struct contains a
// `bool` field (unmanaged 4 vs managed 1).
Size = Unsafe.SizeOf<T>();
}
bool hasMarshalAsField =
typeof(T).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0);
TypeRequiresMarshal =
hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences<T>();
// MarshalSize is only consulted when TypeRequiresMarshal is true; for the
// rare case where Marshal.SizeOf refuses a shape (ref-containing structs),
// fall back to the managed size so the field stays populated.
try
{
MarshalSize = Marshal.SizeOf<T>();
}
catch (ArgumentException)
{
MarshalSize = Size;
}
}
}
-201
View File
@@ -1,201 +0,0 @@
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();
}
}
}
-317
View File
@@ -1,317 +0,0 @@
using WhiteMagic.Hooking;
using WhiteMagic.Native;
using System.Runtime.InteropServices;
using System.Text;
namespace WhiteMagic;
/// <summary>
/// Abstract base for all memory-access readers and writers. Provides typed
/// <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>
public abstract byte[] ReadBytes(IntPtr address, int count, bool isRelative = false);
/// <summary>Writes a sequence of bytes to the target address.</summary>
/// <returns>The number of bytes written.</returns>
public abstract int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false);
// ── Typed IO ───────────────────────────────────────────────────────────
/// <summary>Reads a value of type <typeparamref name="T"/> from the target address.</summary>
/// <returns>The value, or <c>default(T)</c> when the read fails or returns fewer bytes than
/// <see cref="MarshalCache{T}.Size"/>.</returns>
public T Read<T>(IntPtr address, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
byte[] raw = ReadBytes(address, size);
if (raw.Length < size)
return default;
if (MarshalCache<T>.TypeRequiresMarshal)
return MarshalByteArrayToStructure<T>(raw);
return MemoryMarshal.Read<T>(raw.AsSpan());
}
/// <summary>Writes a value of type <typeparamref name="T"/> to the target address.</summary>
/// <returns><see langword="true"/> if all bytes were written.</returns>
public bool Write<T>(IntPtr address, T value, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
byte[] raw;
if (MarshalCache<T>.TypeRequiresMarshal)
raw = StructureToByteArray(value, size);
else
{
raw = new byte[size];
MemoryMarshal.Write(raw.AsSpan(), in value);
}
int written = WriteBytes(address, raw, false);
return written == size;
}
/// <summary>Reads an array of values of type <typeparamref name="T"/> from the target address.</summary>
/// <returns>An array of at most <paramref name="count"/> elements. May be shorter when the read
/// returns fewer bytes than expected.</returns>
public T[] Read<T>(IntPtr address, int count, bool isRelative = false) where T : struct
{
ArgumentOutOfRangeException.ThrowIfNegative(count);
if (isRelative)
address = GetAbsolute(address);
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
long totalSize = (long)elementSize * count;
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
byte[] raw = ReadBytes(address, (int)totalSize);
int actualCount = Math.Min(count, raw.Length / elementSize);
var result = new T[actualCount];
if (actualCount == 0)
return result;
if (MarshalCache<T>.TypeRequiresMarshal)
{
GCHandle pin = GCHandle.Alloc(raw, GCHandleType.Pinned);
try
{
IntPtr basePtr = pin.AddrOfPinnedObject();
for (int i = 0; i < actualCount; i++)
result[i] = Marshal.PtrToStructure<T>(basePtr + (i * elementSize));
}
finally
{
pin.Free();
}
}
else
{
ReadOnlySpan<byte> span = raw;
for (int i = 0; i < actualCount; i++)
result[i] = MemoryMarshal.Read<T>(span.Slice(i * elementSize, elementSize));
}
return result;
}
/// <summary>Writes an array of values of type <typeparamref name="T"/> to the target address.</summary>
/// <returns><see langword="true"/> if all bytes were written.</returns>
public bool Write<T>(IntPtr address, T[] values, bool isRelative = false) where T : struct
{
if (isRelative)
address = GetAbsolute(address);
if (values is null || values.Length == 0)
return true;
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
long total = (long)elementSize * values.Length;
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
int totalSize = (int)total;
byte[] raw = new byte[totalSize];
Span<byte> span = raw;
for (int i = 0; i < values.Length; i++)
{
Span<byte> slice = span.Slice(i * elementSize, elementSize);
if (MarshalCache<T>.TypeRequiresMarshal)
StructureToByteArray(values[i], slice, elementSize);
else
MemoryMarshal.Write(slice, in values[i]);
}
int written = WriteBytes(address, raw, false);
return written == totalSize;
}
// ── String IO ──────────────────────────────────────────────────────────
/// <summary>Reads a null-terminated string from the target address by scanning in small
/// 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. 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)
address = GetAbsolute(address);
// 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>();
while (remaining > 0)
{
int take = Math.Min(chunkSize, remaining);
byte[] chunk = ReadBytes(address + accumulated.Count, take);
if (chunk.Length == 0)
break;
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)
{
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;
}
}
if (remaining > 0)
remaining -= chunk.Length;
}
return encoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(accumulated));
}
/// <summary>Writes a null-terminated string to the target address.</summary>
public virtual bool WriteString(IntPtr address, string value, Encoding encoding, bool relative = false)
{
if (value.Length == 0 || value[^1] != '\0')
value += '\0';
byte[] bytes = encoding.GetBytes(value);
int written = WriteBytes(address, bytes, relative);
return written == bytes.Length;
}
// ── Addressing ─────────────────────────────────────────────────────────
/// <summary>Converts a relative offset to an absolute address relative to <see cref="ImageBase"/>.</summary>
public IntPtr GetAbsolute(IntPtr relative)
{
return ImageBase + (nint)relative;
}
/// <summary>Converts an absolute address to a relative offset from <see cref="ImageBase"/>.
/// This is the inverse of <see cref="GetAbsolute"/>: <c>GetAbsolute(GetRelative(a)) == a</c>.</summary>
public IntPtr GetRelative(IntPtr absolute)
{
return (IntPtr)((nint)absolute - (nint)ImageBase);
}
// ── Lifecycle ──────────────────────────────────────────────────────────
/// <inheritdoc />
public virtual void Dispose()
{
DetourManager.RemoveAll();
PatchManager.RestoreAll();
Handle?.Dispose();
}
// ── Private helpers ────────────────────────────────────────────────────
private static T MarshalByteArrayToStructure<T>(byte[] bytes) where T : struct
{
GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
return Marshal.PtrToStructure<T>(pin.AddrOfPinnedObject());
}
finally
{
pin.Free();
}
}
private static byte[] StructureToByteArray<T>(T value, int size) where T : struct
{
byte[] bytes = new byte[size];
GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
}
finally
{
pin.Free();
}
return bytes;
}
private static void StructureToByteArray<T>(T value, Span<byte> destination, int size) where T : struct
{
byte[] bytes = StructureToByteArray(value, size);
bytes.CopyTo(destination);
}
}
-158
View File
@@ -1,158 +0,0 @@
namespace WhiteMagic.Native;
/// <summary>
/// Access rights that open a process object.
/// </summary>
[Flags]
public enum ProcessAccess : uint
{
/// <summary>The right to terminate the process with TerminateProcess.</summary>
Terminate = 0x0001,
/// <summary>The right to create a thread in the process.</summary>
CreateThread = 0x0002,
/// <summary>The right to operate on the address space of the process.</summary>
VmOperation = 0x0008,
/// <summary>The right to read memory with ReadProcessMemory.</summary>
VmRead = 0x0010,
/// <summary>The right to write memory with WriteProcessMemory.</summary>
VmWrite = 0x0020,
/// <summary>The right to duplicate a handle with DuplicateHandle.</summary>
DupHandle = 0x0040,
/// <summary>The right to set information about the process.</summary>
SetInformation = 0x0200,
/// <summary>The right to read information about the process, such as the exit code.</summary>
QueryInformation = 0x0400,
/// <summary>The right to suspend or resume the process.</summary>
SuspendResume = 0x0800,
/// <summary>The right to read a limited set of information about the process.</summary>
QueryLimitedInformation = 0x1000,
/// <summary>The right to use the process object for synchronization.</summary>
Synchronize = 0x00100000,
/// <summary>All access rights for a process object.</summary>
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>
[Flags]
public enum MemoryAllocationType : uint
{
/// <summary>Commit physical storage for the reserved pages. The pages start as zero.</summary>
Commit = 0x00001000,
/// <summary>Reserve a range of address space without physical storage.</summary>
Reserve = 0x00002000,
/// <summary>Reset the data in the range to indicate that it is no longer of interest.</summary>
Reset = 0x00080000,
/// <summary>Allocate memory at the highest possible address.</summary>
TopDown = 0x00100000,
}
/// <summary>
/// Values that protect a block of memory.
/// </summary>
[Flags]
public enum MemoryProtectionType : uint
{
/// <summary>No access to the committed pages.</summary>
NoAccess = 0x01,
/// <summary>Read access to the committed pages.</summary>
ReadOnly = 0x02,
/// <summary>Read and write access to the committed pages.</summary>
ReadWrite = 0x04,
/// <summary>Copy-on-write access to the committed pages.</summary>
WriteCopy = 0x08,
/// <summary>Execute access to the committed pages.</summary>
Execute = 0x10,
/// <summary>Execute and read access to the committed pages.</summary>
ExecuteRead = 0x20,
/// <summary>Execute, read, and write access to the committed pages.</summary>
ExecuteReadWrite = 0x40,
/// <summary>Execute and copy-on-write access to the committed pages.</summary>
ExecuteWriteCopy = 0x80,
/// <summary>The pages in the range become guard pages.</summary>
Guard = 0x100,
/// <summary>The system does not cache the committed pages.</summary>
NoCache = 0x200,
/// <summary>The system uses write-combined access for the pages.</summary>
WriteCombine = 0x400,
}
/// <summary>
/// Values that control how VirtualFreeEx frees memory.
/// </summary>
[Flags]
public enum MemoryFreeType : uint
{
/// <summary>Decommit the committed pages. The address range stays reserved.</summary>
Decommit = 0x4000,
/// <summary>Release the range of pages. The size must be zero.</summary>
Release = 0x8000,
}
/// <summary>
/// Values that set the initial state of a new thread.
/// </summary>
[Flags]
public enum ThreadCreationFlags : uint
{
/// <summary>The thread runs immediately after creation.</summary>
RunImmediately = 0,
/// <summary>The thread starts in a suspended state. Call ResumeThread to start it.</summary>
CreateSuspended = 0x00000004,
/// <summary>The stack-size parameter sets the reserve size of the stack.</summary>
StackSizeParamIsAReservation = 0x00010000,
}
/// <summary>
/// Flags that select the registers that the thread-context functions read or write.
/// There are separate constants for 32-bit (x86/WOW64) and 64-bit (AMD64) contexts.
/// </summary>
public static class ContextFlags
{
/// <summary>Architecture identifier for x86 contexts.</summary>
public const uint X86 = 0x00010000;
/// <summary>Architecture identifier for AMD64 contexts.</summary>
public const uint Amd64 = 0x00100000;
/// <summary>x86: SS:SP, CS:IP, FLAGS, and BP.</summary>
public const uint X86Control = X86 | 0x01;
/// <summary>x86: AX, BX, CX, DX, SI, and DI.</summary>
public const uint X86Integer = X86 | 0x02;
/// <summary>x86: DS, ES, FS, and GS.</summary>
public const uint X86Segments = X86 | 0x04;
/// <summary>x86: control, integer, and segment registers.</summary>
public const uint X86Full = X86Control | X86Integer | X86Segments;
/// <summary>AMD64: SegSs, Rsp, SegCs, Rip, and EFlags.</summary>
public const uint Amd64Control = Amd64 | 0x01;
/// <summary>AMD64: Rax, Rcx, Rdx, Rbx, Rbp, Rsi, Rdi, and R8 to R15.</summary>
public const uint Amd64Integer = Amd64 | 0x02;
/// <summary>AMD64: SegDs, SegEs, SegFs, and SegGs.</summary>
public const uint Amd64Segments = Amd64 | 0x04;
/// <summary>AMD64: control, integer, and segment registers.</summary>
public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
}
-176
View File
@@ -1,176 +0,0 @@
using System.Runtime.InteropServices;
namespace WhiteMagic.Native;
/// <summary>
/// P/Invoke declarations for the Win32 process, memory, thread, and module
/// APIs that WhiteMagic uses. Every declaration uses <see cref="LibraryImportAttribute"/>
/// (source-generated interop). SetLastError is enabled on all calls that the
/// Win32 API documents as setting a thread-local last-error value.
/// </summary>
internal static partial class NativeMethods
{
// ── Process ──────────────────────────────────────────────────────────────
/// <summary>Opens an existing process and returns a handle to it.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle OpenProcess(
ProcessAccess desiredAccess,
[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>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ReadProcessMemory(
SafeMemoryHandle process,
IntPtr baseAddress,
Span<byte> buffer,
int size,
out nint bytesRead);
/// <summary>Writes memory to a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool WriteProcessMemory(
SafeMemoryHandle process,
IntPtr baseAddress,
ReadOnlySpan<byte> buffer,
int size,
out nint bytesWritten);
/// <summary>Reserves or commits a region of memory in a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial IntPtr VirtualAllocEx(
SafeMemoryHandle process,
IntPtr address,
nint size,
MemoryAllocationType allocationType,
MemoryProtectionType protect);
/// <summary>Changes the protection on a committed region of memory.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool VirtualProtectEx(
SafeMemoryHandle process,
IntPtr address,
nint size,
MemoryProtectionType newProtect,
out MemoryProtectionType oldProtect);
/// <summary>Releases or decommits a region of memory in a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool VirtualFreeEx(
SafeMemoryHandle process,
IntPtr address,
nint size,
MemoryFreeType freeType);
// ── Threading ────────────────────────────────────────────────────────────
/// <summary>Creates a thread that runs in the virtual address space of a process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle CreateRemoteThread(
SafeMemoryHandle process,
IntPtr threadAttributes,
nint stackSize,
IntPtr startAddress,
IntPtr parameter,
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)]
internal static partial bool SetThreadContext(
SafeMemoryHandle thread,
ref Context64 context);
/// <summary>Gets a 64-bit thread context (AMD64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GetThreadContext(
SafeMemoryHandle thread,
ref Context64 context);
/// <summary>Sets a 32-bit thread context (x86 or WOW64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetThreadContext(
SafeMemoryHandle thread,
ref Context32 context);
/// <summary>Gets a 32-bit thread context (x86 or WOW64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GetThreadContext(
SafeMemoryHandle thread,
ref Context32 context);
// ── Modules ──────────────────────────────────────────────────────────────
/// <summary>Loads a module into the calling process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "LoadLibraryW")]
internal static partial IntPtr LoadLibrary(
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
/// <summary>Returns the address of a function or variable from a loaded module.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial IntPtr GetProcAddress(
IntPtr hModule,
[MarshalAs(UnmanagedType.LPStr)] string lpProcName);
/// <summary>Waits until an object is signaled or the timeout elapses. Returns a
/// <c>WAIT_*</c> status (DWORD); <c>WAIT_FAILED</c> is <c>0xFFFFFFFF</c>.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint WaitForSingleObject(
SafeMemoryHandle handle,
uint milliseconds);
}
-207
View File
@@ -1,207 +0,0 @@
using System.Runtime.InteropServices;
namespace WhiteMagic.Native;
/// <summary>
/// The x87 and MMX state inside a 32-bit thread context.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public unsafe struct FloatingSaveArea32
{
/// <summary>The x87 FPU control word.</summary>
public uint ControlWord;
/// <summary>The x87 FPU status word.</summary>
public uint StatusWord;
/// <summary>The x87 FPU tag word.</summary>
public uint TagWord;
/// <summary>The offset of the instruction that caused the last FPU exception.</summary>
public uint ErrorOffset;
/// <summary>The selector of the instruction that caused the last FPU exception.</summary>
public uint ErrorSelector;
/// <summary>The offset of the operand that caused the last FPU exception.</summary>
public uint DataOffset;
/// <summary>The selector of the operand that caused the last FPU exception.</summary>
public uint DataSelector;
/// <summary>The 80-byte register area.</summary>
public fixed byte RegisterArea[80];
/// <summary>The CR0 numeric-processor-extension state.</summary>
public uint Cr0NpxState;
}
/// <summary>
/// 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)]
public unsafe struct Context32
{
/// <summary>Selects which parts of the context are valid. See <see cref="ContextFlags"/>.</summary>
public uint ContextFlags;
/// <summary>Debug register 0.</summary>
public uint Dr0;
/// <summary>Debug register 1.</summary>
public uint Dr1;
/// <summary>Debug register 2.</summary>
public uint Dr2;
/// <summary>Debug register 3.</summary>
public uint Dr3;
/// <summary>Debug register 6.</summary>
public uint Dr6;
/// <summary>Debug register 7.</summary>
public uint Dr7;
/// <summary>The floating-point state.</summary>
public FloatingSaveArea32 FloatSave;
/// <summary>The GS segment.</summary>
public uint SegGs;
/// <summary>The FS segment.</summary>
public uint SegFs;
/// <summary>The ES segment.</summary>
public uint SegEs;
/// <summary>The DS segment.</summary>
public uint SegDs;
/// <summary>The EDI register.</summary>
public uint Edi;
/// <summary>The ESI register.</summary>
public uint Esi;
/// <summary>The EBX register.</summary>
public uint Ebx;
/// <summary>The EDX register.</summary>
public uint Edx;
/// <summary>The ECX register.</summary>
public uint Ecx;
/// <summary>The EAX register.</summary>
public uint Eax;
/// <summary>The base (frame) pointer.</summary>
public uint Ebp;
/// <summary>The instruction pointer.</summary>
public uint Eip;
/// <summary>The CS segment.</summary>
public uint SegCs;
/// <summary>The flags register.</summary>
public uint EFlags;
/// <summary>The stack pointer.</summary>
public uint Esp;
/// <summary>The SS segment.</summary>
public uint SegSs;
/// <summary>The extended (processor-specific) registers. The size is 512 bytes.</summary>
public fixed byte ExtendedRegisters[512];
}
/// <summary>
/// A 64-bit (AMD64) thread context. Use it with the native
/// <c>GetThreadContext</c> and <c>SetThreadContext</c> from a 64-bit process.
/// The structure needs 16-byte alignment. The total size is 1232 bytes.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 16)]
public unsafe struct Context64
{
/// <summary>Home storage for a register parameter.</summary>
public ulong P1Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P2Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P3Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P4Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P5Home;
/// <summary>Home storage for a register parameter.</summary>
public ulong P6Home;
/// <summary>Selects which parts of the context are valid. See <see cref="ContextFlags"/>.</summary>
public uint ContextFlags;
/// <summary>The MXCSR register.</summary>
public uint MxCsr;
/// <summary>The CS segment.</summary>
public ushort SegCs;
/// <summary>The DS segment.</summary>
public ushort SegDs;
/// <summary>The ES segment.</summary>
public ushort SegEs;
/// <summary>The FS segment.</summary>
public ushort SegFs;
/// <summary>The GS segment.</summary>
public ushort SegGs;
/// <summary>The SS segment.</summary>
public ushort SegSs;
/// <summary>The flags register.</summary>
public uint EFlags;
/// <summary>Debug register 0.</summary>
public ulong Dr0;
/// <summary>Debug register 1.</summary>
public ulong Dr1;
/// <summary>Debug register 2.</summary>
public ulong Dr2;
/// <summary>Debug register 3.</summary>
public ulong Dr3;
/// <summary>Debug register 6.</summary>
public ulong Dr6;
/// <summary>Debug register 7.</summary>
public ulong Dr7;
/// <summary>The RAX register.</summary>
public ulong Rax;
/// <summary>The RCX register.</summary>
public ulong Rcx;
/// <summary>The RDX register.</summary>
public ulong Rdx;
/// <summary>The RBX register.</summary>
public ulong Rbx;
/// <summary>The stack pointer.</summary>
public ulong Rsp;
/// <summary>The base (frame) pointer.</summary>
public ulong Rbp;
/// <summary>The RSI register.</summary>
public ulong Rsi;
/// <summary>The RDI register.</summary>
public ulong Rdi;
/// <summary>The R8 register.</summary>
public ulong R8;
/// <summary>The R9 register.</summary>
public ulong R9;
/// <summary>The R10 register.</summary>
public ulong R10;
/// <summary>The R11 register.</summary>
public ulong R11;
/// <summary>The R12 register.</summary>
public ulong R12;
/// <summary>The R13 register.</summary>
public ulong R13;
/// <summary>The R14 register.</summary>
public ulong R14;
/// <summary>The R15 register.</summary>
public ulong R15;
/// <summary>The instruction pointer.</summary>
public ulong Rip;
/// <summary>The XMM save area. The size is 512 bytes.</summary>
public fixed byte FltSave[512];
/// <summary>The vector registers (26 entries of 16 bytes, stored as 52 entries of 8 bytes).</summary>
public fixed ulong VectorRegister[52];
/// <summary>The vector control register.</summary>
public ulong VectorControl;
/// <summary>The debug-control MSR.</summary>
public ulong DebugControl;
/// <summary>The target RIP of the last branch.</summary>
public ulong LastBranchToRip;
/// <summary>The source RIP of the last branch.</summary>
public ulong LastBranchFromRip;
/// <summary>The target RIP of the last exception.</summary>
public ulong LastExceptionToRip;
/// <summary>The source RIP of the last exception.</summary>
public ulong LastExceptionFromRip;
}
-34
View File
@@ -1,34 +0,0 @@
using Microsoft.Win32.SafeHandles;
namespace WhiteMagic.Native;
/// <summary>
/// A Win32 handle (process, thread, or snapshot) with a managed lifetime.
/// The handle closes with <c>CloseHandle</c>, even after an exception or a thread abort.
/// </summary>
/// <remarks>The pattern comes from MemorySharp's SafeMemoryHandle.</remarks>
public sealed class SafeMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid
{
/// <summary>
/// Makes an empty handle. The interop marshaller uses this constructor for a
/// handle that a system call returns (for example, <see cref="NativeMethods.OpenProcess"/>).
/// </summary>
public SafeMemoryHandle() : base(true)
{
}
/// <summary>
/// Wraps a raw handle and takes ownership of the handle.
/// </summary>
/// <param name="handle">The handle to own.</param>
public SafeMemoryHandle(IntPtr handle) : base(true)
{
SetHandle(handle);
}
/// <inheritdoc />
protected override bool ReleaseHandle()
{
return NativeMethods.CloseHandle(handle);
}
}
-182
View File
@@ -1,182 +0,0 @@
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;
}
-92
View File
@@ -1,92 +0,0 @@
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);
}
}
-73
View File
@@ -1,73 +0,0 @@
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);
}
}
-101
View File
@@ -1,101 +0,0 @@
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);
}
}
-50
View File
@@ -1,50 +0,0 @@
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);
}
-61
View File
@@ -1,61 +0,0 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic;
/// <summary>
/// Shared ReadProcessMemory / WriteProcessMemory wrappers used by both
/// <see cref="ExternalReader"/> and <see cref="InProcessReader"/>. Kept in a single
/// location to keep the two readers byte-for-byte consistent on partial-read handling,
/// write-return semantics, and failure modes.
/// </summary>
internal static class RpmHelper
{
/// <summary>
/// Reads up to <paramref name="count"/> bytes from <paramref name="address"/> in
/// the process identified by <paramref name="handle"/>. Returns:
/// <list type="bullet">
/// <item>An empty array if <see cref="NativeMethods.ReadProcessMemory"/> fails and
/// reports zero bytes read.</item>
/// <item>A truncated array of exactly <c>bytesRead</c> bytes when the call returns
/// <see langword="false"/> but the OS has placed a partial copy in the buffer
/// (for example, <c>ERROR_PARTIAL_COPY</c>).</item>
/// <item>The full buffer on success.</item>
/// </list>
/// </summary>
public static byte[] ReadBytes(SafeMemoryHandle handle, IntPtr address, int count)
{
byte[] buffer = new byte[count];
bool ok = NativeMethods.ReadProcessMemory(handle, address, buffer, count, out nint bytesRead);
if (!ok && bytesRead == 0)
{
return [];
}
if ((int)bytesRead < count)
{
// Either a successful short read, or a failed-but-partial RPM. In both
// cases honor the bytes the OS actually produced rather than padding.
byte[] partial = new byte[(int)bytesRead];
Buffer.BlockCopy(buffer, 0, partial, 0, (int)bytesRead);
return partial;
}
return buffer;
}
/// <summary>
/// Writes <paramref name="bytes"/> to <paramref name="address"/> in the process
/// identified by <paramref name="handle"/>. Returns the number of bytes actually
/// written, or 0 on total failure.
/// </summary>
public static int WriteBytes(SafeMemoryHandle handle, IntPtr address, ReadOnlySpan<byte> bytes)
{
if (!NativeMethods.WriteProcessMemory(handle, address, bytes, bytes.Length, out nint written))
{
return 0;
}
return (int)written;
}
}
-98
View File
@@ -1,98 +0,0 @@
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);
}
}
-26
View File
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<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>
-153
View File
@@ -1,153 +0,0 @@
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;
}
}
-74
View File
@@ -1,74 +0,0 @@
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.
}
}
-103
View File
@@ -1,103 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Native;
namespace WhiteMagicTest;
/// <summary>
/// Tests for relative/absolute addressing in <see cref="MemoryBase"/>.
/// GetAbsolute(relative) = ImageBase + relative.
/// GetRelative(absolute) = absolute - ImageBase (inverse of GetAbsolute).
/// </summary>
public class AddressingTests
{
private static ExternalReader OpenSelf()
{
return new ExternalReader(
Process.GetCurrentProcess(),
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
}
[Fact]
public void GetAbsolute_resolves_relative_offset()
{
using var reader = OpenSelf();
IntPtr imageBase = reader.ImageBase;
IntPtr result = reader.GetAbsolute((IntPtr)0x1000);
Assert.Equal(imageBase + 0x1000, result);
}
[Fact]
public void GetRelative_returns_absolute_minus_image_base()
{
using var reader = OpenSelf();
IntPtr imageBase = reader.ImageBase;
IntPtr absolute = imageBase + 0x2000;
IntPtr relative = reader.GetRelative(absolute);
Assert.Equal((IntPtr)((nint)absolute - (nint)imageBase), relative);
}
[Fact]
public void GetAbsolute_and_GetRelative_are_inverses()
{
using var reader = OpenSelf();
IntPtr offset = (IntPtr)0x3000;
// Round-trip: offset -> absolute -> back to offset
IntPtr absolute = reader.GetAbsolute(offset);
IntPtr back = reader.GetRelative(absolute);
Assert.Equal(offset, back);
// Reverse round-trip: absolute -> offset -> back to absolute
IntPtr relative = reader.GetRelative(absolute);
IntPtr absoluteAgain = reader.GetAbsolute(relative);
Assert.Equal(absolute, absoluteAgain);
}
[Fact]
public void GetRelative_on_ImageBase_returns_zero()
{
using var reader = OpenSelf();
IntPtr relative = reader.GetRelative(reader.ImageBase);
Assert.Equal(IntPtr.Zero, relative);
}
[Fact]
public void Read_with_isRelative_true_uses_image_base()
{
using var reader = OpenSelf();
// DOS header 'MZ' at the image base
byte firstByte = reader.Read<byte>(IntPtr.Zero, isRelative: true);
Assert.Equal(0x4D, firstByte);
}
[Fact]
public void Write_with_isRelative_true_resolves_correctly()
{
using var reader = OpenSelf();
int slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr absolute = pin.AddrOfPinnedObject();
IntPtr relative = reader.GetRelative(absolute);
Assert.True(reader.Write(relative, 42, isRelative: true));
Assert.Equal(42, reader.Read<int>(absolute));
}
finally
{
pin.Free();
}
}
[Fact]
public void ReadBytes_with_isRelative_true_resolves_correctly()
{
using var reader = OpenSelf();
byte[] data = reader.ReadBytes(IntPtr.Zero, 2, isRelative: true);
Assert.Equal(0x4D, data[0]);
Assert.Equal(0x5A, data[1]);
}
}
@@ -1,159 +0,0 @@
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.18.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);
}
}
@@ -1,245 +0,0 @@
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);
}
}
@@ -1,188 +0,0 @@
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);
}
}

Some files were not shown because too many files have changed in this diff Show More