Author SHA1 Message Date
kbe da342d355e Remove legacy BlackMagic specs 2026-07-22 19:12:24 +02:00
kbe 1169fdb994 Address review findings for thread control and process discovery
- Drop the false WOW64 claim from GetContext32/SetContext32 docs and guard them for 32-bit targets only.\n- Make FrozenThread dispose the thread handles it owns; make Freeze(predicate) dispose filtered-out threads.\n- Pass the already-validated handle through GetThreadById instead of opening a second one.\n- Add no-progress guard to MemoryBase.EnumerateRegions.\n- Dispose unmatched Process candidates in ApplicationFinder.OpenProcess.\n- Clean up RemoteThreadExecutor allocation formatting.
2026-07-22 17:18:48 +02:00
kbe 3e294dc846 Add process discovery helpers and Magic facade accessors
Introduces ApplicationFinder (by name/title/handle), Magic.Open overloads, and Magic.Threads/Regions/QueryRegion accessors. Closes section 3 of add-thread-region-finder and updates tasks/comparison doc.
2026-07-22 16:04:53 +02:00
kbe 8f988768fe Fix thread namespace collision and tighten executable stub allocation
Fully qualifies System.Threading.Thread in DllInjector after introducing the WhiteMagic.Thread namespace, and replaces the broken+too-small near-allocation loop with a symmetric +/-2 GiB search so the x64 call stub always lands within rel32 range.
2026-07-22 16:04:41 +02:00
kbe 9aef9c21e3 Add thread control surfaces
Adds RemoteThread, ThreadFactory (enumeration, main-thread selection, get-by-id), and FrozenThread scoped freeze. Supports suspend/resume, 32/64-bit context round-trip, TEB query, and reverse-order resume on dispose. Closes section 2 of add-thread-region-finder.
2026-07-22 16:04:30 +02:00
kbe f0faca3112 Add memory-region query, enumeration, and scoped protection
Implements VirtualQueryEx + MEMORY_BASIC_INFORMATION wrappers, the immutable MemoryRegion record, the ProtectionScope disposable helper, and MemoryBase.QueryRegion/EnumerateRegions/ChangeProtection. Closes section 1 of add-thread-region-finder.
2026-07-22 16:04:15 +02:00
kbeandClaude Opus 4.8 e8c84f0ba1 Fix IcedAssembler immediate overflow and unwrap invoke exceptions
- TryBind wraps Convert.ChangeType in TryChangeType so an immediate that
  overflows a candidate parameter type returns false (letting a wider overload
  be tried) instead of throwing OverflowException out of assembly. Verified:
  "mov eax, 4294967295" and "mov eax, -2147483649" no longer crash.
- Immediate now carries a boxed long OR ulong; TryParseImmediate parses decimal
  values above long.MaxValue via a ulong fallback, and hex via ulong. Previously
  such literals were rejected at parse time.
- Unwrap TargetInvocationException from method.Invoke so callers see the real
  Iced failure, not the reflection wrapper.

Tests: 231 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:43:48 +02:00
kbeandClaude Opus 4.8 0ddd812829 Address review: forwarder split, CreateDelegate guard, API-set docs
- PeHeaderParser: split export forwarders on the FIRST dot (IndexOf), not the
  last. A forwarder is "Module.Function" and the module name has no extension, so
  the last-dot split misparsed export names that themselves contain a dot.
- PeHeaderParser: document that API-set (api-ms-win-*/ext-ms-*) and ordinal
  forwarders are unsupported and should be resolved via the OS loader.
- RemoteFunction.CreateDelegate now throws InvalidOperationException unless the
  session is in-process; an external target's address is not host-mapped and a
  delegate to it would access-violate on invocation. Tests cover both paths.
- Reword the SSE-payload comment: the 16-byte scratch sits below the saved
  return address, which the aligned store leaves intact (it never overwrote it).

Tests: 223 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:22:29 +02:00
kbeandClaude Opus 4.8 30d5a9a5ec Add optional Iced backend: arbitrary text assembly and full prologue validation
Section 8 of the whitemagic-foundation change.

- 8.1: reference Iced 1.21.0 behind IcedAssembler:IAssembler. The default
  StubAssembler path never touches Iced; only constructing IcedAssembler pulls
  it into a behavioral path.
- 8.2: IcedAssembler.Assemble bridges Intel-syntax text onto Iced's fluent
  Assembler by reflection (Iced ships no text parser). Registers, immediates and
  labels are supported with origin-relative encoding; memory operands throw
  NotSupportedException.
- 8.3: IcedAssembler.GetPrologueLength decodes arbitrary instructions via Iced's
  Decoder. DetourManager.PrologueLengthResolver (new delegate) defaults to the
  built-in PrologueDecoder and is swappable to the Iced resolver, threaded into
  each Detour. This lifts the "partial boundary safety" caveat on the hooking
  slice when Iced is opted in.

Also gitignore test-run TestResults artifacts.

Tests: 221 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:56:31 +02:00
kbeandClaude Opus 4.8 af7e3dc1b9 Close out verification tasks 9.3 and 9.4
- 9.3: BlackMagic reference tests confirmed green (17 passing, 0 failing);
  WhiteMagic is additive and shares no source or build with it.
- 9.4: document the implementation deviations in the comparison doc's
  synthesis section and correct the InProcessReader architecture line
  (RPM-on-self, not direct deref).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:54:28 +02:00
kbeandClaude Opus 4.8 678cb00895 Implement RemoteModule/RemoteFunction and prove x64 ABI at runtime
Task 7.2: export resolution + module/function facade.
- PeHeaderParser.GetExportAddress walks the PE32/PE32+ export directory and
  follows export forwarders (e.g. kernel32!HeapAlloc -> NTDLL.RtlAllocateHeap)
  into other loaded modules; ordinal and unresolvable API-set forwarders throw
  NotSupportedException.
- RemoteModule resolves a module base via Process.Modules (name match tolerant
  of .dll/case); RemoteFunction executes via RemoteThreadExecutor by default,
  exposes Address for pump routing and CreateDelegate<T> for in-process.
- Magic gains a string indexer: magic["user32"]["MessageBoxA"].

Task 3.8: add the missing live-execution ABI test - an SSE callee whose aligned
movaps #GPs unless the stub delivers a 16-byte-aligned stack, combined with a
5th stack argument. Runtime-proves shadow space, alignment, and arg placement.

Tests: 214 passing, 4 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:49:51 +02:00
kbe eda467bc46 Fix 32-bit thread context API selection in DllInjector
Because DllInjector enforces matching host/target bitness, a 32-bit caller
always handles a 32-bit target. The correct API is native
GetThreadContext/SetThreadContext with Context32; the Wow64 APIs are only for
64-bit processes inspecting WOW64 targets, which never happens here.

- Collapse the 32-bit path to always use GetThreadContext/SetThreadContext.
- Remove the now-unused Wow64GetThreadContext/Wow64SetThreadContext
  declarations.
- Update Context32 doc comment to describe the x86 usage.
- ExternalReader access guard now also accepts QueryLimitedInformation.

Tests: 207 passing, 4 skipped.
2026-07-22 02:31:32 +02:00
kbe 286bb9cab1 Add local agent workspace files and skills
Includes .agents/, additional .claude/skills/, and skills-lock.json.
2026-07-22 02:24:51 +02:00
kbe a34389fbba Fix 32-bit host context APIs and ExternalReader bitness detection
- Add GetThreadContext/SetThreadContext overloads accepting Context32 so a
  32-bit process on a native 32-bit OS can capture x86 thread context.
- DllInjector.InjectWithThreadHijack now selects the context API based on
  both process bitness and OS bitness:
  * 64-bit process -> native 64-bit context
  * 32-bit process on 64-bit OS -> WOW64 context
  * 32-bit process on 32-bit OS -> native x86 context
- ExternalReader now validates that the caller supplied
  ProcessAccess.QueryInformation, and surfaces any IsWow64Process failure
  instead of silently falling back to host bitness.

Tests: 207 passing, 4 skipped.
2026-07-22 02:24:06 +02:00
kbe 1911514120 Fix bounds, memory protection, and completion race in core helpers
- AllocatedMemory.Read<T>/Write<T>/ReadBytes/WriteBytes now validate that
  the requested byte range stays within the allocated block before calling
  into the memory accessor.
- Patch.Apply/Remove temporarily changes the target page to read-write and
  restores the original protection, mirroring the Detour behavior.
- MainThreadPump.WorkItem uses TrySetResult/TrySetException and swallows the
  InvalidOperationException raised when a completion source is already
  completed, preventing Dispose from failing during concurrent pump drainage.

Regression tests added for all three fixes.

Tests: 206 passing, 4 skipped.
2026-07-22 02:16:23 +02:00
kbe ffd72b37ed Consolidate whitemagic-foundation implementation into develop
Replace the previous partial develop snapshot with the completed
whitemagic-foundation implementation. This includes the managed memory
access layer, diagnostic interop helpers, process introspection utilities,
and supporting test suite.

Prior develop state kept in branch develop-backup-20260722-014135.
2026-07-22 01:58:58 +02:00
kbe d04428b42e Fix fallback read too short for sparse prologues in redirection helper
Replace the two-attempt read with a single read sized to the smaller of:
- detourLength + 16 (the decoder's preferred window), and
- bytes remaining in the current page (so ReadProcessMemory does not fail whole read).

Reading only detourLength bytes could leave the instruction analyzer without enough
bytes to resolve a multi-byte instruction that crosses the splice point on
sparse prologues. Reading up to the page boundary gives the largest safe window.

Tests: 199 passing, 4 integration/interactive skipped.
2026-07-22 01:36:52 +02:00
kbe 64be4f8275 Fix page-boundary read issue in redirection helper
Risk mitigation:
- redirect now falls back to reading the minimum required bytes (detourLength) if the full buffer (detourLength + 16) cannot be read due to page boundaries.
- First attempt: read detourLength + 16 bytes for the instruction analyzer (preferred).
- Second attempt: read only detourLength bytes if the first attempt fails (bare minimum).
- Throw only if both attempts fail.

This prevents crashes when function interception functions that sit at the very end of a committed page.

Tests: 199 passing, 4 integration/interactive skipped.
2026-07-22 01:29:52 +02:00
kbe c40f3fd791 Fix InputSimulator wParam, PeHeaderParser double-parse, and other review issues
Bug fixes:
- InputSimulator: Pass correct button state (MK_LBUTTON/MK_RBUTTON) in wParam for button-down messages instead of 0.
- PeHeaderParser: ParseOptionalHeader now reads only the optional header, not section headers (fixes double-parse waste).
- EntryPoint: Removed useless isPe32Plus branch (AddressOfEntryPoint is at offset 16 in both PE32 and PE32+).
- RemoteWindow: Handle null foreground window case in Activate to avoid calling GetWindowThreadProcessId with HWND 0.
- RemotePointer: Remove dead null-conditional operators (encoding ??) since encoding is non-nullable.

Constants added:
- SystemMethods: MkLButton (0x0001) and MkRButton (0x0002) for mouse button state flags.

Tests: 199 passing, 4 integration/interactive skipped.
2026-07-22 01:28:42 +02:00
kbe 614806f5f1 Fix LibraryLoader crash on error during context-transfer restoration
- Track whether transferred thread's original context was successfully restored.
- In the catch block, leak the remote allocation instead of freeing it if the thread was not restored; this prevents the target process from executing freed memory.
- Remove redundant 'op == 0x55' check in InstructionAnalyzer (already matched by (op & 0xF8) == 0x50).
- Simplify MemoryBase ReadString align-down expression to previousLen - (previousLen % nullLen).
- Change StubAllocator size parameter from nint to int for clarity (internal test seam).

Tests: 199 passing, 4 integration/interactive skipped.
2026-07-22 01:24:33 +02:00
kbeandClaude Opus 4.8 77736698ab Add runtime x64 stack-alignment regression test
A non-faulting probe payload returns (rsp+8)&15 from the callee, which is 0
only when the stub delivers callee entry rsp ≡ 8 (mod 16) per the Microsoft
x64 ABI. Proves the stub frame alignment end-to-end through CreateRemoteThread
without risking a #GP that would crash the in-process test host. Verified to
fail against the old fixed-0x20 frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:13:45 +02:00
kbe fa0b5b5013 Add observable alloc/free seams to BackgroundTaskExecutor and fix StubAllocator ownership
- Add RemoteAllocator / RemoteReleaser internal test seams for string/struct scratch memory.
- Route all scratch allocation/freeing through the seams so tests can observe leaks.
- Fix inverted StubAllocator ownership: a caller-provided stub is now never freed by the executor.
- Rewrite Execute_releases_allocated_remote_memory_on_write_failure to fail pre-fix by tracking fake allocations through the seams.
- Update WriteFailingMemoryBase to carry a valid self-handle so the executor reaches the marshal/write path.

Tests: 198 passing, 4 integration/interactive skipped.
2026-07-22 00:51:39 +02:00
kbe 44a368de9c Fix dispatcher crash/deadlock, instruction analyzer, cache equality, task leak, and redirection protection
- MainThreadDispatcher: guard DispatchHook with try/catch so exceptions never escape to native caller; drain and fault pending work on Dispose; synchronize Execute/ExecuteAsync/Dispose against race/dispose.
- InstructionAnalyzer: require ModRM 0xEC for 0x83/0x81 sub-esp/rsp forms, rejecting unsafe RIP-relative or memory forms.
- PatternScannerCache: implement value equality on CacheKey so repeated scans actually hit cache.
- BackgroundTaskExecutor: add remote allocations to the free list immediately after VirtualAllocEx, before any write that could fail and leak.
- redirect: capture and restore original page protection in Apply/Remove instead of leaving target RWX.
- Regression tests for all six fixes.

Tests: 198 passing, 4 integration/interactive skipped.
2026-07-22 00:32:19 +02:00
kbe 3f0bea6bd4 Implement core diagnostic memory layer, execution helpers, and high-level facade slices
Implemented:
- Core: UTF-16 ReadString boundary/alignment fix, target bitness and process id on MemoryBase
- function interception: PatchManager, DetourManager, InstructionAnalyzer, MainThreadDispatcher
- Execution: BackgroundTaskExecutor, InProcessInvoker
- High-level: Magic facade, RemotePointer, async wrappers
- Discovery/external code loading/Window groundwork (PEB/TEB, pattern scanning, raw allocations, DLL external code loading, window/input)

Tests: 180 passing, 4 integration/interactive tests skipped.
2026-07-21 23:43:14 +02:00
kbeandClaude Opus 4.8 a0ca7050a2 openspec: mark Phase 3 done; record Phase 2/3 review deviations
Phase 3 (managed assembler) is implemented and tested — check 3.1-3.9.
Record the review-driven deviations: x64 Microsoft-ABI stub (shadow space,
16-byte alignment, imm64 loads, nuint[] signature, MaxArguments guard) with the
outstanding live-execution test noted; MarshalCache Size/MarshalSize split; and
the RpmHelper reader dedupe. Task 2.10 (ReadString code-unit-aligned scan)
stays open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:30:11 +02:00
kbeandClaude Opus 4.8 75ff4a2320 Guard diagnostic stub builder against argument-count overflow
The x64 frame math (0x20 + 8*stackArgs) and x86 arg buffer size grow with the
argument count. An absurdly large count could overflow int and produce a bogus
or negative frame. Add a MaxArguments (256) bound checked at the public entry —
far above any real calling convention — so the arithmetic stays in range. Add a
test asserting the cap is inclusive and count+1 throws.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:30:11 +02:00
kbe e2654fff91 Agents now merge to develop instead of master 2026-07-21 22:30:11 +02:00
kbeandClaude Opus 4.8 12b9b6c03e Fix x64 stub ABI and marshal-path sizing; dedupe memory readers
x64 call stub was ABI-broken: fixed 0x20 frame left rsp misaligned at the
inner call (callee entry rsp ≡ 0, ABI requires ≡ 8) and, for 5+ args, wrote
stack args over the return address. Compute frame K ≡ 8 (mod 16), K ≥
0x20 + 8*stackArgs, so the callee sees a 16-aligned stack and stack args land
above the shadow window. Load register args as full 64-bit imm64 (was imm32,
which truncated pointers > 4 GiB). BuildCallStub now takes nuint[]; x86 range-
checks each arg against uint.MaxValue instead of silently truncating.

MarshalCache conflated managed and unmanaged width in one Size field: the
blittable path needs Unsafe.SizeOf<T> (bool = 1) while the marshal path needs
Marshal.SizeOf<T> (inline ByValTStr/ByValArray expand past the managed
pointer). Add MarshalSize; MemoryBase picks per TypeRequiresMarshal at all four
IO sites. Prevents PtrToStructure/StructureToPtr from over-reading/overwriting
the pinned scratch buffer (heap corruption on write).

Extract shared RPM/WPM into RpmHelper: honor partial reads (dead Array.Resize
removed), consistent write-return semantics; InProcessReader now guards
MainModule like ExternalReader.

Tests: x64 frame-alignment property + inline-marshal round-trip added (both
fail against the pre-fix code); existing x64 byte-expectation tests updated to
the new frame. Build clean, 100/100 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:30:10 +02:00
kbe 184dec86ca ARchive old spec 2026-07-21 22:30:10 +02:00
kbe 0380705e76 Initial commit 2026-07-21 22:30:10 +02:00
kbe 21d2dd0460 Initial commit 2026-07-21 20:28:59 +00:00
108 changed files with 15555 additions and 143 deletions
+48
View File
@@ -0,0 +1,48 @@
# caveman
Talk like smart caveman. Same brain, fewer tokens.
## What it does
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
Six intensity levels:
| Level | What change |
|-------|-------------|
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
| `full` | Default. Drop articles, fragments OK, short synonyms. |
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
| `wenyan-lite` | Classical Chinese register, light compression. |
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
| `wenyan-ultra` | Extreme classical compression. |
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
## How to invoke
```
/caveman # full mode (default)
/caveman lite # lighter compression
/caveman ultra # extreme compression
/caveman wenyan # classical Chinese
stop caveman # back to normal prose
```
## Example output
Question: "Why does my React component re-render?"
Normal prose:
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
Caveman (full):
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
Caveman (ultra):
> Inline obj prop → new ref → re-render. `useMemo`.
## See also
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
- [Caveman README](../../README.md) — repo overview, install, benchmarks
+78
View File
@@ -0,0 +1,78 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
wenyan-lite, wenyan-full, wenyan-ultra.
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Persistence
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
Default: **full**. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact.
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | What change |
|-------|------------|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
Example — "Explain database connection pooling."
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
## Auto-Clarity
Drop caveman when:
- Security warnings
- Irreversible action confirmations
- Multi-step sequences where fragment order or omitted conjunctions risk misread
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
- User asks to clarify or repeats question
Resume caveman after clear part done.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
> DROP TABLE users;
> ```
> Caveman resume. Verify backup exist first.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
+86
View File
@@ -0,0 +1,86 @@
---
name: fuck-slop
description: >
De-slop pass for any text: detects and erases the statistical fingerprints of
AI writing (negative parallelism / "not X but Y", em-dash abuse, rule-of-three,
false ranges, puffery vocabulary, uniform cadence, hedged both-sidesing) and
rewrites the text into its target register — academic article, tweet, reddit
post, email, blog, anything between. Use when the user says "fuck slop",
"f*ck slop", "deslop", "de-slop this", "remove the AI tells", "humanize this",
"make this not sound like AI", or invokes /fuck-slop. Also use before
publishing any agent-drafted prose.
---
# F*ck Slop
Strip every mark of AI writing from a text and make it good in its genre. Not "make it pass a detector" — make it read like a specific person with a specific point wrote it for a specific audience.
## Why this is a loop, not a style guide
The worst tells — above all the **"not X but Y"** family — are not vocabulary mistakes. They are emergent properties of how LLMs generate text: preference tuning rewards balanced, contrastive, comprehensive-sounding framing, so the contrast move is baked into the model's priors. Two consequences drive this skill's architecture:
1. **You cannot reliably see your own slop.** The same priors that produce the pattern make it invisible on re-read. Detection must be mechanical — regex against a fixed catalog — never "does this look AI to me?"
2. **Rewriting reintroduces slop.** Ask a model to remove "it's not just X, it's Y" and it produces "this is less about X than Y" — the same move in a wig. So every rewrite gets re-scanned, and the loop runs until the scan is clean.
Workflow: **Scan → Diagnose → Rewrite by meaning → Re-scan → (repeat) → Register check.**
## Phase 0: Fix the target
Before touching the text, establish:
- **Genre and venue** — academic article, tweet, reddit post, LinkedIn, email, blog, docs, marketing. If not stated and not obvious from the text, ask. Genre decides which tells are fatal and what "good" means; see [references/voices.md](references/voices.md).
- **Audience and stance** — who reads it, and what the author actually claims. Slop is what fills the space where a claim should be; you cannot remove it without knowing the claim.
- **Constraints** — length limits, required citations, house style.
## Phase 1: Mechanical scan
Run the detection patterns from [references/tells.md](references/tells.md) against the text. If the text is in a file (or you can write it to a temp file), run the grep commands in that reference literally — the catalog is written as runnable `grep -Ein` patterns. Otherwise apply each pattern by hand, line by line.
Produce a finding list: line/sentence, matched pattern, tell category. Also run the two structural checks that regex can't fully catch:
- **Cadence**: flag any run of 3+ consecutive sentences within ±4 words of the same length, and any paragraph where every sentence has the same shape (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).
@@ -0,0 +1,171 @@
# AI-Writing Tell Catalog
Detection patterns for the F*ck Slop scan. Patterns are written for `grep -Ein` (extended regex, case-insensitive, line numbers) so they can be run literally against a file:
```bash
grep -Ein -f /dev/stdin draft.txt <<'PATTERNS'
<paste patterns from a section below, one per line>
PATTERNS
```
When the text only exists in conversation, apply each pattern by hand. A match is a *finding*, not an automatic deletion — every finding goes through the Phase 2 triage in SKILL.md. Density matters: one em dash is nothing; one em dash plus a negative parallelism plus "delve" in the same paragraph is a verdict.
## 1. Negative parallelism — the "not X but Y" family
The highest-priority category. LLMs reach for the negation-then-assertion move roughly once a paragraph; humans use it occasionally and deliberately. It is an emergent generative habit, so expect it to reappear in paraphrased form after every rewrite pass — that is why the scan loops.
```
not (just|only|merely|simply|solely) [^.;]{2,80}(but|it'?s| — )
isn'?t (just|only|merely|simply|about)
it'?s not (a|an|the|that|about|just) [^.;]{2,80}(it'?s|but)
(is|was|are|were)n'?t about [^.;]{2,60}\. (it|this|that)'?s about
less about [^.;]{2,60}(than|and more about)
more than (just|a mere|simply)
not because [^.;]{2,80}but because
the (question|point|issue|problem|goal|real [a-z]+) is(n'?t| not) (whether|about|just|if)
(doesn'?t|don'?t|didn'?t|won'?t) (just|merely|simply) [^.;]{2,80}(it|they|he|she|we)
no [a-z]+, no [a-z]+(, no [a-z]+)?[,.]? just
— not [^—.;]{2,60}, but
not only [^.;]{2,80}but (also )?
we'?re not (just )?(talking about|looking at|dealing with)
gone are the days
(here|this)'?s the (thing|kicker|catch|twist)
```
Rhetorical-question variant (regex-resistant; check by hand): a one-line question immediately answered by a one-word or one-clause sentence. *"The result? Chaos."* / *"Sound familiar?"*
## 2. Puffery and inflated vocabulary
Single words that spike in LLM output. Each is fine in isolation; two or more per page is a finding. The fix is the plain word or the concrete fact the word was hiding.
```
\b(delve|delving)\b
\btapestry\b
\b(testament|stands as)\b
\bseamless(ly)?\b
\b(pivotal|paramount|crucial)\b
\bunderscore(s|d)?\b
\b(landscape|realm|sphere) of\b
\bnavigat(e|ing) the\b
\bfoster(s|ing)?\b
\bleverage(s|d)?\b
\bmeticulous(ly)?\b
\bintricate\b
\bboasts\b
\bgame.?chang(er|ing)\b
\b(seismic|monumental|transformative) (shift|change)\b
\bunwavering\b
\bcommendable\b
\belevate(s|d)? (the|your)\b
\bshowcas(e|es|ing)\b
\bresonate(s|d)?\b
\bcompelling\b
\brich (cultural )?(heritage|history|tradition)\b
\bvibrant\b
\bplays? a (vital|key|crucial|pivotal) role\b
\bdeep(er)? dive\b
\bunlock(s|ing)? (the|your)\b
\bharness(es|ing)? the\b
\bembark(s|ed|ing)? on\b
\bever.?(evolving|changing)\b
\bfast.?paced (world|environment)\b
\bin today'?s\b
\bat the end of the day\b
\bwhen it comes to\b
\bcutting.?edge\b
\brobust\b
\bholistic\b
\bsynergy\b
\bempower(s|ing|ment)?\b
```
## 3. Hedging, both-sidesing, throat-clearing
The tell is reflexive balance: every claim gets a softener, every opinion gets a counterpoint. Commit or cut.
```
it'?s (worth|important) (to note|noting|to remember|to consider)
(that|it) (being )?said,
while (it'?s|this is) (true|important)
arguably
in many ways
to some (extent|degree)
on the other hand
at its core
in essence
essentially,
ultimately,
in conclusion
in summary
to sum(marize| up)
overall,
in the end,
needless to say
as (we|you) (can see|know|all know)
let'?s (dive|unpack|explore|take a (look|closer look))
whether you('re| are) [^.;]{2,60} or
```
## 4. False ranges and rule-of-three
**False range** — a "from X to Y" with no actual spectrum between X and Y:
```
from [^.;]{3,50} to [^.;]{3,50}
```
Triage by hand: if you can name a meaningful midpoint, it's a real range and stays. If X and Y are just two loosely related examples, name them plainly or cut one.
**Rule of three** — LLMs default to triplets to make thin analysis look thorough. Regex only catches the simplest shape; check lists by hand too.
```
\b\w+, \w+, and \w+[.!?]
\b(\w+ \w+), (\w+ \w+), and (\w+ \w+)
```
Triage: keep the strongest item, cut the rest — or keep all three only if each carries distinct information, and then break the rhythm.
## 5. Punctuation and formatting
Em dash: not banned — humans use it. Findings are about **density** and the contrast move:
- More than ~1 em dash per 150 words.
- Two em dashes in one sentence.
- `— not X, but Y` (already in section 1).
- Em dash used for punchy emphasis where a comma works: `[a-z] — [a-z][^—]{1,25}\.$`
Other formatting tells (check by hand; most regexes here are layout-dependent):
- **Bold scattered through prose** like a textbook highlighting itself: `\*\*[^*]{2,40}\*\*` appearing more than ~once per 3 paragraphs of body prose.
- **"Term: definition" bullets**: `^[-*] +\*\*[^*]+:?\*\*:? ` — the signature LLM list shape.
- **Emoji headers/bullets** (🚀, ✅, 💡): needs PCRE, not `-E``LC_ALL=C.UTF-8 grep -Pn '^\s*[-*#]+\s.*[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]' draft.txt`.
- **Headers on short texts** — section headers on anything under ~400 words.
- **The tidy skeleton** — intro that previews three points, three matched sections, conclusion that restates them. Resolves too neatly; real writing has loose ends.
- **Numbered lists where a paragraph would do.**
- Curly quotes/apostrophes in a context where the author types straight ones (mixed within one text is the stronger tell).
## 6. Cadence and statistical shape
No regex; measure or eyeball.
- **Uniform sentence length** (the single strongest current tell): a run of 3+ consecutive sentences within ±4 words of each other, paragraph after paragraph of 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.
@@ -0,0 +1,61 @@
# Register Guide
What "good" means per genre, what tells are fatal there, and what the de-slopped text should sound like. Use in Phase 0 (fix the target) and Phase 4 (register check). Two universal rules first:
1. **Voice comes from commitment, not decoration.** A text sounds human when it asserts specific things a specific person believes, at the level of detail only someone who did the work would know. Slang, typos, and "personality" sprinkled on top do not produce this and read as humanizer-tool output.
2. **Match the author, not a persona.** If the user supplied earlier writing or a draft with their own phrasing in it, keep their words wherever they survive the scan. De-slopping someone into a generic "casual" voice is just different slop.
## Academic article / paper
- **Goal**: precise claims, honest hedges, dense information. Formality stays; puffery goes.
- **Fatal tells here**: "delve", "novel", inflated significance ("crucial implications", "paradigm shift"), rule-of-three in abstracts, negative parallelism in intros ("X is not merely a tool but a fundamental…"), em-dash chains.
- **De-slop moves**: replace significance puffery with the actual finding and effect size. Hedges must be calibrated, not reflexive — "may" because the evidence is genuinely uncertain, not as seasoning. Keep passive voice where the venue expects it; do not inject first person or attitude. Numbers, conditions, and citations beat adjectives.
- **Cadence**: long sentences are fine and normal; the tell is uniformity, not length. Vary clause structure.
## Tweet / X post
- **Goal**: one idea, said like a person, under the limit.
- **Fatal tells here**: "🧵", "Let that sink in", "Read that again", one-clause-per-line stacking, ending on an engagement question, hashtag clusters, the not-X-but-Y move compressed into 200 characters.
- **De-slop moves**: cut to the single claim. Lowercase is fine if that's the author's habit. No setup ("Hot take:") — just the take. A tweet that states an opinion without insurance reads human; a tweet that balances itself does not.
## Reddit post / comment
- **Goal**: reads like a knowledgeable person typing in a text box, because that's what reddit is.
- **Fatal tells here** (reddit users are the most slop-sensitive audience on the internet): **any** bold in a comment, bullet-point essays, headers, "Hope this helps!", "Great question!", symmetric pro/con framing, em-dash density, perfect paragraphing.
- **De-slop moves**: plain paragraphs, contractions, direct answers first. Mild hedges are human here ("iirc", "I might be wrong but") — but only the author's own. Concrete personal detail ("ran into this on a 2019 Outback") is the strongest human marker; never fabricate it, ask the author or drop it.
## LinkedIn post
- **Goal**: professional but specific. The platform's native style is so slop-adjacent that the bar is: would a colleague forward this without cringing?
- **Fatal tells here**: stacked one-line paragraphs, "Agree?", "Let's connect", broetry rhythm, negative parallelism (this is its natural habitat — scan twice), rule-of-three value statements, "I'm humbled to announce".
- **De-slop moves**: write actual paragraphs. Lead with the concrete event or number, not the lesson. One lesson max, stated once, not echoed in a closer.
## Email
- **Goal**: shortest text that's still warm enough for the relationship.
- **Fatal tells here**: "I hope this email finds you well", restating the recipient's email back to them, three symmetric paragraphs wrapping a one-line answer, "Please don't hesitate to reach out".
- **De-slop moves**: answer in the first sentence. Greeting and sign-off match the existing thread's register. Cut every sentence whose only job is politeness padding except one, if the relationship needs it.
## Blog post / newsletter / essay
- **Goal**: a person with a view, walking the reader through it.
- **Fatal tells here**: "In today's fast-paced world" openers, intro-that-previews-three-sections skeleton, "In conclusion", bold-scattered prose, section headers every two paragraphs, engagement-bait closers.
- **De-slop moves**: open inside the subject (a scene, a number, a claim). Let structure follow the argument instead of a template — real essays have asymmetric sections and loose ends. First person and digressions are allowed; they are how essays sound human. Keep headers only when the piece is long enough to need navigation.
## Marketing / landing copy
- **Goal**: concrete benefit, named audience, zero filler.
- **Fatal tells here**: "seamless", "unlock", "empower", "game-changing", "effortless", rule-of-three feature triplets, false ranges ("from startups to enterprises"), every header a not-X-but-Y.
- **De-slop moves**: replace each abstraction with the mechanism or the number ("Set up in 4 minutes" beats "seamless onboarding"). One verb per claim. Specificity is the whole game; if no specifics exist, that's a product-marketing problem the text can't fix — say so.
## Technical docs / README
- **Goal**: the reader gets unblocked fast.
- **Fatal tells here**: "robust", "powerful", "blazingly fast" without benchmarks, "simply"/"just" before steps that aren't, marketing voice in reference material, emoji section headers.
- **De-slop moves**: imperative mood, exact commands, exact versions, expected output. Adjectives almost to zero. Lists are fine here — docs are the one genre where "Term: definition" bullets are legitimate structure, so don't strip them; strip the puffery inside them.
## Academic-adjacent: cover letters, statements, grant prose
- **Goal**: claims about the author backed by evidence, in formal register.
- **Fatal tells here**: "passionate", "deeply committed", "unique perspective", testament/tapestry vocabulary, rule-of-three trait lists, every paragraph ending with a not-X-but-Y synthesis.
- **De-slop moves**: every trait claim becomes an event ("I led X, which produced Y"). Keep formality; cut self-puffery. The reader has read ten thousand of these — only specifics differentiate.
+209
View File
@@ -0,0 +1,209 @@
---
name: grill-me
description: Calibrated grilling session for stress-testing a plan, design, idea, or decision. First assesses the user's topic knowledge, confidence, and desired pressure level, then asks one question at a time with recommended answers. Use when user says "grill me", "stress-test this", "challenge my plan", "interview me", or wants a plan probed without being overwhelmed.
---
# Grill Me
Interview the user until the plan is clear, defensible, and ready for action.
This is not hostile debate. It is calibrated pressure. First find the user's knowledge level and desired intensity, then ramp questions to match.
## Core Rules
- Ask one question at a time.
- Give a recommended answer for every question.
- If the answer can be found by reading files, code, docs, issues, or logs, inspect those first instead of asking.
- Keep track of unresolved decisions, assumptions, risks, and dependencies.
- Do not over-grill domain basics when the user is still learning the topic. Teach the missing frame briefly, then ask the next useful question.
- Do not under-grill confident experts. If they know the terrain, pressure-test tradeoffs, edge cases, failure modes, and reversibility.
- Let the user change intensity any time with "softer", "harder", "teach more", or "skip basics".
## Phase 1: Frame The Target
Identify what should be grilled before asking about comfort. If the topic is not clear, ask:
> What plan, design, or decision should I grill?
>
> Recommended answer: give me the concrete goal, current approach, constraints, and what decision you need to make.
If context already contains the plan, summarize it in 3-6 bullets and ask for correction:
> I think target is: [...]
>
> Recommended answer: "Yes, grill that" or "Adjust: ..."
## Phase 2: Calibration
Before grilling the topic, ask a short calibration question unless the user's level is already obvious from context.
Ask:
> Before I grill the plan: what is your current comfort with this topic, and how hard do you want the pressure?
>
> Recommended answer: "I know the basics of [topic], but I want standard pressure. Explain missing concepts briefly, then keep pushing."
Use the user's answer to set two dials:
### Knowledge Level
- **New** - user lacks core vocabulary or model of the domain.
- **Working** - user understands basics and can discuss tradeoffs.
- **Expert** - user knows domain deeply and wants sharper critique.
### Pressure Level
- **Light** - clarify goals, constraints, and missing context.
- **Standard** - challenge assumptions, tradeoffs, and execution path.
- **Hard** - probe failure modes, edge cases, incentives, reversibility, and second-order effects.
If the user does not answer calibration, default to:
- Knowledge: **Working**
- Pressure: **Standard**
## Phase 3: Build The Decision Map
Create a private decision map while asking questions one at a time:
- Goal - what success means.
- User or customer - who this affects.
- Constraints - time, money, stack, team, policy, risk.
- Options - obvious alternatives and why current option wins.
- Dependencies - what must be true first.
- Risks - what breaks, gets expensive, or becomes irreversible.
- Validation - how user will know it worked.
- Rollback - how to undo or recover.
Do not dump the full map unless user asks. Use it to choose the next question.
## Phase 4: Question Ladder
Move through this ladder. Stop early if the plan becomes clear enough or user asks to stop.
### 1. Goal Fit
Questions:
- What outcome matters most?
- What would make this not worth doing?
- What problem are we solving, and for whom?
### 2. Constraint Reality
Questions:
- What hard constraint cannot move?
- What resource bottleneck decides the plan?
- What assumption would kill the plan if false?
### 3. Option Pressure
Questions:
- What are the top two alternatives?
- Why this approach over the boring one?
- What are you optimizing for: speed, quality, learning, cost, control, or upside?
### 4. Execution Path
Questions:
- What is the smallest useful version?
- What has to happen first?
- What can be deferred without harming the goal?
### 5. Failure Modes
Questions:
- How does this fail in production or real use?
- What edge case would embarrass the plan?
- What part is hardest to observe once it breaks?
### 6. Validation
Questions:
- What test, metric, screenshot, demo, or user behavior proves this works?
- What would you check before trusting it?
- What does done mean in observable terms?
### 7. Reversibility
Questions:
- What decision here is hardest to undo?
- What backup, migration, rollback, or escape hatch exists?
- What should be logged as an ADR or explicit tradeoff?
## Pressure Adaptation
### If Knowledge Is New
- Define one missing concept in 2-4 sentences before asking.
- Avoid jargon unless you define it.
- Ask fewer branching questions.
- Focus on goals, constraints, and first principles.
- Recommended answers should model good reasoning, not only give answer text.
### If Knowledge Is Working
- Ask normal tradeoff questions.
- Surface alternatives.
- Push for validation and smallest useful version.
- Challenge vague words like "simple", "scalable", "good", "clean", or "fast".
### If Knowledge Is Expert
- Skip basics.
- Ask sharper counterfactuals.
- Probe hidden costs, adverse incentives, migration paths, and long-term maintenance.
- Ask what evidence would change their mind.
### If Pressure Is Light
- Keep questions clarifying.
- Use supportive framing.
- Stop after top ambiguities are resolved.
### If Pressure Is Standard
- Challenge assumptions and tradeoffs.
- Keep moving until implementation path is concrete.
### If Pressure Is Hard
- Be direct.
- Name weak reasoning.
- Ask about unpleasant edge cases.
- Demand observable validation.
- Still ask one question at a time.
## Recommended Answer Format
Every question includes:
```text
Question: ...
Recommended answer: ...
Why it matters: ...
```
Keep "Why it matters" to one sentence.
## When To Stop
Stop grilling when one of these is true:
- User says stop.
- Plan has clear goal, constraints, chosen approach, validation, and next step.
- Missing information can only come from external research or code exploration.
- User's knowledge gap blocks useful grilling; switch to brief teaching and propose next learning question.
End with:
- Final decision or current best plan.
- Remaining open questions.
- Next concrete action.
- Risks to watch.
+582
View File
@@ -0,0 +1,582 @@
---
name: interface-kit
description: |
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
design engineering philosophy, accessibility standards, animation principles, spatial design,
typography, color systems, and component craft into a single actionable reference.
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
the HOW of implementation.
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
"frontend", "design", "polish UI", "implement design", "make it beautiful",
"UI implementation", "component styling", "animation", "accessibility"
---
# Interface Kit: Implementation Guide for Exceptional Interfaces
> If a DESIGN.md exists at the project root, its tokens and specifications override all defaults in this skill. This skill provides sensible defaults for when no design system exists, and implementation guidance that applies regardless.
> For deep dives on any section, see the reference files in this skill's `references/` directory.
---
## 1. Core Philosophy
Taste is trained, not innate. Study why great interfaces feel right. Deconstruct apps you admire — the spacing, the timing, the weight of a shadow. The gap between "fine" and "exceptional" is built from hundreds of micro-decisions that users feel but never consciously notice.
**Unseen details compound.** A single rounded corner, a single eased transition, a single well-chosen shadow — none of these matter alone. Together they become "a thousand barely audible voices singing in tune." The cumulative effect is what separates craft from output.
**Beauty is leverage.** Polish is not vanity. Good defaults, considered typography, and intentional motion are real differentiators. Users trust interfaces that feel cared for. Investors notice. Competitors can't easily replicate taste.
**Intentionality over intensity.** Both bold maximalism and refined minimalism work — what fails is the absence of a clear point of view. Every visual decision should trace back to a deliberate conceptual direction. If you can't articulate WHY a choice was made, reconsider it.
**Choose a direction and execute with precision.** Don't hedge between styles. A brutalist page committed fully will always outperform a page that's "a little bit of everything." Commit, then refine.
**NEVER produce generic "AI slop" aesthetics.** No gratuitous gradients on white backgrounds. No cookie-cutter hero sections with stock illustrations. No safe, forgettable layouts that could belong to any product. Every interface should have a point of view that makes it recognizable.
---
## 2. The Priority Stack
When implementing UI, work through these priorities in order. Higher priorities are non-negotiable; lower priorities are polish that compounds quality.
| Priority | Level | What It Means |
|----------|-------|---------------|
| **Accessibility** | CRITICAL | Contrast 4.5:1, keyboard nav, ARIA semantics, visible focus rings. Ship nothing that excludes users. |
| **Performance** | HIGH | WebP/AVIF images, lazy loading below fold, CLS < 0.1, transform-only animations on the compositor thread. |
| **Typography** | HIGH | Font smoothing, text-wrap balance/pretty, tabular-nums for data, 65ch max line length. |
| **Layout & Spatial** | HIGH | 4/8px grid, concentric border radius, optical alignment over geometric. |
| **Color & Theme** | MEDIUM | HSL custom properties, semantic tokens, dark mode pairs tested separately. |
| **Motion & Interaction** | MEDIUM | Frequency-based animation decisions, 150-300ms durations, ease-out default. |
| **Polish & Details** | LOW | Layered shadows over borders, press feedback on buttons, staggered enter animations. |
Never skip a CRITICAL/HIGH item to chase a LOW item. A beautifully animated button that fails keyboard navigation is a net negative.
---
## 3. Aesthetic Direction
Before writing a single line of CSS, commit to a bold aesthetic direction. The most common failure mode in AI-generated UI is convergence on the same safe, forgettable look.
### Pick a Tone
Choose one and commit fully:
- **Brutally minimal** — generous whitespace, monospace type, stark contrast, near-zero decoration
- **Maximalist chaos** — layered textures, clashing type scales, dense information, intentional visual noise
- **Retro-futuristic** — CRT glow effects, monospace terminals, scan lines, neon on dark
- **Organic / natural** — earth tones, rounded shapes, paper textures, hand-drawn accents
- **Luxury / refined** — serif headlines, muted palettes, ample negative space, subtle gold or cream accents
- **Editorial / magazine** — dramatic type hierarchy, full-bleed imagery, grid-breaking layouts
- **Playful / bold** — bright primaries, chunky borders, exaggerated shadows, bouncy motion
### Match Complexity to Vision
Maximalist design demands elaborate code — layered backgrounds, complex grid structures, multiple font stacks. Minimalist design demands surgical precision — every pixel of spacing matters more when there's nothing to hide behind.
### The Ban List (When No DESIGN.md Exists)
When building without an existing design system, avoid these overused defaults that signal "AI-generated":
- **Fonts**: Inter, Roboto, Arial, system-ui as display fonts, Space Grotesk
- **Colors**: Purple-to-blue gradients on white backgrounds
- **Patterns**: Generic hero with centered text + CTA + stock illustration
Vary between light and dark themes, different font pairings, different aesthetic directions. Never converge on the same choices across projects.
### Visual Texture
Add depth through: gradient meshes, noise/grain overlays (`filter: url(#noise)`), layered transparencies, subtle background patterns, duotone image treatments.
**DESIGN.md overrides this entire section.** If DESIGN.md specifies Inter, use Inter. If it specifies purple gradients, use them. The ban list only applies when no design system exists and you're making aesthetic choices from scratch.
---
## 4. Typography Essentials
Typography is the single highest-leverage design element. Get it right and mediocre layouts still feel good. Get it wrong and nothing else saves it.
### Root Setup
```css
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
```
Apply font smoothing to the root layout. On macOS, the default sub-pixel rendering makes text appear heavier than the designer intended.
### Text Wrapping
```css
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
p, li, dd, blockquote {
text-wrap: pretty;
}
```
`balance` distributes heading lines evenly. `pretty` avoids orphaned words in body text.
### Numeric Display
```css
.data-value, .price, .counter, [data-numeric] {
font-variant-numeric: tabular-nums;
}
```
Use `tabular-nums` for any number that updates dynamically — prices, counters, table columns. Without it, layout shifts as digit widths change.
### Scale and Rhythm
- **Base size**: 16px minimum for body text. Never go below 14px for any readable content.
- **Line height**: 1.5-1.75 for body text, 1.1-1.3 for large headings.
- **Max line length**: `max-width: 65ch` for body text. Long lines destroy readability.
- **Type scale**: Pick a consistent scale and stick to it: 12 / 14 / 16 / 18 / 24 / 32 / 48 / 64.
### Font Pairing
Pair a distinctive display font with a refined body font. The display font carries personality; the body font carries readability. Use `font-weight` for hierarchy within a family:
- **Headings**: 600-700 (semibold to bold)
- **Body**: 400 (regular)
- **Labels / UI**: 500 (medium)
Always include font stack fallbacks:
```css
--font-display: "Instrument Serif", "Georgia", serif;
--font-body: "Söhne", "Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
```
---
## 5. Color & Theme
### HSL Custom Properties (shadcn Pattern)
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
```
Define semantic tokens: primary, secondary, destructive, muted, accent, background, foreground. Reference colors by semantic name — never hardcode hex values in components.
### Dark Mode
```css
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... desaturated, lighter tonal variants — NOT simply inverted */
}
```
Dark mode is not "invert colors." Use desaturated, lighter tonal variants. Backgrounds go dark but not pure black (`#000`). Text goes light but not pure white (`#fff`). Test contrast separately for dark mode — what passes in light may fail in dark.
### Contrast Requirements
- **WCAG AA minimum**: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular)
- Never convey information by color alone — always pair with an icon, label, or pattern
- Test with browser devtools contrast checker or axe-core
### Color Confidence
Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Pick one or two hero colors and let the rest of the palette recede. A confident palette has clear hierarchy; an uncertain palette spreads color evenly and feels flat.
---
## 6. Spatial Design
### Concentric Border Radius
This is the single most common thing that makes nested UI elements feel "off":
```
outer_radius = inner_radius + padding
```
```css
/* Correct: concentric */
.card { border-radius: 16px; padding: 8px; }
.card-inner { border-radius: 8px; } /* 16 - 8 = 8 */
/* Wrong: same radius on parent and child */
.card { border-radius: 12px; }
.card-inner { border-radius: 12px; } /* Looks bloated */
```
When geometric centering looks off, align optically. Play/pause icons, dropdown carets, and asymmetric glyphs often need 1-2px manual nudges to look centered.
### Shadows Over Borders
Layer multiple transparent `box-shadow` values for natural depth instead of using borders:
```css
.elevated {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 4px rgba(0, 0, 0, 0.04),
0 4px 8px rgba(0, 0, 0, 0.04);
}
```
Multiple shadows at different spreads mimic how light works. A single hard shadow looks artificial.
### Image Outlines
Add a subtle inset outline to images and media for consistent depth against varied backgrounds:
```css
img, video {
outline: 1px solid rgba(0, 0, 0, 0.06);
outline-offset: -1px;
}
```
### Spacing Scale
Use a 4px / 8px base incremental system. Every spacing value should be a multiple of 4:
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128`
### Hit Areas
Minimum 44x44px for all interactive elements. If the visual element is smaller, extend the hit area with a pseudo-element:
```css
.small-button::before {
content: "";
position: absolute;
inset: -8px;
}
```
### Z-Index Scale
Define a layered scale and never use arbitrary values:
```css
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 40;
--z-modal: 100;
--z-toast: 1000;
```
---
## 7. Motion & Interaction
### The Frequency-Based Decision Framework
This is the most important mental model for animation decisions:
| Frequency | Examples | Animation |
|-----------|----------|-----------|
| **100+ times/day** | Keyboard shortcuts, command palette actions, tab switches | **None.** Zero animation. Instant. |
| **Tens of times/day** | Hover effects, list item navigation, toggles | **Remove or drastically reduce.** 50-100ms max. |
| **Occasional** | Modals, drawers, toasts, page transitions | **Standard animation.** 150-300ms. |
| **Rare / first-time** | Onboarding, celebrations, empty states | **Can add delight.** 300-500ms, more elaborate. |
High-frequency animations feel sluggish. Low-frequency animations without motion feel jarring. Match the animation budget to usage frequency.
### Custom Easing Curves
Built-in CSS easings (`ease`, `ease-in-out`) are too weak. Define custom curves:
```css
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
```
### Duration Guide
| Element | Duration |
|---------|----------|
| Buttons, toggles | 100-160ms |
| Tooltips | 125-200ms |
| Dropdowns, popovers | 150-250ms |
| Modals, drawers | 200-500ms |
| Page transitions | 250-400ms |
UI animations should stay under 300ms. Never use `ease-in` for UI animations — it front-loads the pause and feels sluggish.
### Enter/Exit Asymmetry
Exits should be softer and faster than enters. An enter animation at 250ms should have its exit at 150-200ms.
### Split and Stagger Enter Animations
When multiple elements enter the viewport, stagger them by semantic chunks with ~50-100ms delay:
```css
.stagger-item {
animation: fadeSlideIn 300ms var(--ease-out) both;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 60ms; }
.stagger-item:nth-child(3) { animation-delay: 120ms; }
```
### Scale Animations
Never animate from `scale(0)`. Start from `scale(0.9)` or higher, combined with opacity:
```css
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
```
### Press Feedback
Every pressable element should scale down slightly on `:active`:
```css
button:active {
transform: scale(0.97);
}
```
### Interruptibility
Use CSS transitions (not keyframe animations) for interactive state changes. Transitions can be interrupted mid-way; keyframes cannot. This matters for hover states, toggles, and any element the user might interact with rapidly.
### Popover Origin
Make popovers transform-origin aware — they should grow from their trigger element, not from center. Exception: modals always originate from center.
### Tooltip Hover Delay
Skip the tooltip delay on subsequent hovers. If the user has already waited for one tooltip, show the next one immediately.
### Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
Respect `prefers-reduced-motion`. Reduce animations — don't eliminate opacity and color transitions entirely, as those provide important feedback.
### Hover Gate
Gate hover animations behind a media query so touch devices don't trigger stuck hover states:
```css
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-2px); }
}
```
> Reference `references/animation-playbook.md` for deep dives on spring physics, gesture-driven animation, and complex choreography.
---
## 8. Component Craft
### Primitives
Use Radix UI primitives for accessible, unstyled foundations. Use CVA (class-variance-authority) for type-safe component variants:
```tsx
import { cva } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-9 px-3 text-sm",
default: "h-10 px-4 py-2",
lg: "h-11 px-8 text-lg",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
```
### Button
- Scale on press (`transform: scale(0.97)` on `:active`)
- Visible focus ring (never `outline: none` without replacement)
- Loading state with spinner replacing label, maintaining button dimensions
- Disabled state at `opacity: 0.5` with `pointer-events: none`
### Card
- Concentric border radius between card and inner elements
- Layered shadows (not borders) for depth
- Hover state: subtle elevation change (`translateY(-1px)` + shadow increase)
### Dialog / Modal
- Focus trap (keyboard cannot escape to elements behind)
- ESC to close, click outside overlay to close
- `transform-origin: center`, fade + scale enter animation
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
### Form
- Visible labels always — never placeholder-only inputs
- Error messages near the field with `aria-live="polite"` for screen readers
- Progressive disclosure: show advanced fields only when needed
- Use React Hook Form + Zod for validation
### Theming
Use shadcn CSS variable pattern (HSL format) for all component colors. Wrap client-interactive components in server components for Next.js App Router compatibility.
> Reference `references/component-patterns.md` for the full component catalog with copy-paste implementations.
---
## 9. Accessibility Essentials
### Semantic HTML First
Use `<button>`, `<nav>`, `<main>`, `<header>`, `<footer>`, `<article>`, `<section>` before reaching for ARIA. A `<button>` gives you keyboard handling, focus management, and screen reader semantics for free. A `<div onClick>` gives you none of that.
### Keyboard Navigation
- **Tab / Shift+Tab**: move between focusable elements
- **Enter / Space**: activate buttons and links
- **Arrow keys**: navigate within lists, menus, tabs, radio groups
- **Escape**: close modals, popovers, dropdowns
- **Home / End**: jump to first/last item in lists
### Focus Management
- Visible focus rings on all interactive elements — NEVER use `outline: none` without a replacement
- Trap focus inside modals (Tab wraps within the modal, not behind it)
- Restore focus to the trigger element when a modal/popover closes
- Use `focus-visible` to show rings only for keyboard users, not mouse clicks:
```css
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
```
### ARIA Attributes
- `aria-label` for icon-only buttons: `<button aria-label="Close menu">X</button>`
- `aria-labelledby` to associate headings with sections
- `aria-describedby` to link help text or error messages to inputs
- `aria-live="polite"` for dynamic content updates (toast messages, form errors)
- `aria-hidden="true"` for decorative elements (icons next to text labels)
- `aria-expanded` for toggleable elements (dropdowns, accordions)
### Color and Contrast
- WCAG AA: 4.5:1 for normal text, 3:1 for large text
- Never use color as the sole indicator — pair with icons, text, or patterns
- Test in both light and dark modes
### Images and Media
- Descriptive `alt` text for meaningful images: `alt="Dashboard showing 23% revenue growth"`
- Empty `alt=""` for purely decorative images
- Captions for video, transcripts for audio
### Navigation Aids
- **Skip link**: first focusable element, hidden until focused:
```html
<a href="#main-content" class="sr-only focus:not-sr-only">
Skip to main content
</a>
```
- **Heading hierarchy**: sequential h1 through h6, no level skips. One `<h1>` per page.
### Touch Targets
- Minimum 44x44px interactive area
- 8px minimum spacing between adjacent touch targets
- Extend small visual elements with invisible padding or pseudo-elements
### Testing
- **Automated**: axe-core in CI, Lighthouse accessibility score 90+
- **Manual**: full keyboard-only navigation test
- **Screen reader**: test with VoiceOver (macOS) or NVDA (Windows)
- **Visual**: zoom to 200%, check nothing breaks or overlaps
> Reference `references/accessibility-checklist.md` for the full audit guide with pass/fail criteria.
---
## 10. Pre-Delivery Review
Run through this checklist before considering any UI implementation complete:
### Typography
- [ ] Font smoothing applied (`-webkit-font-smoothing: antialiased`)
- [ ] Headings use `text-wrap: balance`
- [ ] Dynamic numbers use `font-variant-numeric: tabular-nums`
### Color
- [ ] All colors referenced via semantic tokens, no hardcoded hex in components
- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
- [ ] Dark mode tested separately for contrast
### Spatial
- [ ] Nested rounded elements use concentric border radius
- [ ] Spacing follows 4px / 8px scale consistently
- [ ] Interactive elements have 44x44px minimum hit area
- [ ] Shadows used instead of borders where appropriate
### Motion
- [ ] Animation frequency matches usage frequency (no animation on high-frequency actions)
- [ ] No `transition: all` anywhere — specific properties only
- [ ] Enter animations split and staggered where multiple elements appear
- [ ] `prefers-reduced-motion` respected
### Accessibility
- [ ] All interactive elements keyboard accessible
- [ ] Focus rings visible on keyboard navigation (never `outline: none` without replacement)
- [ ] Semantic HTML used before ARIA
- [ ] `aria-live` on dynamic content updates
> Reference `references/review-checklist.md` for the extended 30-item checklist with severity ratings and automated testing commands.
@@ -0,0 +1,425 @@
# WCAG 2.1 AA Accessibility Audit Guide
Comprehensive checklist for building accessible web interfaces. Every requirement maps to WCAG 2.1 Level AA success criteria.
---
## 1. Semantic HTML Priority
ALWAYS use semantic HTML before reaching for ARIA. Native elements carry built-in keyboard behavior, focus management, and screen reader announcements that ARIA can only approximate.
### Element Selection Rules
| Instead of | Use |
|---|---|
| `<div role="button">` | `<button>` |
| `<div role="navigation">` | `<nav>` |
| `<div class="header">` | `<header>` |
| `<div class="footer">` | `<footer>` |
| `<span onClick>` | `<a href>` or `<button>` |
| `<div role="list">` | `<ul>` / `<ol>` |
| `<div class="table">` | `<table>` with `<thead>`, `<tbody>`, `<th>` |
### Landmark Elements
- `<main>` — one per page, wraps primary content
- `<nav>` — navigation sections (label with `aria-label` when multiple exist)
- `<header>` — introductory content or navigation aids
- `<footer>` — footer content, copyright, related links
- `<aside>` — tangentially related content (sidebars, callouts)
- `<article>` — self-contained composition (blog post, comment, widget)
- `<section>` — thematic grouping of content (always pair with a heading)
### Form Associations
- `<label>` with `for` attribute connected to the input's `id`
- Group related inputs with `<fieldset>` and `<legend>`
- Use `<optgroup>` for grouped select options
### Heading Hierarchy
- Sequential order: h1 -> h2 -> h3 -> h4 -> h5 -> h6
- NEVER skip levels (e.g., h1 directly to h3)
- One `<h1>` per page (the page title)
- Headings must describe the content that follows
---
## 2. Keyboard Navigation Patterns
Every interactive element must be operable with a keyboard alone. No mouse-only interactions.
### Global Key Bindings
| Key | Action |
|---|---|
| `Tab` | Move focus to next focusable element |
| `Shift + Tab` | Move focus to previous focusable element |
| `Enter` | Activate links, buttons, submit forms |
| `Space` | Activate buttons, toggle checkboxes |
| `Escape` | Close modals, dropdowns, popovers, tooltips |
| `Arrow keys` | Navigate within composite widgets |
| `Home` | Jump to first item in a list or range |
| `End` | Jump to last item in a list or range |
### Composite Widget Navigation (Arrow Keys)
- **Tabs**: Left/Right arrows move between tabs
- **Menus**: Up/Down arrows move between menu items
- **Radio groups**: Arrow keys cycle through options, selecting as they go
- **Listboxes**: Up/Down arrows move highlight, Space selects
- **Tree views**: Up/Down navigate siblings, Right expands, Left collapses
### tabindex Rules
- `tabindex="0"` — places element in natural tab order (use for custom interactive elements)
- `tabindex="-1"` — removes from tab order but allows programmatic focus via `element.focus()` (use for modal containers, skip-link targets, dynamically focused content)
- **NEVER** use `tabindex > 0` — it overrides natural DOM order and creates an unpredictable, unmaintainable focus sequence
### Focus Order Principle
Focus order must match the visual reading order (left-to-right, top-to-bottom for LTR languages). If the DOM order does not match the visual layout, fix the DOM order rather than using positive tabindex values.
---
## 3. ARIA Attributes Reference
The first rule of ARIA: do not use ARIA if a native HTML element provides the behavior. When you must use ARIA, apply it correctly.
### Naming and Describing
| Attribute | Purpose | Example |
|---|---|---|
| `aria-label` | Names an element without visible text | Icon button: `<button aria-label="Close">X</button>` |
| `aria-labelledby` | Points to another element as the label | Modal: `aria-labelledby="dialog-title"` |
| `aria-describedby` | Provides additional description | Form hint: `aria-describedby="password-hint"` |
### Live Regions
| Attribute | Behavior |
|---|---|
| `aria-live="polite"` | Waits for current speech to finish before announcing (toasts, status updates) |
| `aria-live="assertive"` | Interrupts current speech immediately (critical errors, urgent alerts) |
| `aria-atomic="true"` | Re-reads entire region content on change, not just the delta |
| `role="alert"` | Shorthand for `aria-live="assertive"` + `aria-atomic="true"` |
| `role="status"` | Shorthand for `aria-live="polite"` + `aria-atomic="true"` |
### State and Properties
| Attribute | Purpose |
|---|---|
| `aria-expanded` | Indicates whether a collapsible section is open (`true`) or closed (`false`) |
| `aria-haspopup` | Indicates the trigger opens a popup (`menu`, `listbox`, `dialog`, `grid`, `tree`) |
| `aria-modal="true"` | Marks a dialog as modal (assistive tech should ignore content outside) |
| `aria-hidden="true"` | Hides element from assistive technology (decorative images, duplicate content) |
| `aria-invalid` | Marks a form field as having an error (`true`, `grammar`, `spelling`) |
| `aria-required` | Indicates the field is required before form submission |
| `aria-sort` | Indicates sort direction on table column headers (`ascending`, `descending`, `none`) |
| `aria-selected` | Indicates selected state in single/multi-select widgets |
| `aria-controls` | Identifies the element(s) controlled by this element |
| `aria-current` | Indicates the current item in a set (`page`, `step`, `location`, `date`, `true`) |
| `aria-disabled` | Marks element as disabled but still perceivable (unlike `disabled` attribute which removes from tab order) |
---
## 4. Focus Management
### Visible Focus Indicators
- **NEVER** use `outline: none` or `outline: 0` without providing a custom alternative
- Recommended default: `outline: 3px solid currentColor; outline-offset: 2px;`
- Use `:focus-visible` for keyboard-only focus styling (hides ring on mouse click):
```css
:focus-visible {
outline: 3px solid var(--focus-color, #2563eb);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
```
- Focus indicators must meet 3:1 contrast ratio against adjacent colors (WCAG 2.4.11)
- Minimum focus indicator area: at least 2px perimeter around the component
### Modal Focus Trapping
When a modal opens:
1. Move focus to the first focusable element inside the modal (or the modal container with `tabindex="-1"`)
2. Trap Tab/Shift+Tab to cycle only through focusable elements within the modal
3. Pressing Escape closes the modal
4. On close, return focus to the element that triggered the modal
### Focus Restoration
- When a dropdown/popover/modal closes, return focus to its trigger element
- When an item is deleted from a list, move focus to the nearest remaining item
- When a dialog confirms an action, focus the result or next logical element
### SPA Route Changes
- On navigation, move focus to the main content heading or a skip-link target
- Announce the new page title to screen readers using an `aria-live` region or document.title update
- Use `<title>` updates: "Page Name | Site Name"
### Skip Links
- First focusable element on the page should be "Skip to main content"
- Link target: `<main id="main-content" tabindex="-1">`
- Visually hidden until focused:
```css
.skip-link {
position: absolute;
left: -9999px;
top: auto;
}
.skip-link:focus {
position: static;
left: auto;
}
```
---
## 5. Color Contrast Requirements
### WCAG AA Minimum Ratios
| Element | Minimum Contrast Ratio |
|---|---|
| Normal text (< 24px, or < 18.66px if bold) | 4.5:1 |
| Large text (>= 24px, or >= 18.66px if bold) | 3:1 |
| UI components (borders, icons, form controls) | 3:1 |
| Graphical objects (charts, infographics) | 3:1 |
| Disabled elements | No requirement (but keep readable) |
| Placeholder text | 4.5:1 (it is regular text) |
### Testing Tools
- Chrome DevTools: Elements panel -> Styles -> color swatch -> contrast ratio
- axe-core browser extension
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Stark (Figma/Sketch plugin)
### Color Independence Rules
- **NEVER** convey information by color alone
- Error states: red color + error icon + descriptive text message
- Required fields: asterisk + "required" label text (not just red border)
- Status indicators: color + icon + text label (e.g., green checkmark + "Complete")
- Links in body text: color + underline (or other non-color differentiator)
- Charts/graphs: use patterns, labels, or shapes in addition to color
### Dark Mode Considerations
- Test contrast ratios separately in dark mode
- Use desaturated color variants, not simple CSS `invert()`
- Background and foreground pairs must both be intentionally chosen
- Semi-transparent overlays can reduce effective contrast -- verify computed values
---
## 6. Accessible Component Patterns
### Dropdown / Select
```
trigger: aria-haspopup="listbox", aria-expanded="false|true"
container: role="listbox"
options: role="option", aria-selected="true|false"
```
- Arrow keys navigate options
- Typeahead: typing characters jumps to matching option
- Enter/Space selects highlighted option
- Escape closes without selecting
- Selected option text updates trigger label
### Modal / Dialog
```
container: role="dialog", aria-modal="true", aria-labelledby="title-id"
title: id="title-id"
close button: aria-label="Close dialog"
```
- Focus moves into modal on open
- Tab cycles within modal (focus trap)
- Escape closes modal
- Click on backdrop closes modal
- Focus returns to trigger on close
- Background content gets `aria-hidden="true"` or `inert`
### Tabs
```
container: role="tablist"
tab: role="tab", aria-selected="true|false", aria-controls="panel-id", tabindex="0|-1"
panel: role="tabpanel", aria-labelledby="tab-id", tabindex="0"
```
- Only the active tab has `tabindex="0"`; inactive tabs have `tabindex="-1"`
- Left/Right arrows move between tabs (wrapping optional)
- Home/End jump to first/last tab
- Tab key moves focus from the active tab into the panel content
### Forms
- Every `<input>`, `<select>`, `<textarea>` has a visible `<label>`
- Required fields: `aria-required="true"` + visual asterisk indicator
- Error fields: `aria-invalid="true"` + `aria-describedby` pointing to error message element
- Error messages: use `role="alert"` or `aria-live="assertive"` region
- On failed submission: focus the first invalid field
- Helper text: linked via `aria-describedby` to the associated input
- Password fields: toggle visibility button with `aria-label` describing current state
- Groups of related controls: `<fieldset>` + `<legend>`
### Accordion
```
trigger: <button aria-expanded="true|false" aria-controls="panel-id">
panel: id="panel-id", role="region", aria-labelledby="trigger-id"
```
- Enter/Space toggles section
- Only one section open at a time (optional, depends on design)
- Panel content hidden with `hidden` attribute or `display: none` (not just visually)
### Toast / Notification
- Container: `role="status"` or `aria-live="polite"` (non-critical)
- Critical notifications: `role="alert"` (assertive)
- Must be dismissible (close button or auto-dismiss with sufficient time)
- Auto-dismiss: minimum 5 seconds visible, pauses on hover/focus
---
## 7. prefers-reduced-motion
### Global Reset
```css
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
### Nuanced Approach
Reduced motion means fewer/gentler animations, not zero motion:
- **Keep**: opacity fades, color transitions that aid comprehension
- **Remove**: parallax scrolling, zoom/scale transforms, slide/translate animations, auto-playing carousels
- **Simplify**: complex multi-step animations to simple fades
### Framework Integration
React (framer-motion):
```jsx
import { useReducedMotion } from 'framer-motion';
function Component() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={{ x: shouldReduceMotion ? 0 : 100 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
/>
);
}
```
CSS custom property approach:
```css
:root {
--transition-speed: 0.3s;
}
@media (prefers-reduced-motion: reduce) {
:root {
--transition-speed: 0.01ms;
}
}
```
---
## 8. Testing Approach
### Automated Testing
| Tool | Usage |
|---|---|
| axe-core | `npm install jest-axe` for unit tests; `expect(container).toHaveNoViolations()` |
| Lighthouse | Accessibility score target: 90+ |
| eslint-plugin-jsx-a11y | Static analysis for JSX accessibility issues |
| pa11y | CLI/CI integration for automated page-level audits |
| Playwright/axe | `@axe-core/playwright` for integration test accessibility checks |
### Manual Testing Checklist
1. **Keyboard-only navigation**: unplug mouse, navigate entire page with Tab, Enter, Arrows, Escape
2. **Screen reader**: VoiceOver (macOS: Cmd+F5), NVDA (Windows, free), JAWS (Windows)
3. **Zoom 200%**: content should reflow without horizontal scrolling or content clipping
4. **Zoom 400%**: text should remain readable (WCAG 1.4.10 Reflow)
5. **Focus indicators**: every interactive element shows a visible focus ring when focused via keyboard
6. **Forced colors mode**: test in Windows High Contrast Mode (use `forced-colors` media query)
7. **Text spacing**: override letter-spacing (0.12em), word-spacing (0.16em), line-height (1.5), paragraph-spacing (2em) -- content must remain readable
### CI Integration
```bash
# Example: axe-core with Playwright in CI
npx playwright test --project=accessibility
```
---
## 9. Common Mistakes
| Mistake | Fix |
|---|---|
| `outline: none` on focus | Use `:focus-visible` with a custom focus ring |
| Placeholder as only label | Always use `<label>` element |
| Icon button without label | Add `aria-label="Action description"` |
| Color-only error indication | Add icon + descriptive text alongside color |
| Missing alt text on images | Descriptive `alt` text, or `alt=""` for decorative images |
| Heading level skip (h1 to h3) | Sequential hierarchy: h1 -> h2 -> h3 |
| `tabindex > 0` | Use natural DOM order; only use `0` or `-1` |
| Emoji used as functional icons | Use SVG icons with `aria-label` |
| Auto-playing animation | Respect `prefers-reduced-motion` media query |
| Non-dismissible modal | Always support Escape key to close |
| `aria-hidden="true"` on focusable elements | Remove from tab order or remove `aria-hidden` |
| Missing `lang` attribute on `<html>` | Set `<html lang="en">` (or appropriate language code) |
| Autoplaying video/audio with sound | Require user interaction to start, or mute by default with controls |
| Tiny tap targets on mobile | Minimum 44x44 CSS pixels for touch targets |
| Using `title` attribute as primary label | `title` is unreliable; use `aria-label` or visible `<label>` |
| Links that say "click here" or "read more" | Descriptive link text: "Read the accessibility guide" |
| Missing form error summary | On submit failure, show summary of all errors at top of form |
---
## Quick Reference: Testing a New Component
Before marking any component as complete, verify:
1. Can you reach and operate it using only a keyboard?
2. Does it have a visible focus indicator?
3. Does it announce correctly in a screen reader?
4. Does it meet color contrast ratios?
5. Does it work at 200% zoom?
6. Does it respect `prefers-reduced-motion`?
7. Does it pass `jest-axe` / axe-core automated checks?
8. Does it have appropriate semantic HTML or ARIA roles?
9. Are all images, icons, and media labeled?
10. Can it be operated with one hand on mobile (44x44px touch targets)?
@@ -0,0 +1,545 @@
# Animation Playbook
Deep-dive reference for animation patterns. The main SKILL.md references these techniques
but does not include the full detail needed for implementation.
---
## 1. Easing Curve Library
The built-in CSS keywords (`ease`, `ease-in`, `ease-out`, `ease-in-out`) produce weak,
generic motion. Define custom curves as CSS custom properties so every animation in the
project shares the same vocabulary.
```css
:root {
/* Strong ease-out — the default for UI interactions (enter, appear, respond) */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Strong ease-in-out — on-screen movement and morphing transitions */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* iOS-like drawer curve — slide-up sheets, bottom drawers */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Snappy — fast micro-interactions, toggles, checkboxes */
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
/* Emphasized deceleration — large surface transitions, page-level changes */
--ease-decel: cubic-bezier(0, 0, 0.2, 1);
}
```
### When to use which
| Curve | Use case |
| ---------------- | ---------------------------------------------- |
| `--ease-out` | Elements entering the viewport, appearing |
| `--ease-in-out` | Elements morphing shape, moving across screen |
| `--ease-drawer` | Sheets, drawers, panels sliding into view |
| `--ease-snappy` | Micro-interactions: toggles, checks, switches |
| `--ease-decel` | Large page transitions, route changes |
| `linear` | Constant-rate motion only: progress bars, spin |
Never use `ease-in` alone for UI elements — it makes things feel sluggish at the start.
Reserve `linear` for continuous motion (loading spinners, progress indicators) where
deceleration would look wrong.
**Resources**: [easing.dev](https://easing.dev), [easings.co](https://easings.co)
for visual curve comparison and copying.
---
## 2. Spring Animations
Springs are physics-based. They do not have a fixed duration — they simulate mass,
stiffness, and damping. This makes them ideal for anything interactive.
### When to use springs instead of easing curves
- Drag interactions (the element should follow the finger naturally)
- Elements that feel "alive" (cards, floating actions, avatars)
- Gestures that can be interrupted mid-animation
- Mouse-tracking interactions (cursor followers, magnetic buttons)
### Apple-style spring (duration + bounce)
```js
// Framer Motion / Motion One
animate(element, { x: 100 }, {
type: "spring",
duration: 0.5,
bounce: 0.2
})
```
This is the simpler API. `duration` controls overall timing, `bounce` controls overshoot.
### Traditional physics spring (mass + stiffness + damping)
```js
animate(element, { x: 100 }, {
type: "spring",
mass: 1,
stiffness: 100,
damping: 10
})
```
More control, but harder to tune. Start with mass=1 and adjust stiffness/damping.
### Guidelines
- Keep bounce subtle: **0.1 to 0.3** for most UI. Higher values feel toy-like.
- Avoid bounce entirely for actions that need to feel decisive (confirms, deletes).
- Springs **maintain velocity when interrupted** — if you change the target mid-animation,
the element smoothly redirects. Keyframe animations restart from scratch.
- Use `useSpring` (or equivalent) for mouse-tracking: it makes cursor followers feel
natural instead of artificial. The lag is intentional and pleasant.
- For lists, spring each item separately so they can settle independently.
---
## 3. clip-path Animation Patterns
`clip-path` is one of the most underused animation tools. It lets you reveal, hide,
and transition content without layout shifts.
### Inset shape basics
```css
/* Full visibility */
clip-path: inset(0 0 0 0);
/* Clipped from bottom — only top portion visible */
clip-path: inset(0 0 50% 0);
/* Fully hidden — clipped from all sides */
clip-path: inset(50% 50% 50% 50%);
/* With border-radius */
clip-path: inset(10px round 8px);
```
The values are `inset(top right bottom left)` — how far each edge clips inward.
### Pattern: Tabs with perfect color transitions
Duplicate the entire tab list. Place one copy on top of the other. The bottom copy has
inactive styles; the top copy has active styles. Animate `clip-path: inset(...)` on the
top copy to reveal only the active tab region. The color transition is instantaneous and
pixel-perfect — no fade needed.
```css
.tabs-active-overlay {
clip-path: inset(0 calc(100% - var(--tab-right)) 0 var(--tab-left));
transition: clip-path 300ms var(--ease-out);
}
```
### Pattern: Hold-to-delete
Overlay a colored fill on the button. On `:active`, animate `clip-path` from
`inset(0 100% 0 0)` to `inset(0 0 0 0)` over 2 seconds with `linear` timing (the user
needs to see constant progress). On release, snap back with `200ms ease-out`.
```css
.delete-btn::after {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms var(--ease-out);
}
.delete-btn:active::after {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear;
}
```
### Pattern: Image reveals on scroll
Start with `clip-path: inset(0 0 100% 0)` (image hidden, clipped from bottom).
Use IntersectionObserver to detect viewport entry, then animate to `inset(0 0 0 0)`.
```js
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.clipPath = 'inset(0 0 0 0)';
}
});
}, { threshold: 0.1 });
```
### Pattern: Comparison sliders
Overlay two images. Clip the top image by the drag position:
`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`. Update `--pos` on pointer move.
---
## 4. Gesture Design
Gestures are the hardest animation category because they involve real-time user input
and require physics-aware feedback.
### Momentum-based dismissal
Calculate velocity during drag:
```js
const velocity = distance / elapsed; // px per ms
if (velocity > 0.11) {
dismiss(); // Fast enough — dismiss regardless of distance
} else if (Math.abs(offset) > threshold) {
dismiss(); // Far enough — dismiss regardless of speed
} else {
snapBack(); // Neither fast nor far — return to origin
}
```
The velocity threshold (0.11 px/ms) matters more than distance. A quick flick should
dismiss even from a small offset.
### Damping at boundaries
When the user drags past a natural boundary (e.g., top of a scroll view), apply
increasing resistance:
```js
function dampedOffset(raw, boundary) {
const overflow = raw - boundary;
// Logarithmic damping — diminishing returns
return boundary + Math.log(1 + Math.abs(overflow)) * 30 * Math.sign(overflow);
}
```
This produces the rubber-band effect. The element still moves, but progressively less.
### Pointer capture
Once a drag begins, call `element.setPointerCapture(event.pointerId)`. This ensures
all subsequent pointer events route to this element even if the pointer leaves its
bounds. Release on `pointerup`.
### Multi-touch protection
Track only the first pointer. If a second finger touches during a drag, ignore it:
```js
let activePointerId = null;
element.addEventListener('pointerdown', (e) => {
if (activePointerId !== null) return; // Already tracking
activePointerId = e.pointerId;
element.setPointerCapture(e.pointerId);
});
```
### Friction instead of hard stops
Never hard-clamp position. Always allow movement with increasing resistance. Hard stops
feel broken. Friction feels physical.
---
## 5. Stagger Patterns
Staggering creates a sense of flow by delaying each item slightly.
### CSS implementation
```css
.stagger-item {
opacity: 0;
transform: translateY(8px);
animation: stagger-in 400ms var(--ease-out) forwards;
}
@keyframes stagger-in {
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 40ms; }
.stagger-item:nth-child(3) { animation-delay: 80ms; }
.stagger-item:nth-child(4) { animation-delay: 120ms; }
.stagger-item:nth-child(5) { animation-delay: 160ms; }
```
Or with a custom property:
```css
.stagger-item {
animation-delay: calc(var(--index) * 40ms);
}
```
Set `--index` via `style` attribute in markup or JS.
### Guidelines
- **30-80ms** per step is the sweet spot. Under 30ms looks simultaneous. Over 80ms
feels sluggish.
- Break content into **semantic chunks** — stagger cards, not individual lines of text.
- **Never block interaction** during stagger animations. All items should be clickable
immediately, even if not yet visible.
- Cap the total stagger time. For a list of 20 items, stagger the first 5-6 and let the
rest appear together.
- Stagger on initial load only. Re-renders should not re-stagger.
---
## 6. Exit Animation Patterns
Exits are often neglected. They deserve as much care as entries.
### Principles
- **Exits should be faster than enters.** If enter is 400ms, exit should be 200-250ms.
- **Use small fixed translateY** (8-12px) instead of full-height slides. Large movements
during exit draw too much attention away from what remains.
- **Opacity + scale combination** works better than opacity alone for removal. A slight
`scale(0.96)` during fade-out makes it feel more physical.
- **Asymmetric timing is intentional.** A hold-to-delete might take 2 seconds (deliberate),
but the actual removal should be 200ms (snappy). The weight is in the decision, not
the consequence.
### Exit with height collapse
When removing an item from a list, animate both the content (opacity + translate) and
the container height. The content fades first, then the gap closes:
```css
.item-exiting {
opacity: 0;
transform: translateY(-8px);
transition: opacity 150ms var(--ease-out),
transform 150ms var(--ease-out);
}
.item-exiting-collapse {
height: 0;
margin: 0;
padding: 0;
transition: height 200ms var(--ease-out) 100ms, /* delayed start */
margin 200ms var(--ease-out) 100ms,
padding 200ms var(--ease-out) 100ms;
}
```
### Tuning
There is no formula for the right opacity/height/transform combination. Adjust until it
feels right. Test by performing the action 10 times quickly — if anything feels off on
repetition, it needs work.
---
## 7. Performance Rules
Animation jank is unacceptable. These rules keep animations at 60fps.
### The compositing-only rule
Only animate properties that skip layout and paint:
- `transform` (translate, scale, rotate)
- `opacity`
- `filter` (with caveats — see below)
Everything else triggers layout recalculation (width, height, margin, padding, top, left)
or paint (background-color, box-shadow, border). Both are expensive.
### CSS vs JavaScript animations
- **CSS animations and transitions** run off the main thread on the compositor. Use them
for predetermined animations (hover effects, enter/exit, state changes).
- **Framer Motion `x`/`y` props are NOT hardware-accelerated.** They animate inline
styles, which run on the main thread. Use the full transform string or CSS-based
approaches for performance-critical animations.
- **CSS variables on parent elements** cause expensive style recalculation when updated.
If animating a CSS variable, update the `transform` property directly instead.
### Web Animations API (WAAPI)
For programmatic animations that need CSS-level performance:
```js
element.animate(
[
{ transform: 'translateY(20px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
],
{ duration: 400, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
);
```
WAAPI runs on the compositor like CSS animations but is controlled from JavaScript.
### Blur and filter performance
- Keep `blur()` under **20px**, especially on Safari where large blurs are expensive.
- `backdrop-filter: blur()` is even more expensive — use sparingly.
- Prefer pre-blurred images over real-time blur when possible.
### will-change
- Only use `will-change` for `transform`, `opacity`, or `filter`.
- **Never** use `will-change: all` — it promotes every property and wastes GPU memory.
- Add `will-change` only when you observe first-frame stutter on an animation. It is a
last resort, not a default.
- Remove `will-change` after the animation completes if the element is long-lived.
### transition: all is banned
```css
/* Bad — animates every property change, including ones you did not intend */
transition: all 200ms ease;
/* Good — explicit about what animates */
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
```
`transition: all` causes unexpected animations when other properties change and makes
debugging difficult.
---
## 8. The Sonner Principles
Sonner (the toast library) demonstrates principles that apply broadly to dynamic UI
components.
### Good defaults matter more than options
If you need 12 configuration props to make a component feel right, the defaults are
wrong. The component should feel right out of the box.
### Use transitions, not keyframes, for dynamic UI
Toasts are added rapidly and unpredictably. Keyframe animations have fixed timelines
that cannot adapt to rapid state changes. CSS transitions respond to the current state
and interpolate naturally.
### Handle edge cases invisibly
- Pause toast timers when the browser tab is hidden (the user should not miss toasts).
- When a toast is dismissed from the middle of a stack, the remaining toasts should
fill the gap smoothly.
- When multiple toasts arrive simultaneously, batch the visual update.
### Match motion personality to component personality
A success toast can be slightly bouncy. An error toast should be direct and firm.
A loading toast should feel steady and patient. The animation communicates as much as
the content.
---
## 9. @starting-style for Modern CSS Enter Animations
`@starting-style` defines the initial style of an element when it first renders.
Combined with transitions, it creates enter animations in pure CSS — no JavaScript
`useEffect` + `mounted` state needed.
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}
```
When the `.toast` element is inserted into the DOM, the browser starts from the
`@starting-style` values and transitions to the normal values.
### Works with display: none toggling
```css
.dialog {
display: block;
opacity: 1;
transition: opacity 300ms var(--ease-out), display 300ms allow-discrete;
@starting-style {
opacity: 0;
}
}
.dialog[hidden] {
display: none;
opacity: 0;
}
```
The `allow-discrete` keyword lets `display` participate in the transition timeline.
### Fallback for older browsers
When `@starting-style` is not supported, fall back to a `data-mounted` attribute pattern:
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
}
.toast:not([data-mounted]) {
opacity: 0;
transform: translateY(100%);
}
```
Add `data-mounted` via JavaScript after a single `requestAnimationFrame`.
---
## 10. Debug Techniques
### Slow motion testing
Increase animation duration by 2-5x during development. At normal speed, problems are
invisible. At 5x, you see every hitch, wrong easing, and misaligned property.
```css
:root {
--debug-speed: 1; /* Change to 5 for slow-mo */
}
.animated {
transition-duration: calc(200ms * var(--debug-speed));
}
```
### Chrome DevTools Animations panel
Open DevTools > More Tools > Animations. This panel shows:
- A timeline of all running animations
- Frame-by-frame scrubbing
- Easing curve visualization
- Duration and delay for each animation
Use the playback speed controls (25%, 10%) for detailed inspection.
### Real device testing
Touch interactions feel completely different on a real phone versus a trackpad simulator.
Always test gestures, drag interactions, and spring animations on physical devices.
### Fresh eyes check
Review animations with fresh eyes the next day. What felt right at 11pm during
development often feels too fast, too slow, or too dramatic the next morning.
### The checklist
Before shipping any animation, verify:
- Smooth color transitions (no banding or flashing)?
- Correct easing curve for the interaction type?
- Right `transform-origin` (elements scaling/rotating from the expected point)?
- All animated properties in sync (opacity and transform finishing together)?
- No layout shift during the animation?
- Works with `prefers-reduced-motion: reduce`?
- Performs at 60fps on a mid-range device?
@@ -0,0 +1,604 @@
# Component Implementation Patterns
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
---
## 1. shadcn/ui Setup
```bash
npx shadcn@latest init
npx shadcn@latest add button input form card dialog select sheet toast
```
Key concepts:
- **Not an npm package** -- components are copied into your project. You own the code and can modify it freely.
- Built on **Radix UI** primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
- Styled with **Tailwind CSS** utilities -- no CSS-in-JS runtime.
- Required dependencies:
- `class-variance-authority` (CVA) -- variant management
- `clsx` -- conditional class joining
- `tailwind-merge` -- deduplicates conflicting Tailwind classes
- `lucide-react` -- icon library
- `tailwindcss-animate` -- animation utilities
The `cn()` utility combines `clsx` and `tailwind-merge`:
```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
---
## 2. CSS Variables for Theming (HSL Format)
shadcn uses HSL values without the `hsl()` wrapper so Tailwind can apply opacity modifiers:
```css
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... remaining dark overrides */
}
}
```
Usage in `tailwind.config.ts`:
```ts
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// ...
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
}
```
---
## 3. Button Patterns
Use CVA to define variants declaratively:
```tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
```
Design rules:
- **Press feedback**: `active:scale-[0.97]` gives tactile response without layout shift.
- **Focus ring**: Always visible via `focus-visible:ring-2`. Never use `outline: none` without a replacement.
- **Loading state**: Disable the button and show a spinner inline.
```tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
)
)
```
---
## 4. Form Patterns (React Hook Form + Zod)
Schema-first validation keeps validation logic co-located and type-safe:
```tsx
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(2).max(50),
})
type FormValues = z.infer<typeof formSchema>
```
The shadcn Form components wire React Hook Form to accessible markup:
```tsx
function SignUpForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "", name: "" },
mode: "onBlur", // validate on blur, not keystroke
})
function onSubmit(values: FormValues) {
// handle submission
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ...more fields */}
<Button type="submit" isLoading={form.formState.isSubmitting}>
Sign Up
</Button>
</form>
</Form>
)
}
```
Accessibility rules:
- `FormMessage` renders error text with `aria-describedby` linked to the input.
- Inputs get `aria-invalid="true"` when in error state automatically.
- Mark required fields with `aria-required="true"`.
- Validate on **blur**, not on every keystroke -- reduces noise and respects user flow.
- Use **progressive disclosure** for complex forms: show additional fields only when relevant.
---
## 5. Card Patterns
```tsx
import {
Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter,
} from "@/components/ui/card"
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
<CardHeader>
<CardTitle>Project Settings</CardTitle>
<CardDescription>Manage your project configuration.</CardDescription>
</CardHeader>
<CardContent>
{/* form fields or content */}
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</CardFooter>
</Card>
```
Design rules:
- **Concentric border radius**: Outer radius = inner radius + padding. If inner elements have `rounded-md` (6px) and padding is 16px, outer card should be `rounded-xl` (12px) or greater.
- **Layered shadows**: Use multiple shadow values for natural depth -- `shadow-sm` at rest, `shadow-lg` on hover.
- **Hover lift**: Subtle `translateY(-2px)` on hover, never more than 4px.
- Use semantic color tokens (`bg-card`, `text-card-foreground`) so cards adapt to theme changes.
---
## 6. Dialog (Modal) Patterns
```tsx
import {
Dialog, DialogTrigger, DialogContent,
DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Make changes to your profile here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* form content */}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
Accessibility and interaction rules (handled by Radix):
- **Focus trap**: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
- **ESC to close**: Always. No exceptions.
- **Click outside overlay**: Closes the dialog by default.
- `aria-modal="true"` is set automatically.
- `aria-labelledby` points to `DialogTitle`, `aria-describedby` points to `DialogDescription`.
- **Restore focus**: When dialog closes, focus returns to the trigger element.
- **Animation origin**: `transform-origin: center` -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
---
## 7. Select/Dropdown Patterns
```tsx
import {
Select, SelectTrigger, SelectValue,
SelectContent, SelectItem, SelectGroup, SelectLabel,
} from "@/components/ui/select"
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
Interaction rules:
- **Keyboard navigation**: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
- ARIA: `aria-haspopup="listbox"` on trigger, `aria-expanded` toggles with open state.
- **Transform origin**: Popover should animate from the trigger position (origin-aware), not from center.
- **Tooltip delay skip**: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
---
## 8. Sheet (Slide-over) Patterns
```tsx
import {
Sheet, SheetTrigger, SheetContent,
SheetHeader, SheetTitle, SheetDescription,
SheetFooter, SheetClose,
} from "@/components/ui/sheet"
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open Menu</Button>
</SheetTrigger>
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Browse sections of the app.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-2 py-4">
{/* nav links */}
</nav>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
```
Use cases:
- **Mobile navigation**: Slide from left with full-height overlay.
- **Detail panels**: Slide from right to show item details without leaving the list view.
- **Filters**: Slide from bottom on mobile for filter controls.
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
---
## 9. Toast/Notification Patterns
Using the shadcn Toast (or Sonner for a lighter API):
```tsx
// With shadcn toast
import { useToast } from "@/components/ui/use-toast"
function SaveButton() {
const { toast } = useToast()
return (
<Button
onClick={() => {
toast({
title: "Changes saved",
description: "Your settings have been updated.",
})
}}
>
Save
</Button>
)
}
// With Sonner (simpler API)
import { toast } from "sonner"
toast.success("Changes saved")
toast.error("Something went wrong")
toast.promise(saveSettings(), {
loading: "Saving...",
success: "Settings saved",
error: "Could not save",
})
```
Design and accessibility rules:
- **Auto-dismiss**: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
- `aria-live="polite"` -- screen readers announce without stealing focus.
- **CSS transitions, not keyframes** -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
- **Pause timers** when the browser tab is hidden (`document.visibilityState`).
- **Swipe to dismiss**: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
---
## 10. Table Patterns
```tsx
import {
Table, TableHeader, TableBody, TableFooter,
TableHead, TableRow, TableCell, TableCaption,
} from "@/components/ui/table"
<div className="overflow-x-auto rounded-md border">
<Table>
<TableCaption>A list of recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">{invoice.id}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
```
Rules:
- **Responsive**: Wrap table in `overflow-x-auto` container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
- **Sortable columns**: Use `aria-sort="ascending"` or `aria-sort="descending"` on the active `TableHead`. Show a visual indicator (chevron icon).
- **Virtualization**: For lists exceeding ~50 items, use `@tanstack/react-virtual` or similar to render only visible rows.
- **Row distinction**: Use zebra striping (`even:bg-muted/50`) or subtle borders between rows. Never rely on color alone.
---
## 11. Chart Integration
When integrating charts (Recharts, Chart.js, or similar):
- **Match chart type to data intent**:
- Trend over time: line chart
- Comparison across categories: bar chart
- Part-of-whole: pie/donut chart
- Distribution: histogram
- Correlation: scatter plot
- **Accessible color palettes**: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
- **Always include a legend** and provide **tooltips on hover/focus** for precise values.
- **Screen reader alternative**: Provide a visually hidden `<table>` with the same data so screen readers can access it.
- **Respect `prefers-reduced-motion`**: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
```tsx
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches
<LineChart data={data}>
<Line
type="monotone"
dataKey="value"
animationDuration={prefersReducedMotion ? 0 : 500}
/>
</LineChart>
```
---
## 12. Server Component Wrapping (Next.js)
Most shadcn/ui components use React state or event handlers and require `"use client"`. Structure your components to keep data fetching in server components:
```tsx
// app/dashboard/page.tsx (Server Component -- no "use client")
import { getProjects } from "@/lib/data"
import { ProjectList } from "./project-list"
export default async function DashboardPage() {
const projects = await getProjects()
return <ProjectList projects={projects} />
}
```
```tsx
// app/dashboard/project-list.tsx (Client Component)
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface ProjectListProps {
projects: { id: string; name: string; status: string }[]
}
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card key={project.id}>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
</CardHeader>
<CardContent>
<p>{project.status}</p>
<Button variant="outline" size="sm">View</Button>
</CardContent>
</Card>
))}
</div>
)
}
```
The pattern: **Server component fetches data, passes to client component as serializable props.** This keeps the client bundle small and data fetching on the server.
---
## 13. CVA (class-variance-authority) Deep Dive
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
```ts
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
```
Key patterns:
- **Compose with `cn()`**: Always wrap CVA output with `cn()` so consumer-passed `className` can override defaults via `tailwind-merge`.
- **Type extraction**: `VariantProps<typeof badgeVariants>` generates the TypeScript type for variant props automatically.
- **Compound variants**: Handle combinations of variant values that need special styling:
```ts
const inputVariants = cva("...", {
variants: {
size: { sm: "...", lg: "..." },
state: { error: "...", success: "..." },
},
compoundVariants: [
{ size: "sm", state: "error", class: "border-2 border-red-500" },
],
})
```
- **Use CVA for any component with visual variants** -- buttons, badges, alerts, inputs, cards. It replaces manual `if/else` class concatenation with a declarative, type-safe API.
@@ -0,0 +1,204 @@
# Pre-Delivery Review Checklist
Extended 30-item checklist for UI implementation quality. Run through this before marking any UI task as complete.
## Typography (6 items)
### 1. Font Smoothing Applied
- **Check**: Root layout has `-webkit-font-smoothing: antialiased`
- **How**: Inspect `<html>` or `<body>` computed styles
- **Failing looks like**: Text appears heavy/blurry on macOS, especially at small sizes
### 2. Headings Use text-wrap: balance
- **Check**: All `<h1>``<h4>` elements have `text-wrap: balance`
- **How**: Resize viewport to trigger wrapping — headings should break evenly
- **Failing looks like**: One long line followed by a single orphan word
### 3. Body Text Uses text-wrap: pretty
- **Check**: Paragraphs and body text use `text-wrap: pretty`
- **How**: Check for orphaned words at the end of paragraphs
- **Failing looks like**: A single short word sitting alone on the last line
### 4. Dynamic Numbers Use tabular-nums
- **Check**: Counters, prices, timers, and data columns have `font-variant-numeric: tabular-nums`
- **How**: Watch numbers update — layout should not shift
- **Failing looks like**: Content jumps horizontally as digits change width
### 5. Line Length Controlled
- **Check**: Body text containers are capped at `max-width: 65ch`
- **How**: Measure character count on a full-width line
- **Failing looks like**: Text stretching edge-to-edge on wide monitors, hard to read
### 6. Type Scale Consistency
- **Check**: All text sizes come from the defined type scale (no arbitrary sizes)
- **How**: Inspect font sizes — they should match scale values (12/14/16/18/24/32/48)
- **Failing looks like**: Random sizes like 15px, 19px, 22px that aren't in the scale
## Color & Theme (5 items)
### 7. Semantic Color Tokens Only
- **Check**: No hardcoded hex/rgb values in component code
- **How**: Search for `#[0-9a-f]` or `rgb(` in component files
- **Failing looks like**: `background: #3b82f6` instead of `bg-primary` or `var(--primary)`
### 8. WCAG AA Contrast Met
- **Check**: Normal text ≥ 4.5:1, large text ≥ 3:1, UI components ≥ 3:1
- **How**: Run axe-core or Chrome DevTools contrast checker
- **Failing looks like**: Light gray text on white background, low-contrast placeholders
### 9. Dark Mode Contrast Verified
- **Check**: Contrast ratios pass in dark mode separately
- **How**: Toggle dark mode, re-run contrast checks
- **Failing looks like**: Passing in light mode but failing in dark (common with desaturated variants)
### 10. Color Not Sole Information Channel
- **Check**: Error, success, warning states use icon + text alongside color
- **How**: View the page in grayscale (browser DevTools → Rendering → Emulate vision deficiency)
- **Failing looks like**: Red border on error field with no icon or text explanation
### 11. Dark Mode Visual Review
- **Check**: All surfaces, borders, shadows, and text are legible in dark mode
- **How**: Toggle dark mode and visually scan every component
- **Failing looks like**: Invisible borders, washed-out shadows, or text-on-background collision
## Layout & Spatial (5 items)
### 12. Concentric Border Radius
- **Check**: Outer radius = inner radius + padding on all nested rounded elements
- **How**: Inspect nested cards, buttons-in-containers, input groups
- **Failing looks like**: Inner and outer corners don't follow the same curvature — looks "off"
| Before | After | Why |
|--------|-------|-----|
| Parent `rounded-lg` (12px), child `rounded-lg` (12px), padding 8px | Parent `rounded-xl` (16px), child `rounded-md` (8px), padding 8px | 8 + 8 = 16 — radii now concentric |
### 13. Spacing Follows Scale
- **Check**: All padding, margin, and gap values are multiples of 4px
- **How**: Inspect spacing values — no 5px, 7px, 13px, 19px etc.
- **Failing looks like**: Inconsistent spacing that makes the layout feel uneven
### 14. Hit Areas Meet Minimum
- **Check**: All interactive elements have at least 44×44px clickable area
- **How**: Use browser DevTools to measure element + padding dimensions
- **Failing looks like**: Tiny icon buttons, close buttons, or links that are hard to tap on mobile
### 15. Shadows Over Borders
- **Check**: Depth is created with layered box-shadows, not solid borders between sections
- **How**: Look for `border: 1px solid` between content sections
- **Failing looks like**: Hard dividing lines instead of natural depth transitions
### 16. Optical Alignment Verified
- **Check**: Icons in buttons, play triangles, and asymmetric elements are optically centered
- **How**: Squint at the element — does it look centered to the eye?
- **Failing looks like**: A play triangle that's geometrically centered but looks shifted left
## Motion & Interaction (7 items)
### 17. Animation Frequency Appropriate
- **Check**: High-frequency actions (keyboard shortcuts, command palette) have NO animation
- **How**: Review the frequency table — occasional actions get standard animation, frequent actions get none
- **Failing looks like**: A command palette with a 300ms open animation that feels sluggish after the 50th use
### 18. No `transition: all`
- **Check**: Every transition specifies exact properties
- **How**: Search for `transition: all` or `transition-property: all`
- **Failing looks like**: Unintended properties animating (color, padding, border) causing jank
### 19. Custom Easing Curves Used
- **Check**: UI animations use custom bezier curves, not built-in `ease`, `ease-in`, `ease-out`
- **How**: Inspect transition/animation easing values
- **Failing looks like**: Animations feel generic and lack punch
### 20. Enter Animations Split and Staggered
- **Check**: Multi-element entrances use 30-80ms stagger between items
- **How**: Watch page load or section reveal — elements should cascade, not appear all at once
- **Failing looks like**: An entire section popping in as one block
### 21. Press Feedback on Buttons
- **Check**: All pressable elements have subtle `scale(0.96-0.97)` on `:active`
- **How**: Click and hold buttons — they should compress slightly
- **Failing looks like**: Clicking a button with zero visual feedback
### 22. prefers-reduced-motion Respected
- **Check**: Animations reduce/simplify when the user has reduced motion enabled
- **How**: Enable reduced motion in OS settings, reload, check all animations
- **Failing looks like**: Full animations playing for users who opted out
### 23. Hover States Gated
- **Check**: Hover animations are behind `@media (hover: hover) and (pointer: fine)`
- **How**: Test on touch device or emulate touch in DevTools
- **Failing looks like**: Hover states triggering on tap on mobile, causing sticky hover effects
## Accessibility (7 items)
### 24. Keyboard Navigation Complete
- **Check**: Every interactive element is reachable and operable with keyboard only
- **How**: Unplug mouse, Tab through entire page, operate every control
- **Failing looks like**: Unreachable buttons, inoperable dropdowns, trapped focus
### 25. Focus Rings Visible
- **Check**: Every focusable element has a visible focus indicator
- **How**: Tab through the page and verify each element shows focus
- **Failing looks like**: `outline: none` with no replacement, invisible focus state
### 26. Semantic HTML Used
- **Check**: `<button>` for actions, `<a>` for links, `<nav>` for navigation, proper heading hierarchy
- **How**: Inspect the DOM — look for `<div onclick>` or `<span>` where buttons should be
- **Failing looks like**: Divs with click handlers instead of buttons, missing landmarks
### 27. ARIA Labels on Icon Buttons
- **Check**: Every icon-only button has `aria-label` describing its action
- **How**: Inspect icon buttons in DevTools or run axe-core
- **Failing looks like**: Screen reader announcing "button" with no context
### 28. Form Errors Accessible
- **Check**: Error messages use `aria-live` or `role="alert"`, linked via `aria-describedby`
- **How**: Submit an invalid form, check screen reader announces errors
- **Failing looks like**: Visual error message that screen reader users never hear
### 29. Images Have Alt Text
- **Check**: Meaningful images have descriptive `alt`, decorative images have `alt=""`
- **How**: Search for `<img>` without `alt` attribute
- **Failing looks like**: Screen reader announcing file names or nothing for important images
### 30. Skip Link Present
- **Check**: First focusable element is "Skip to main content" link
- **How**: Tab once on page load — skip link should appear
- **Failing looks like**: Keyboard users forced to Tab through entire header/nav on every page
## Quick Pass/Fail Summary
Use this table to record results:
| # | Item | Pass | Notes |
|---|------|------|-------|
| 1 | Font smoothing | | |
| 2 | text-wrap: balance | | |
| 3 | text-wrap: pretty | | |
| 4 | tabular-nums | | |
| 5 | Line length | | |
| 6 | Type scale | | |
| 7 | Semantic tokens | | |
| 8 | WCAG contrast | | |
| 9 | Dark mode contrast | | |
| 10 | Color not sole channel | | |
| 11 | Dark mode visual | | |
| 12 | Concentric radius | | |
| 13 | Spacing scale | | |
| 14 | Hit areas | | |
| 15 | Shadows over borders | | |
| 16 | Optical alignment | | |
| 17 | Animation frequency | | |
| 18 | No transition: all | | |
| 19 | Custom easing | | |
| 20 | Staggered enter | | |
| 21 | Press feedback | | |
| 22 | Reduced motion | | |
| 23 | Hover gated | | |
| 24 | Keyboard nav | | |
| 25 | Focus rings | | |
| 26 | Semantic HTML | | |
| 27 | ARIA labels | | |
| 28 | Form errors | | |
| 29 | Alt text | | |
| 30 | Skip link | | |
+48
View File
@@ -0,0 +1,48 @@
# caveman
Talk like smart caveman. Same brain, fewer tokens.
## What it does
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
Six intensity levels:
| Level | What change |
|-------|-------------|
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
| `full` | Default. Drop articles, fragments OK, short synonyms. |
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
| `wenyan-lite` | Classical Chinese register, light compression. |
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
| `wenyan-ultra` | Extreme classical compression. |
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
## How to invoke
```
/caveman # full mode (default)
/caveman lite # lighter compression
/caveman ultra # extreme compression
/caveman wenyan # classical Chinese
stop caveman # back to normal prose
```
## Example output
Question: "Why does my React component re-render?"
Normal prose:
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
Caveman (full):
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
Caveman (ultra):
> Inline obj prop → new ref → re-render. `useMemo`.
## See also
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
- [Caveman README](../../README.md) — repo overview, install, benchmarks
+78
View File
@@ -0,0 +1,78 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
wenyan-lite, wenyan-full, wenyan-ultra.
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Persistence
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
Default: **full**. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact.
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | What change |
|-------|------------|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
Example — "Explain database connection pooling."
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
## Auto-Clarity
Drop caveman when:
- Security warnings
- Irreversible action confirmations
- Multi-step sequences where fragment order or omitted conjunctions risk misread
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
- User asks to clarify or repeats question
Resume caveman after clear part done.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
> DROP TABLE users;
> ```
> Caveman resume. Verify backup exist first.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
+86
View File
@@ -0,0 +1,86 @@
---
name: fuck-slop
description: >
De-slop pass for any text: detects and erases the statistical fingerprints of
AI writing (negative parallelism / "not X but Y", em-dash abuse, rule-of-three,
false ranges, puffery vocabulary, uniform cadence, hedged both-sidesing) and
rewrites the text into its target register — academic article, tweet, reddit
post, email, blog, anything between. Use when the user says "fuck slop",
"f*ck slop", "deslop", "de-slop this", "remove the AI tells", "humanize this",
"make this not sound like AI", or invokes /fuck-slop. Also use before
publishing any agent-drafted prose.
---
# F*ck Slop
Strip every mark of AI writing from a text and make it good in its genre. Not "make it pass a detector" — make it read like a specific person with a specific point wrote it for a specific audience.
## Why this is a loop, not a style guide
The worst tells — above all the **"not X but Y"** family — are not vocabulary mistakes. They are emergent properties of how LLMs generate text: preference tuning rewards balanced, contrastive, comprehensive-sounding framing, so the contrast move is baked into the model's priors. Two consequences drive this skill's architecture:
1. **You cannot reliably see your own slop.** The same priors that produce the pattern make it invisible on re-read. Detection must be mechanical — regex against a fixed catalog — never "does this look AI to me?"
2. **Rewriting reintroduces slop.** Ask a model to remove "it's not just X, it's Y" and it produces "this is less about X than Y" — the same move in a wig. So every rewrite gets re-scanned, and the loop runs until the scan is clean.
Workflow: **Scan → Diagnose → Rewrite by meaning → Re-scan → (repeat) → Register check.**
## Phase 0: Fix the target
Before touching the text, establish:
- **Genre and venue** — academic article, tweet, reddit post, LinkedIn, email, blog, docs, marketing. If not stated and not obvious from the text, ask. Genre decides which tells are fatal and what "good" means; see [references/voices.md](references/voices.md).
- **Audience and stance** — who reads it, and what the author actually claims. Slop is what fills the space where a claim should be; you cannot remove it without knowing the claim.
- **Constraints** — length limits, required citations, house style.
## Phase 1: Mechanical scan
Run the detection patterns from [references/tells.md](references/tells.md) against the text. If the text is in a file (or you can write it to a temp file), run the grep commands in that reference literally — the catalog is written as runnable `grep -Ein` patterns. Otherwise apply each pattern by hand, line by line.
Produce a finding list: line/sentence, matched pattern, tell category. Also run the two structural checks that regex can't fully catch:
- **Cadence**: flag any run of 3+ consecutive sentences within ±4 words of the same length, and any paragraph where every sentence has the same shape (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).
@@ -0,0 +1,171 @@
# AI-Writing Tell Catalog
Detection patterns for the F*ck Slop scan. Patterns are written for `grep -Ein` (extended regex, case-insensitive, line numbers) so they can be run literally against a file:
```bash
grep -Ein -f /dev/stdin draft.txt <<'PATTERNS'
<paste patterns from a section below, one per line>
PATTERNS
```
When the text only exists in conversation, apply each pattern by hand. A match is a *finding*, not an automatic deletion — every finding goes through the Phase 2 triage in SKILL.md. Density matters: one em dash is nothing; one em dash plus a negative parallelism plus "delve" in the same paragraph is a verdict.
## 1. Negative parallelism — the "not X but Y" family
The highest-priority category. LLMs reach for the negation-then-assertion move roughly once a paragraph; humans use it occasionally and deliberately. It is an emergent generative habit, so expect it to reappear in paraphrased form after every rewrite pass — that is why the scan loops.
```
not (just|only|merely|simply|solely) [^.;]{2,80}(but|it'?s| — )
isn'?t (just|only|merely|simply|about)
it'?s not (a|an|the|that|about|just) [^.;]{2,80}(it'?s|but)
(is|was|are|were)n'?t about [^.;]{2,60}\. (it|this|that)'?s about
less about [^.;]{2,60}(than|and more about)
more than (just|a mere|simply)
not because [^.;]{2,80}but because
the (question|point|issue|problem|goal|real [a-z]+) is(n'?t| not) (whether|about|just|if)
(doesn'?t|don'?t|didn'?t|won'?t) (just|merely|simply) [^.;]{2,80}(it|they|he|she|we)
no [a-z]+, no [a-z]+(, no [a-z]+)?[,.]? just
— not [^—.;]{2,60}, but
not only [^.;]{2,80}but (also )?
we'?re not (just )?(talking about|looking at|dealing with)
gone are the days
(here|this)'?s the (thing|kicker|catch|twist)
```
Rhetorical-question variant (regex-resistant; check by hand): a one-line question immediately answered by a one-word or one-clause sentence. *"The result? Chaos."* / *"Sound familiar?"*
## 2. Puffery and inflated vocabulary
Single words that spike in LLM output. Each is fine in isolation; two or more per page is a finding. The fix is the plain word or the concrete fact the word was hiding.
```
\b(delve|delving)\b
\btapestry\b
\b(testament|stands as)\b
\bseamless(ly)?\b
\b(pivotal|paramount|crucial)\b
\bunderscore(s|d)?\b
\b(landscape|realm|sphere) of\b
\bnavigat(e|ing) the\b
\bfoster(s|ing)?\b
\bleverage(s|d)?\b
\bmeticulous(ly)?\b
\bintricate\b
\bboasts\b
\bgame.?chang(er|ing)\b
\b(seismic|monumental|transformative) (shift|change)\b
\bunwavering\b
\bcommendable\b
\belevate(s|d)? (the|your)\b
\bshowcas(e|es|ing)\b
\bresonate(s|d)?\b
\bcompelling\b
\brich (cultural )?(heritage|history|tradition)\b
\bvibrant\b
\bplays? a (vital|key|crucial|pivotal) role\b
\bdeep(er)? dive\b
\bunlock(s|ing)? (the|your)\b
\bharness(es|ing)? the\b
\bembark(s|ed|ing)? on\b
\bever.?(evolving|changing)\b
\bfast.?paced (world|environment)\b
\bin today'?s\b
\bat the end of the day\b
\bwhen it comes to\b
\bcutting.?edge\b
\brobust\b
\bholistic\b
\bsynergy\b
\bempower(s|ing|ment)?\b
```
## 3. Hedging, both-sidesing, throat-clearing
The tell is reflexive balance: every claim gets a softener, every opinion gets a counterpoint. Commit or cut.
```
it'?s (worth|important) (to note|noting|to remember|to consider)
(that|it) (being )?said,
while (it'?s|this is) (true|important)
arguably
in many ways
to some (extent|degree)
on the other hand
at its core
in essence
essentially,
ultimately,
in conclusion
in summary
to sum(marize| up)
overall,
in the end,
needless to say
as (we|you) (can see|know|all know)
let'?s (dive|unpack|explore|take a (look|closer look))
whether you('re| are) [^.;]{2,60} or
```
## 4. False ranges and rule-of-three
**False range** — a "from X to Y" with no actual spectrum between X and Y:
```
from [^.;]{3,50} to [^.;]{3,50}
```
Triage by hand: if you can name a meaningful midpoint, it's a real range and stays. If X and Y are just two loosely related examples, name them plainly or cut one.
**Rule of three** — LLMs default to triplets to make thin analysis look thorough. Regex only catches the simplest shape; check lists by hand too.
```
\b\w+, \w+, and \w+[.!?]
\b(\w+ \w+), (\w+ \w+), and (\w+ \w+)
```
Triage: keep the strongest item, cut the rest — or keep all three only if each carries distinct information, and then break the rhythm.
## 5. Punctuation and formatting
Em dash: not banned — humans use it. Findings are about **density** and the contrast move:
- More than ~1 em dash per 150 words.
- Two em dashes in one sentence.
- `— not X, but Y` (already in section 1).
- Em dash used for punchy emphasis where a comma works: `[a-z] — [a-z][^—]{1,25}\.$`
Other formatting tells (check by hand; most regexes here are layout-dependent):
- **Bold scattered through prose** like a textbook highlighting itself: `\*\*[^*]{2,40}\*\*` appearing more than ~once per 3 paragraphs of body prose.
- **"Term: definition" bullets**: `^[-*] +\*\*[^*]+:?\*\*:? ` — the signature LLM list shape.
- **Emoji headers/bullets** (🚀, ✅, 💡): needs PCRE, not `-E``LC_ALL=C.UTF-8 grep -Pn '^\s*[-*#]+\s.*[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]' draft.txt`.
- **Headers on short texts** — section headers on anything under ~400 words.
- **The tidy skeleton** — intro that previews three points, three matched sections, conclusion that restates them. Resolves too neatly; real writing has loose ends.
- **Numbered lists where a paragraph would do.**
- Curly quotes/apostrophes in a context where the author types straight ones (mixed within one text is the stronger tell).
## 6. Cadence and statistical shape
No regex; measure or eyeball.
- **Uniform sentence length** (the single strongest current tell): a run of 3+ consecutive sentences within ±4 words of each other, paragraph after paragraph of 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.
@@ -0,0 +1,61 @@
# Register Guide
What "good" means per genre, what tells are fatal there, and what the de-slopped text should sound like. Use in Phase 0 (fix the target) and Phase 4 (register check). Two universal rules first:
1. **Voice comes from commitment, not decoration.** A text sounds human when it asserts specific things a specific person believes, at the level of detail only someone who did the work would know. Slang, typos, and "personality" sprinkled on top do not produce this and read as humanizer-tool output.
2. **Match the author, not a persona.** If the user supplied earlier writing or a draft with their own phrasing in it, keep their words wherever they survive the scan. De-slopping someone into a generic "casual" voice is just different slop.
## Academic article / paper
- **Goal**: precise claims, honest hedges, dense information. Formality stays; puffery goes.
- **Fatal tells here**: "delve", "novel", inflated significance ("crucial implications", "paradigm shift"), rule-of-three in abstracts, negative parallelism in intros ("X is not merely a tool but a fundamental…"), em-dash chains.
- **De-slop moves**: replace significance puffery with the actual finding and effect size. Hedges must be calibrated, not reflexive — "may" because the evidence is genuinely uncertain, not as seasoning. Keep passive voice where the venue expects it; do not inject first person or attitude. Numbers, conditions, and citations beat adjectives.
- **Cadence**: long sentences are fine and normal; the tell is uniformity, not length. Vary clause structure.
## Tweet / X post
- **Goal**: one idea, said like a person, under the limit.
- **Fatal tells here**: "🧵", "Let that sink in", "Read that again", one-clause-per-line stacking, ending on an engagement question, hashtag clusters, the not-X-but-Y move compressed into 200 characters.
- **De-slop moves**: cut to the single claim. Lowercase is fine if that's the author's habit. No setup ("Hot take:") — just the take. A tweet that states an opinion without insurance reads human; a tweet that balances itself does not.
## Reddit post / comment
- **Goal**: reads like a knowledgeable person typing in a text box, because that's what reddit is.
- **Fatal tells here** (reddit users are the most slop-sensitive audience on the internet): **any** bold in a comment, bullet-point essays, headers, "Hope this helps!", "Great question!", symmetric pro/con framing, em-dash density, perfect paragraphing.
- **De-slop moves**: plain paragraphs, contractions, direct answers first. Mild hedges are human here ("iirc", "I might be wrong but") — but only the author's own. Concrete personal detail ("ran into this on a 2019 Outback") is the strongest human marker; never fabricate it, ask the author or drop it.
## LinkedIn post
- **Goal**: professional but specific. The platform's native style is so slop-adjacent that the bar is: would a colleague forward this without cringing?
- **Fatal tells here**: stacked one-line paragraphs, "Agree?", "Let's connect", broetry rhythm, negative parallelism (this is its natural habitat — scan twice), rule-of-three value statements, "I'm humbled to announce".
- **De-slop moves**: write actual paragraphs. Lead with the concrete event or number, not the lesson. One lesson max, stated once, not echoed in a closer.
## Email
- **Goal**: shortest text that's still warm enough for the relationship.
- **Fatal tells here**: "I hope this email finds you well", restating the recipient's email back to them, three symmetric paragraphs wrapping a one-line answer, "Please don't hesitate to reach out".
- **De-slop moves**: answer in the first sentence. Greeting and sign-off match the existing thread's register. Cut every sentence whose only job is politeness padding except one, if the relationship needs it.
## Blog post / newsletter / essay
- **Goal**: a person with a view, walking the reader through it.
- **Fatal tells here**: "In today's fast-paced world" openers, intro-that-previews-three-sections skeleton, "In conclusion", bold-scattered prose, section headers every two paragraphs, engagement-bait closers.
- **De-slop moves**: open inside the subject (a scene, a number, a claim). Let structure follow the argument instead of a template — real essays have asymmetric sections and loose ends. First person and digressions are allowed; they are how essays sound human. Keep headers only when the piece is long enough to need navigation.
## Marketing / landing copy
- **Goal**: concrete benefit, named audience, zero filler.
- **Fatal tells here**: "seamless", "unlock", "empower", "game-changing", "effortless", rule-of-three feature triplets, false ranges ("from startups to enterprises"), every header a not-X-but-Y.
- **De-slop moves**: replace each abstraction with the mechanism or the number ("Set up in 4 minutes" beats "seamless onboarding"). One verb per claim. Specificity is the whole game; if no specifics exist, that's a product-marketing problem the text can't fix — say so.
## Technical docs / README
- **Goal**: the reader gets unblocked fast.
- **Fatal tells here**: "robust", "powerful", "blazingly fast" without benchmarks, "simply"/"just" before steps that aren't, marketing voice in reference material, emoji section headers.
- **De-slop moves**: imperative mood, exact commands, exact versions, expected output. Adjectives almost to zero. Lists are fine here — docs are the one genre where "Term: definition" bullets are legitimate structure, so don't strip them; strip the puffery inside them.
## Academic-adjacent: cover letters, statements, grant prose
- **Goal**: claims about the author backed by evidence, in formal register.
- **Fatal tells here**: "passionate", "deeply committed", "unique perspective", testament/tapestry vocabulary, rule-of-three trait lists, every paragraph ending with a not-X-but-Y synthesis.
- **De-slop moves**: every trait claim becomes an event ("I led X, which produced Y"). Keep formality; cut self-puffery. The reader has read ten thousand of these — only specifics differentiate.
+209
View File
@@ -0,0 +1,209 @@
---
name: grill-me
description: Calibrated grilling session for stress-testing a plan, design, idea, or decision. First assesses the user's topic knowledge, confidence, and desired pressure level, then asks one question at a time with recommended answers. Use when user says "grill me", "stress-test this", "challenge my plan", "interview me", or wants a plan probed without being overwhelmed.
---
# Grill Me
Interview the user until the plan is clear, defensible, and ready for action.
This is not hostile debate. It is calibrated pressure. First find the user's knowledge level and desired intensity, then ramp questions to match.
## Core Rules
- Ask one question at a time.
- Give a recommended answer for every question.
- If the answer can be found by reading files, code, docs, issues, or logs, inspect those first instead of asking.
- Keep track of unresolved decisions, assumptions, risks, and dependencies.
- Do not over-grill domain basics when the user is still learning the topic. Teach the missing frame briefly, then ask the next useful question.
- Do not under-grill confident experts. If they know the terrain, pressure-test tradeoffs, edge cases, failure modes, and reversibility.
- Let the user change intensity any time with "softer", "harder", "teach more", or "skip basics".
## Phase 1: Frame The Target
Identify what should be grilled before asking about comfort. If the topic is not clear, ask:
> What plan, design, or decision should I grill?
>
> Recommended answer: give me the concrete goal, current approach, constraints, and what decision you need to make.
If context already contains the plan, summarize it in 3-6 bullets and ask for correction:
> I think target is: [...]
>
> Recommended answer: "Yes, grill that" or "Adjust: ..."
## Phase 2: Calibration
Before grilling the topic, ask a short calibration question unless the user's level is already obvious from context.
Ask:
> Before I grill the plan: what is your current comfort with this topic, and how hard do you want the pressure?
>
> Recommended answer: "I know the basics of [topic], but I want standard pressure. Explain missing concepts briefly, then keep pushing."
Use the user's answer to set two dials:
### Knowledge Level
- **New** - user lacks core vocabulary or model of the domain.
- **Working** - user understands basics and can discuss tradeoffs.
- **Expert** - user knows domain deeply and wants sharper critique.
### Pressure Level
- **Light** - clarify goals, constraints, and missing context.
- **Standard** - challenge assumptions, tradeoffs, and execution path.
- **Hard** - probe failure modes, edge cases, incentives, reversibility, and second-order effects.
If the user does not answer calibration, default to:
- Knowledge: **Working**
- Pressure: **Standard**
## Phase 3: Build The Decision Map
Create a private decision map while asking questions one at a time:
- Goal - what success means.
- User or customer - who this affects.
- Constraints - time, money, stack, team, policy, risk.
- Options - obvious alternatives and why current option wins.
- Dependencies - what must be true first.
- Risks - what breaks, gets expensive, or becomes irreversible.
- Validation - how user will know it worked.
- Rollback - how to undo or recover.
Do not dump the full map unless user asks. Use it to choose the next question.
## Phase 4: Question Ladder
Move through this ladder. Stop early if the plan becomes clear enough or user asks to stop.
### 1. Goal Fit
Questions:
- What outcome matters most?
- What would make this not worth doing?
- What problem are we solving, and for whom?
### 2. Constraint Reality
Questions:
- What hard constraint cannot move?
- What resource bottleneck decides the plan?
- What assumption would kill the plan if false?
### 3. Option Pressure
Questions:
- What are the top two alternatives?
- Why this approach over the boring one?
- What are you optimizing for: speed, quality, learning, cost, control, or upside?
### 4. Execution Path
Questions:
- What is the smallest useful version?
- What has to happen first?
- What can be deferred without harming the goal?
### 5. Failure Modes
Questions:
- How does this fail in production or real use?
- What edge case would embarrass the plan?
- What part is hardest to observe once it breaks?
### 6. Validation
Questions:
- What test, metric, screenshot, demo, or user behavior proves this works?
- What would you check before trusting it?
- What does done mean in observable terms?
### 7. Reversibility
Questions:
- What decision here is hardest to undo?
- What backup, migration, rollback, or escape hatch exists?
- What should be logged as an ADR or explicit tradeoff?
## Pressure Adaptation
### If Knowledge Is New
- Define one missing concept in 2-4 sentences before asking.
- Avoid jargon unless you define it.
- Ask fewer branching questions.
- Focus on goals, constraints, and first principles.
- Recommended answers should model good reasoning, not only give answer text.
### If Knowledge Is Working
- Ask normal tradeoff questions.
- Surface alternatives.
- Push for validation and smallest useful version.
- Challenge vague words like "simple", "scalable", "good", "clean", or "fast".
### If Knowledge Is Expert
- Skip basics.
- Ask sharper counterfactuals.
- Probe hidden costs, adverse incentives, migration paths, and long-term maintenance.
- Ask what evidence would change their mind.
### If Pressure Is Light
- Keep questions clarifying.
- Use supportive framing.
- Stop after top ambiguities are resolved.
### If Pressure Is Standard
- Challenge assumptions and tradeoffs.
- Keep moving until implementation path is concrete.
### If Pressure Is Hard
- Be direct.
- Name weak reasoning.
- Ask about unpleasant edge cases.
- Demand observable validation.
- Still ask one question at a time.
## Recommended Answer Format
Every question includes:
```text
Question: ...
Recommended answer: ...
Why it matters: ...
```
Keep "Why it matters" to one sentence.
## When To Stop
Stop grilling when one of these is true:
- User says stop.
- Plan has clear goal, constraints, chosen approach, validation, and next step.
- Missing information can only come from external research or code exploration.
- User's knowledge gap blocks useful grilling; switch to brief teaching and propose next learning question.
End with:
- Final decision or current best plan.
- Remaining open questions.
- Next concrete action.
- Risks to watch.
+582
View File
@@ -0,0 +1,582 @@
---
name: interface-kit
description: |
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
design engineering philosophy, accessibility standards, animation principles, spatial design,
typography, color systems, and component craft into a single actionable reference.
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
the HOW of implementation.
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
"frontend", "design", "polish UI", "implement design", "make it beautiful",
"UI implementation", "component styling", "animation", "accessibility"
---
# Interface Kit: Implementation Guide for Exceptional Interfaces
> If a DESIGN.md exists at the project root, its tokens and specifications override all defaults in this skill. This skill provides sensible defaults for when no design system exists, and implementation guidance that applies regardless.
> For deep dives on any section, see the reference files in this skill's `references/` directory.
---
## 1. Core Philosophy
Taste is trained, not innate. Study why great interfaces feel right. Deconstruct apps you admire — the spacing, the timing, the weight of a shadow. The gap between "fine" and "exceptional" is built from hundreds of micro-decisions that users feel but never consciously notice.
**Unseen details compound.** A single rounded corner, a single eased transition, a single well-chosen shadow — none of these matter alone. Together they become "a thousand barely audible voices singing in tune." The cumulative effect is what separates craft from output.
**Beauty is leverage.** Polish is not vanity. Good defaults, considered typography, and intentional motion are real differentiators. Users trust interfaces that feel cared for. Investors notice. Competitors can't easily replicate taste.
**Intentionality over intensity.** Both bold maximalism and refined minimalism work — what fails is the absence of a clear point of view. Every visual decision should trace back to a deliberate conceptual direction. If you can't articulate WHY a choice was made, reconsider it.
**Choose a direction and execute with precision.** Don't hedge between styles. A brutalist page committed fully will always outperform a page that's "a little bit of everything." Commit, then refine.
**NEVER produce generic "AI slop" aesthetics.** No gratuitous gradients on white backgrounds. No cookie-cutter hero sections with stock illustrations. No safe, forgettable layouts that could belong to any product. Every interface should have a point of view that makes it recognizable.
---
## 2. The Priority Stack
When implementing UI, work through these priorities in order. Higher priorities are non-negotiable; lower priorities are polish that compounds quality.
| Priority | Level | What It Means |
|----------|-------|---------------|
| **Accessibility** | CRITICAL | Contrast 4.5:1, keyboard nav, ARIA semantics, visible focus rings. Ship nothing that excludes users. |
| **Performance** | HIGH | WebP/AVIF images, lazy loading below fold, CLS < 0.1, transform-only animations on the compositor thread. |
| **Typography** | HIGH | Font smoothing, text-wrap balance/pretty, tabular-nums for data, 65ch max line length. |
| **Layout & Spatial** | HIGH | 4/8px grid, concentric border radius, optical alignment over geometric. |
| **Color & Theme** | MEDIUM | HSL custom properties, semantic tokens, dark mode pairs tested separately. |
| **Motion & Interaction** | MEDIUM | Frequency-based animation decisions, 150-300ms durations, ease-out default. |
| **Polish & Details** | LOW | Layered shadows over borders, press feedback on buttons, staggered enter animations. |
Never skip a CRITICAL/HIGH item to chase a LOW item. A beautifully animated button that fails keyboard navigation is a net negative.
---
## 3. Aesthetic Direction
Before writing a single line of CSS, commit to a bold aesthetic direction. The most common failure mode in AI-generated UI is convergence on the same safe, forgettable look.
### Pick a Tone
Choose one and commit fully:
- **Brutally minimal** — generous whitespace, monospace type, stark contrast, near-zero decoration
- **Maximalist chaos** — layered textures, clashing type scales, dense information, intentional visual noise
- **Retro-futuristic** — CRT glow effects, monospace terminals, scan lines, neon on dark
- **Organic / natural** — earth tones, rounded shapes, paper textures, hand-drawn accents
- **Luxury / refined** — serif headlines, muted palettes, ample negative space, subtle gold or cream accents
- **Editorial / magazine** — dramatic type hierarchy, full-bleed imagery, grid-breaking layouts
- **Playful / bold** — bright primaries, chunky borders, exaggerated shadows, bouncy motion
### Match Complexity to Vision
Maximalist design demands elaborate code — layered backgrounds, complex grid structures, multiple font stacks. Minimalist design demands surgical precision — every pixel of spacing matters more when there's nothing to hide behind.
### The Ban List (When No DESIGN.md Exists)
When building without an existing design system, avoid these overused defaults that signal "AI-generated":
- **Fonts**: Inter, Roboto, Arial, system-ui as display fonts, Space Grotesk
- **Colors**: Purple-to-blue gradients on white backgrounds
- **Patterns**: Generic hero with centered text + CTA + stock illustration
Vary between light and dark themes, different font pairings, different aesthetic directions. Never converge on the same choices across projects.
### Visual Texture
Add depth through: gradient meshes, noise/grain overlays (`filter: url(#noise)`), layered transparencies, subtle background patterns, duotone image treatments.
**DESIGN.md overrides this entire section.** If DESIGN.md specifies Inter, use Inter. If it specifies purple gradients, use them. The ban list only applies when no design system exists and you're making aesthetic choices from scratch.
---
## 4. Typography Essentials
Typography is the single highest-leverage design element. Get it right and mediocre layouts still feel good. Get it wrong and nothing else saves it.
### Root Setup
```css
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
```
Apply font smoothing to the root layout. On macOS, the default sub-pixel rendering makes text appear heavier than the designer intended.
### Text Wrapping
```css
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
p, li, dd, blockquote {
text-wrap: pretty;
}
```
`balance` distributes heading lines evenly. `pretty` avoids orphaned words in body text.
### Numeric Display
```css
.data-value, .price, .counter, [data-numeric] {
font-variant-numeric: tabular-nums;
}
```
Use `tabular-nums` for any number that updates dynamically — prices, counters, table columns. Without it, layout shifts as digit widths change.
### Scale and Rhythm
- **Base size**: 16px minimum for body text. Never go below 14px for any readable content.
- **Line height**: 1.5-1.75 for body text, 1.1-1.3 for large headings.
- **Max line length**: `max-width: 65ch` for body text. Long lines destroy readability.
- **Type scale**: Pick a consistent scale and stick to it: 12 / 14 / 16 / 18 / 24 / 32 / 48 / 64.
### Font Pairing
Pair a distinctive display font with a refined body font. The display font carries personality; the body font carries readability. Use `font-weight` for hierarchy within a family:
- **Headings**: 600-700 (semibold to bold)
- **Body**: 400 (regular)
- **Labels / UI**: 500 (medium)
Always include font stack fallbacks:
```css
--font-display: "Instrument Serif", "Georgia", serif;
--font-body: "Söhne", "Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
```
---
## 5. Color & Theme
### HSL Custom Properties (shadcn Pattern)
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
```
Define semantic tokens: primary, secondary, destructive, muted, accent, background, foreground. Reference colors by semantic name — never hardcode hex values in components.
### Dark Mode
```css
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... desaturated, lighter tonal variants — NOT simply inverted */
}
```
Dark mode is not "invert colors." Use desaturated, lighter tonal variants. Backgrounds go dark but not pure black (`#000`). Text goes light but not pure white (`#fff`). Test contrast separately for dark mode — what passes in light may fail in dark.
### Contrast Requirements
- **WCAG AA minimum**: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular)
- Never convey information by color alone — always pair with an icon, label, or pattern
- Test with browser devtools contrast checker or axe-core
### Color Confidence
Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Pick one or two hero colors and let the rest of the palette recede. A confident palette has clear hierarchy; an uncertain palette spreads color evenly and feels flat.
---
## 6. Spatial Design
### Concentric Border Radius
This is the single most common thing that makes nested UI elements feel "off":
```
outer_radius = inner_radius + padding
```
```css
/* Correct: concentric */
.card { border-radius: 16px; padding: 8px; }
.card-inner { border-radius: 8px; } /* 16 - 8 = 8 */
/* Wrong: same radius on parent and child */
.card { border-radius: 12px; }
.card-inner { border-radius: 12px; } /* Looks bloated */
```
When geometric centering looks off, align optically. Play/pause icons, dropdown carets, and asymmetric glyphs often need 1-2px manual nudges to look centered.
### Shadows Over Borders
Layer multiple transparent `box-shadow` values for natural depth instead of using borders:
```css
.elevated {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 4px rgba(0, 0, 0, 0.04),
0 4px 8px rgba(0, 0, 0, 0.04);
}
```
Multiple shadows at different spreads mimic how light works. A single hard shadow looks artificial.
### Image Outlines
Add a subtle inset outline to images and media for consistent depth against varied backgrounds:
```css
img, video {
outline: 1px solid rgba(0, 0, 0, 0.06);
outline-offset: -1px;
}
```
### Spacing Scale
Use a 4px / 8px base incremental system. Every spacing value should be a multiple of 4:
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128`
### Hit Areas
Minimum 44x44px for all interactive elements. If the visual element is smaller, extend the hit area with a pseudo-element:
```css
.small-button::before {
content: "";
position: absolute;
inset: -8px;
}
```
### Z-Index Scale
Define a layered scale and never use arbitrary values:
```css
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 40;
--z-modal: 100;
--z-toast: 1000;
```
---
## 7. Motion & Interaction
### The Frequency-Based Decision Framework
This is the most important mental model for animation decisions:
| Frequency | Examples | Animation |
|-----------|----------|-----------|
| **100+ times/day** | Keyboard shortcuts, command palette actions, tab switches | **None.** Zero animation. Instant. |
| **Tens of times/day** | Hover effects, list item navigation, toggles | **Remove or drastically reduce.** 50-100ms max. |
| **Occasional** | Modals, drawers, toasts, page transitions | **Standard animation.** 150-300ms. |
| **Rare / first-time** | Onboarding, celebrations, empty states | **Can add delight.** 300-500ms, more elaborate. |
High-frequency animations feel sluggish. Low-frequency animations without motion feel jarring. Match the animation budget to usage frequency.
### Custom Easing Curves
Built-in CSS easings (`ease`, `ease-in-out`) are too weak. Define custom curves:
```css
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
```
### Duration Guide
| Element | Duration |
|---------|----------|
| Buttons, toggles | 100-160ms |
| Tooltips | 125-200ms |
| Dropdowns, popovers | 150-250ms |
| Modals, drawers | 200-500ms |
| Page transitions | 250-400ms |
UI animations should stay under 300ms. Never use `ease-in` for UI animations — it front-loads the pause and feels sluggish.
### Enter/Exit Asymmetry
Exits should be softer and faster than enters. An enter animation at 250ms should have its exit at 150-200ms.
### Split and Stagger Enter Animations
When multiple elements enter the viewport, stagger them by semantic chunks with ~50-100ms delay:
```css
.stagger-item {
animation: fadeSlideIn 300ms var(--ease-out) both;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 60ms; }
.stagger-item:nth-child(3) { animation-delay: 120ms; }
```
### Scale Animations
Never animate from `scale(0)`. Start from `scale(0.9)` or higher, combined with opacity:
```css
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
```
### Press Feedback
Every pressable element should scale down slightly on `:active`:
```css
button:active {
transform: scale(0.97);
}
```
### Interruptibility
Use CSS transitions (not keyframe animations) for interactive state changes. Transitions can be interrupted mid-way; keyframes cannot. This matters for hover states, toggles, and any element the user might interact with rapidly.
### Popover Origin
Make popovers transform-origin aware — they should grow from their trigger element, not from center. Exception: modals always originate from center.
### Tooltip Hover Delay
Skip the tooltip delay on subsequent hovers. If the user has already waited for one tooltip, show the next one immediately.
### Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
Respect `prefers-reduced-motion`. Reduce animations — don't eliminate opacity and color transitions entirely, as those provide important feedback.
### Hover Gate
Gate hover animations behind a media query so touch devices don't trigger stuck hover states:
```css
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-2px); }
}
```
> Reference `references/animation-playbook.md` for deep dives on spring physics, gesture-driven animation, and complex choreography.
---
## 8. Component Craft
### Primitives
Use Radix UI primitives for accessible, unstyled foundations. Use CVA (class-variance-authority) for type-safe component variants:
```tsx
import { cva } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-9 px-3 text-sm",
default: "h-10 px-4 py-2",
lg: "h-11 px-8 text-lg",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
```
### Button
- Scale on press (`transform: scale(0.97)` on `:active`)
- Visible focus ring (never `outline: none` without replacement)
- Loading state with spinner replacing label, maintaining button dimensions
- Disabled state at `opacity: 0.5` with `pointer-events: none`
### Card
- Concentric border radius between card and inner elements
- Layered shadows (not borders) for depth
- Hover state: subtle elevation change (`translateY(-1px)` + shadow increase)
### Dialog / Modal
- Focus trap (keyboard cannot escape to elements behind)
- ESC to close, click outside overlay to close
- `transform-origin: center`, fade + scale enter animation
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
### Form
- Visible labels always — never placeholder-only inputs
- Error messages near the field with `aria-live="polite"` for screen readers
- Progressive disclosure: show advanced fields only when needed
- Use React Hook Form + Zod for validation
### Theming
Use shadcn CSS variable pattern (HSL format) for all component colors. Wrap client-interactive components in server components for Next.js App Router compatibility.
> Reference `references/component-patterns.md` for the full component catalog with copy-paste implementations.
---
## 9. Accessibility Essentials
### Semantic HTML First
Use `<button>`, `<nav>`, `<main>`, `<header>`, `<footer>`, `<article>`, `<section>` before reaching for ARIA. A `<button>` gives you keyboard handling, focus management, and screen reader semantics for free. A `<div onClick>` gives you none of that.
### Keyboard Navigation
- **Tab / Shift+Tab**: move between focusable elements
- **Enter / Space**: activate buttons and links
- **Arrow keys**: navigate within lists, menus, tabs, radio groups
- **Escape**: close modals, popovers, dropdowns
- **Home / End**: jump to first/last item in lists
### Focus Management
- Visible focus rings on all interactive elements — NEVER use `outline: none` without a replacement
- Trap focus inside modals (Tab wraps within the modal, not behind it)
- Restore focus to the trigger element when a modal/popover closes
- Use `focus-visible` to show rings only for keyboard users, not mouse clicks:
```css
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
```
### ARIA Attributes
- `aria-label` for icon-only buttons: `<button aria-label="Close menu">X</button>`
- `aria-labelledby` to associate headings with sections
- `aria-describedby` to link help text or error messages to inputs
- `aria-live="polite"` for dynamic content updates (toast messages, form errors)
- `aria-hidden="true"` for decorative elements (icons next to text labels)
- `aria-expanded` for toggleable elements (dropdowns, accordions)
### Color and Contrast
- WCAG AA: 4.5:1 for normal text, 3:1 for large text
- Never use color as the sole indicator — pair with icons, text, or patterns
- Test in both light and dark modes
### Images and Media
- Descriptive `alt` text for meaningful images: `alt="Dashboard showing 23% revenue growth"`
- Empty `alt=""` for purely decorative images
- Captions for video, transcripts for audio
### Navigation Aids
- **Skip link**: first focusable element, hidden until focused:
```html
<a href="#main-content" class="sr-only focus:not-sr-only">
Skip to main content
</a>
```
- **Heading hierarchy**: sequential h1 through h6, no level skips. One `<h1>` per page.
### Touch Targets
- Minimum 44x44px interactive area
- 8px minimum spacing between adjacent touch targets
- Extend small visual elements with invisible padding or pseudo-elements
### Testing
- **Automated**: axe-core in CI, Lighthouse accessibility score 90+
- **Manual**: full keyboard-only navigation test
- **Screen reader**: test with VoiceOver (macOS) or NVDA (Windows)
- **Visual**: zoom to 200%, check nothing breaks or overlaps
> Reference `references/accessibility-checklist.md` for the full audit guide with pass/fail criteria.
---
## 10. Pre-Delivery Review
Run through this checklist before considering any UI implementation complete:
### Typography
- [ ] Font smoothing applied (`-webkit-font-smoothing: antialiased`)
- [ ] Headings use `text-wrap: balance`
- [ ] Dynamic numbers use `font-variant-numeric: tabular-nums`
### Color
- [ ] All colors referenced via semantic tokens, no hardcoded hex in components
- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
- [ ] Dark mode tested separately for contrast
### Spatial
- [ ] Nested rounded elements use concentric border radius
- [ ] Spacing follows 4px / 8px scale consistently
- [ ] Interactive elements have 44x44px minimum hit area
- [ ] Shadows used instead of borders where appropriate
### Motion
- [ ] Animation frequency matches usage frequency (no animation on high-frequency actions)
- [ ] No `transition: all` anywhere — specific properties only
- [ ] Enter animations split and staggered where multiple elements appear
- [ ] `prefers-reduced-motion` respected
### Accessibility
- [ ] All interactive elements keyboard accessible
- [ ] Focus rings visible on keyboard navigation (never `outline: none` without replacement)
- [ ] Semantic HTML used before ARIA
- [ ] `aria-live` on dynamic content updates
> Reference `references/review-checklist.md` for the extended 30-item checklist with severity ratings and automated testing commands.
@@ -0,0 +1,425 @@
# WCAG 2.1 AA Accessibility Audit Guide
Comprehensive checklist for building accessible web interfaces. Every requirement maps to WCAG 2.1 Level AA success criteria.
---
## 1. Semantic HTML Priority
ALWAYS use semantic HTML before reaching for ARIA. Native elements carry built-in keyboard behavior, focus management, and screen reader announcements that ARIA can only approximate.
### Element Selection Rules
| Instead of | Use |
|---|---|
| `<div role="button">` | `<button>` |
| `<div role="navigation">` | `<nav>` |
| `<div class="header">` | `<header>` |
| `<div class="footer">` | `<footer>` |
| `<span onClick>` | `<a href>` or `<button>` |
| `<div role="list">` | `<ul>` / `<ol>` |
| `<div class="table">` | `<table>` with `<thead>`, `<tbody>`, `<th>` |
### Landmark Elements
- `<main>` — one per page, wraps primary content
- `<nav>` — navigation sections (label with `aria-label` when multiple exist)
- `<header>` — introductory content or navigation aids
- `<footer>` — footer content, copyright, related links
- `<aside>` — tangentially related content (sidebars, callouts)
- `<article>` — self-contained composition (blog post, comment, widget)
- `<section>` — thematic grouping of content (always pair with a heading)
### Form Associations
- `<label>` with `for` attribute connected to the input's `id`
- Group related inputs with `<fieldset>` and `<legend>`
- Use `<optgroup>` for grouped select options
### Heading Hierarchy
- Sequential order: h1 -> h2 -> h3 -> h4 -> h5 -> h6
- NEVER skip levels (e.g., h1 directly to h3)
- One `<h1>` per page (the page title)
- Headings must describe the content that follows
---
## 2. Keyboard Navigation Patterns
Every interactive element must be operable with a keyboard alone. No mouse-only interactions.
### Global Key Bindings
| Key | Action |
|---|---|
| `Tab` | Move focus to next focusable element |
| `Shift + Tab` | Move focus to previous focusable element |
| `Enter` | Activate links, buttons, submit forms |
| `Space` | Activate buttons, toggle checkboxes |
| `Escape` | Close modals, dropdowns, popovers, tooltips |
| `Arrow keys` | Navigate within composite widgets |
| `Home` | Jump to first item in a list or range |
| `End` | Jump to last item in a list or range |
### Composite Widget Navigation (Arrow Keys)
- **Tabs**: Left/Right arrows move between tabs
- **Menus**: Up/Down arrows move between menu items
- **Radio groups**: Arrow keys cycle through options, selecting as they go
- **Listboxes**: Up/Down arrows move highlight, Space selects
- **Tree views**: Up/Down navigate siblings, Right expands, Left collapses
### tabindex Rules
- `tabindex="0"` — places element in natural tab order (use for custom interactive elements)
- `tabindex="-1"` — removes from tab order but allows programmatic focus via `element.focus()` (use for modal containers, skip-link targets, dynamically focused content)
- **NEVER** use `tabindex > 0` — it overrides natural DOM order and creates an unpredictable, unmaintainable focus sequence
### Focus Order Principle
Focus order must match the visual reading order (left-to-right, top-to-bottom for LTR languages). If the DOM order does not match the visual layout, fix the DOM order rather than using positive tabindex values.
---
## 3. ARIA Attributes Reference
The first rule of ARIA: do not use ARIA if a native HTML element provides the behavior. When you must use ARIA, apply it correctly.
### Naming and Describing
| Attribute | Purpose | Example |
|---|---|---|
| `aria-label` | Names an element without visible text | Icon button: `<button aria-label="Close">X</button>` |
| `aria-labelledby` | Points to another element as the label | Modal: `aria-labelledby="dialog-title"` |
| `aria-describedby` | Provides additional description | Form hint: `aria-describedby="password-hint"` |
### Live Regions
| Attribute | Behavior |
|---|---|
| `aria-live="polite"` | Waits for current speech to finish before announcing (toasts, status updates) |
| `aria-live="assertive"` | Interrupts current speech immediately (critical errors, urgent alerts) |
| `aria-atomic="true"` | Re-reads entire region content on change, not just the delta |
| `role="alert"` | Shorthand for `aria-live="assertive"` + `aria-atomic="true"` |
| `role="status"` | Shorthand for `aria-live="polite"` + `aria-atomic="true"` |
### State and Properties
| Attribute | Purpose |
|---|---|
| `aria-expanded` | Indicates whether a collapsible section is open (`true`) or closed (`false`) |
| `aria-haspopup` | Indicates the trigger opens a popup (`menu`, `listbox`, `dialog`, `grid`, `tree`) |
| `aria-modal="true"` | Marks a dialog as modal (assistive tech should ignore content outside) |
| `aria-hidden="true"` | Hides element from assistive technology (decorative images, duplicate content) |
| `aria-invalid` | Marks a form field as having an error (`true`, `grammar`, `spelling`) |
| `aria-required` | Indicates the field is required before form submission |
| `aria-sort` | Indicates sort direction on table column headers (`ascending`, `descending`, `none`) |
| `aria-selected` | Indicates selected state in single/multi-select widgets |
| `aria-controls` | Identifies the element(s) controlled by this element |
| `aria-current` | Indicates the current item in a set (`page`, `step`, `location`, `date`, `true`) |
| `aria-disabled` | Marks element as disabled but still perceivable (unlike `disabled` attribute which removes from tab order) |
---
## 4. Focus Management
### Visible Focus Indicators
- **NEVER** use `outline: none` or `outline: 0` without providing a custom alternative
- Recommended default: `outline: 3px solid currentColor; outline-offset: 2px;`
- Use `:focus-visible` for keyboard-only focus styling (hides ring on mouse click):
```css
:focus-visible {
outline: 3px solid var(--focus-color, #2563eb);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
```
- Focus indicators must meet 3:1 contrast ratio against adjacent colors (WCAG 2.4.11)
- Minimum focus indicator area: at least 2px perimeter around the component
### Modal Focus Trapping
When a modal opens:
1. Move focus to the first focusable element inside the modal (or the modal container with `tabindex="-1"`)
2. Trap Tab/Shift+Tab to cycle only through focusable elements within the modal
3. Pressing Escape closes the modal
4. On close, return focus to the element that triggered the modal
### Focus Restoration
- When a dropdown/popover/modal closes, return focus to its trigger element
- When an item is deleted from a list, move focus to the nearest remaining item
- When a dialog confirms an action, focus the result or next logical element
### SPA Route Changes
- On navigation, move focus to the main content heading or a skip-link target
- Announce the new page title to screen readers using an `aria-live` region or document.title update
- Use `<title>` updates: "Page Name | Site Name"
### Skip Links
- First focusable element on the page should be "Skip to main content"
- Link target: `<main id="main-content" tabindex="-1">`
- Visually hidden until focused:
```css
.skip-link {
position: absolute;
left: -9999px;
top: auto;
}
.skip-link:focus {
position: static;
left: auto;
}
```
---
## 5. Color Contrast Requirements
### WCAG AA Minimum Ratios
| Element | Minimum Contrast Ratio |
|---|---|
| Normal text (< 24px, or < 18.66px if bold) | 4.5:1 |
| Large text (>= 24px, or >= 18.66px if bold) | 3:1 |
| UI components (borders, icons, form controls) | 3:1 |
| Graphical objects (charts, infographics) | 3:1 |
| Disabled elements | No requirement (but keep readable) |
| Placeholder text | 4.5:1 (it is regular text) |
### Testing Tools
- Chrome DevTools: Elements panel -> Styles -> color swatch -> contrast ratio
- axe-core browser extension
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Stark (Figma/Sketch plugin)
### Color Independence Rules
- **NEVER** convey information by color alone
- Error states: red color + error icon + descriptive text message
- Required fields: asterisk + "required" label text (not just red border)
- Status indicators: color + icon + text label (e.g., green checkmark + "Complete")
- Links in body text: color + underline (or other non-color differentiator)
- Charts/graphs: use patterns, labels, or shapes in addition to color
### Dark Mode Considerations
- Test contrast ratios separately in dark mode
- Use desaturated color variants, not simple CSS `invert()`
- Background and foreground pairs must both be intentionally chosen
- Semi-transparent overlays can reduce effective contrast -- verify computed values
---
## 6. Accessible Component Patterns
### Dropdown / Select
```
trigger: aria-haspopup="listbox", aria-expanded="false|true"
container: role="listbox"
options: role="option", aria-selected="true|false"
```
- Arrow keys navigate options
- Typeahead: typing characters jumps to matching option
- Enter/Space selects highlighted option
- Escape closes without selecting
- Selected option text updates trigger label
### Modal / Dialog
```
container: role="dialog", aria-modal="true", aria-labelledby="title-id"
title: id="title-id"
close button: aria-label="Close dialog"
```
- Focus moves into modal on open
- Tab cycles within modal (focus trap)
- Escape closes modal
- Click on backdrop closes modal
- Focus returns to trigger on close
- Background content gets `aria-hidden="true"` or `inert`
### Tabs
```
container: role="tablist"
tab: role="tab", aria-selected="true|false", aria-controls="panel-id", tabindex="0|-1"
panel: role="tabpanel", aria-labelledby="tab-id", tabindex="0"
```
- Only the active tab has `tabindex="0"`; inactive tabs have `tabindex="-1"`
- Left/Right arrows move between tabs (wrapping optional)
- Home/End jump to first/last tab
- Tab key moves focus from the active tab into the panel content
### Forms
- Every `<input>`, `<select>`, `<textarea>` has a visible `<label>`
- Required fields: `aria-required="true"` + visual asterisk indicator
- Error fields: `aria-invalid="true"` + `aria-describedby` pointing to error message element
- Error messages: use `role="alert"` or `aria-live="assertive"` region
- On failed submission: focus the first invalid field
- Helper text: linked via `aria-describedby` to the associated input
- Password fields: toggle visibility button with `aria-label` describing current state
- Groups of related controls: `<fieldset>` + `<legend>`
### Accordion
```
trigger: <button aria-expanded="true|false" aria-controls="panel-id">
panel: id="panel-id", role="region", aria-labelledby="trigger-id"
```
- Enter/Space toggles section
- Only one section open at a time (optional, depends on design)
- Panel content hidden with `hidden` attribute or `display: none` (not just visually)
### Toast / Notification
- Container: `role="status"` or `aria-live="polite"` (non-critical)
- Critical notifications: `role="alert"` (assertive)
- Must be dismissible (close button or auto-dismiss with sufficient time)
- Auto-dismiss: minimum 5 seconds visible, pauses on hover/focus
---
## 7. prefers-reduced-motion
### Global Reset
```css
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
### Nuanced Approach
Reduced motion means fewer/gentler animations, not zero motion:
- **Keep**: opacity fades, color transitions that aid comprehension
- **Remove**: parallax scrolling, zoom/scale transforms, slide/translate animations, auto-playing carousels
- **Simplify**: complex multi-step animations to simple fades
### Framework Integration
React (framer-motion):
```jsx
import { useReducedMotion } from 'framer-motion';
function Component() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={{ x: shouldReduceMotion ? 0 : 100 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
/>
);
}
```
CSS custom property approach:
```css
:root {
--transition-speed: 0.3s;
}
@media (prefers-reduced-motion: reduce) {
:root {
--transition-speed: 0.01ms;
}
}
```
---
## 8. Testing Approach
### Automated Testing
| Tool | Usage |
|---|---|
| axe-core | `npm install jest-axe` for unit tests; `expect(container).toHaveNoViolations()` |
| Lighthouse | Accessibility score target: 90+ |
| eslint-plugin-jsx-a11y | Static analysis for JSX accessibility issues |
| pa11y | CLI/CI integration for automated page-level audits |
| Playwright/axe | `@axe-core/playwright` for integration test accessibility checks |
### Manual Testing Checklist
1. **Keyboard-only navigation**: unplug mouse, navigate entire page with Tab, Enter, Arrows, Escape
2. **Screen reader**: VoiceOver (macOS: Cmd+F5), NVDA (Windows, free), JAWS (Windows)
3. **Zoom 200%**: content should reflow without horizontal scrolling or content clipping
4. **Zoom 400%**: text should remain readable (WCAG 1.4.10 Reflow)
5. **Focus indicators**: every interactive element shows a visible focus ring when focused via keyboard
6. **Forced colors mode**: test in Windows High Contrast Mode (use `forced-colors` media query)
7. **Text spacing**: override letter-spacing (0.12em), word-spacing (0.16em), line-height (1.5), paragraph-spacing (2em) -- content must remain readable
### CI Integration
```bash
# Example: axe-core with Playwright in CI
npx playwright test --project=accessibility
```
---
## 9. Common Mistakes
| Mistake | Fix |
|---|---|
| `outline: none` on focus | Use `:focus-visible` with a custom focus ring |
| Placeholder as only label | Always use `<label>` element |
| Icon button without label | Add `aria-label="Action description"` |
| Color-only error indication | Add icon + descriptive text alongside color |
| Missing alt text on images | Descriptive `alt` text, or `alt=""` for decorative images |
| Heading level skip (h1 to h3) | Sequential hierarchy: h1 -> h2 -> h3 |
| `tabindex > 0` | Use natural DOM order; only use `0` or `-1` |
| Emoji used as functional icons | Use SVG icons with `aria-label` |
| Auto-playing animation | Respect `prefers-reduced-motion` media query |
| Non-dismissible modal | Always support Escape key to close |
| `aria-hidden="true"` on focusable elements | Remove from tab order or remove `aria-hidden` |
| Missing `lang` attribute on `<html>` | Set `<html lang="en">` (or appropriate language code) |
| Autoplaying video/audio with sound | Require user interaction to start, or mute by default with controls |
| Tiny tap targets on mobile | Minimum 44x44 CSS pixels for touch targets |
| Using `title` attribute as primary label | `title` is unreliable; use `aria-label` or visible `<label>` |
| Links that say "click here" or "read more" | Descriptive link text: "Read the accessibility guide" |
| Missing form error summary | On submit failure, show summary of all errors at top of form |
---
## Quick Reference: Testing a New Component
Before marking any component as complete, verify:
1. Can you reach and operate it using only a keyboard?
2. Does it have a visible focus indicator?
3. Does it announce correctly in a screen reader?
4. Does it meet color contrast ratios?
5. Does it work at 200% zoom?
6. Does it respect `prefers-reduced-motion`?
7. Does it pass `jest-axe` / axe-core automated checks?
8. Does it have appropriate semantic HTML or ARIA roles?
9. Are all images, icons, and media labeled?
10. Can it be operated with one hand on mobile (44x44px touch targets)?
@@ -0,0 +1,545 @@
# Animation Playbook
Deep-dive reference for animation patterns. The main SKILL.md references these techniques
but does not include the full detail needed for implementation.
---
## 1. Easing Curve Library
The built-in CSS keywords (`ease`, `ease-in`, `ease-out`, `ease-in-out`) produce weak,
generic motion. Define custom curves as CSS custom properties so every animation in the
project shares the same vocabulary.
```css
:root {
/* Strong ease-out — the default for UI interactions (enter, appear, respond) */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Strong ease-in-out — on-screen movement and morphing transitions */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* iOS-like drawer curve — slide-up sheets, bottom drawers */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Snappy — fast micro-interactions, toggles, checkboxes */
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
/* Emphasized deceleration — large surface transitions, page-level changes */
--ease-decel: cubic-bezier(0, 0, 0.2, 1);
}
```
### When to use which
| Curve | Use case |
| ---------------- | ---------------------------------------------- |
| `--ease-out` | Elements entering the viewport, appearing |
| `--ease-in-out` | Elements morphing shape, moving across screen |
| `--ease-drawer` | Sheets, drawers, panels sliding into view |
| `--ease-snappy` | Micro-interactions: toggles, checks, switches |
| `--ease-decel` | Large page transitions, route changes |
| `linear` | Constant-rate motion only: progress bars, spin |
Never use `ease-in` alone for UI elements — it makes things feel sluggish at the start.
Reserve `linear` for continuous motion (loading spinners, progress indicators) where
deceleration would look wrong.
**Resources**: [easing.dev](https://easing.dev), [easings.co](https://easings.co)
for visual curve comparison and copying.
---
## 2. Spring Animations
Springs are physics-based. They do not have a fixed duration — they simulate mass,
stiffness, and damping. This makes them ideal for anything interactive.
### When to use springs instead of easing curves
- Drag interactions (the element should follow the finger naturally)
- Elements that feel "alive" (cards, floating actions, avatars)
- Gestures that can be interrupted mid-animation
- Mouse-tracking interactions (cursor followers, magnetic buttons)
### Apple-style spring (duration + bounce)
```js
// Framer Motion / Motion One
animate(element, { x: 100 }, {
type: "spring",
duration: 0.5,
bounce: 0.2
})
```
This is the simpler API. `duration` controls overall timing, `bounce` controls overshoot.
### Traditional physics spring (mass + stiffness + damping)
```js
animate(element, { x: 100 }, {
type: "spring",
mass: 1,
stiffness: 100,
damping: 10
})
```
More control, but harder to tune. Start with mass=1 and adjust stiffness/damping.
### Guidelines
- Keep bounce subtle: **0.1 to 0.3** for most UI. Higher values feel toy-like.
- Avoid bounce entirely for actions that need to feel decisive (confirms, deletes).
- Springs **maintain velocity when interrupted** — if you change the target mid-animation,
the element smoothly redirects. Keyframe animations restart from scratch.
- Use `useSpring` (or equivalent) for mouse-tracking: it makes cursor followers feel
natural instead of artificial. The lag is intentional and pleasant.
- For lists, spring each item separately so they can settle independently.
---
## 3. clip-path Animation Patterns
`clip-path` is one of the most underused animation tools. It lets you reveal, hide,
and transition content without layout shifts.
### Inset shape basics
```css
/* Full visibility */
clip-path: inset(0 0 0 0);
/* Clipped from bottom — only top portion visible */
clip-path: inset(0 0 50% 0);
/* Fully hidden — clipped from all sides */
clip-path: inset(50% 50% 50% 50%);
/* With border-radius */
clip-path: inset(10px round 8px);
```
The values are `inset(top right bottom left)` — how far each edge clips inward.
### Pattern: Tabs with perfect color transitions
Duplicate the entire tab list. Place one copy on top of the other. The bottom copy has
inactive styles; the top copy has active styles. Animate `clip-path: inset(...)` on the
top copy to reveal only the active tab region. The color transition is instantaneous and
pixel-perfect — no fade needed.
```css
.tabs-active-overlay {
clip-path: inset(0 calc(100% - var(--tab-right)) 0 var(--tab-left));
transition: clip-path 300ms var(--ease-out);
}
```
### Pattern: Hold-to-delete
Overlay a colored fill on the button. On `:active`, animate `clip-path` from
`inset(0 100% 0 0)` to `inset(0 0 0 0)` over 2 seconds with `linear` timing (the user
needs to see constant progress). On release, snap back with `200ms ease-out`.
```css
.delete-btn::after {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms var(--ease-out);
}
.delete-btn:active::after {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear;
}
```
### Pattern: Image reveals on scroll
Start with `clip-path: inset(0 0 100% 0)` (image hidden, clipped from bottom).
Use IntersectionObserver to detect viewport entry, then animate to `inset(0 0 0 0)`.
```js
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.clipPath = 'inset(0 0 0 0)';
}
});
}, { threshold: 0.1 });
```
### Pattern: Comparison sliders
Overlay two images. Clip the top image by the drag position:
`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`. Update `--pos` on pointer move.
---
## 4. Gesture Design
Gestures are the hardest animation category because they involve real-time user input
and require physics-aware feedback.
### Momentum-based dismissal
Calculate velocity during drag:
```js
const velocity = distance / elapsed; // px per ms
if (velocity > 0.11) {
dismiss(); // Fast enough — dismiss regardless of distance
} else if (Math.abs(offset) > threshold) {
dismiss(); // Far enough — dismiss regardless of speed
} else {
snapBack(); // Neither fast nor far — return to origin
}
```
The velocity threshold (0.11 px/ms) matters more than distance. A quick flick should
dismiss even from a small offset.
### Damping at boundaries
When the user drags past a natural boundary (e.g., top of a scroll view), apply
increasing resistance:
```js
function dampedOffset(raw, boundary) {
const overflow = raw - boundary;
// Logarithmic damping — diminishing returns
return boundary + Math.log(1 + Math.abs(overflow)) * 30 * Math.sign(overflow);
}
```
This produces the rubber-band effect. The element still moves, but progressively less.
### Pointer capture
Once a drag begins, call `element.setPointerCapture(event.pointerId)`. This ensures
all subsequent pointer events route to this element even if the pointer leaves its
bounds. Release on `pointerup`.
### Multi-touch protection
Track only the first pointer. If a second finger touches during a drag, ignore it:
```js
let activePointerId = null;
element.addEventListener('pointerdown', (e) => {
if (activePointerId !== null) return; // Already tracking
activePointerId = e.pointerId;
element.setPointerCapture(e.pointerId);
});
```
### Friction instead of hard stops
Never hard-clamp position. Always allow movement with increasing resistance. Hard stops
feel broken. Friction feels physical.
---
## 5. Stagger Patterns
Staggering creates a sense of flow by delaying each item slightly.
### CSS implementation
```css
.stagger-item {
opacity: 0;
transform: translateY(8px);
animation: stagger-in 400ms var(--ease-out) forwards;
}
@keyframes stagger-in {
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 40ms; }
.stagger-item:nth-child(3) { animation-delay: 80ms; }
.stagger-item:nth-child(4) { animation-delay: 120ms; }
.stagger-item:nth-child(5) { animation-delay: 160ms; }
```
Or with a custom property:
```css
.stagger-item {
animation-delay: calc(var(--index) * 40ms);
}
```
Set `--index` via `style` attribute in markup or JS.
### Guidelines
- **30-80ms** per step is the sweet spot. Under 30ms looks simultaneous. Over 80ms
feels sluggish.
- Break content into **semantic chunks** — stagger cards, not individual lines of text.
- **Never block interaction** during stagger animations. All items should be clickable
immediately, even if not yet visible.
- Cap the total stagger time. For a list of 20 items, stagger the first 5-6 and let the
rest appear together.
- Stagger on initial load only. Re-renders should not re-stagger.
---
## 6. Exit Animation Patterns
Exits are often neglected. They deserve as much care as entries.
### Principles
- **Exits should be faster than enters.** If enter is 400ms, exit should be 200-250ms.
- **Use small fixed translateY** (8-12px) instead of full-height slides. Large movements
during exit draw too much attention away from what remains.
- **Opacity + scale combination** works better than opacity alone for removal. A slight
`scale(0.96)` during fade-out makes it feel more physical.
- **Asymmetric timing is intentional.** A hold-to-delete might take 2 seconds (deliberate),
but the actual removal should be 200ms (snappy). The weight is in the decision, not
the consequence.
### Exit with height collapse
When removing an item from a list, animate both the content (opacity + translate) and
the container height. The content fades first, then the gap closes:
```css
.item-exiting {
opacity: 0;
transform: translateY(-8px);
transition: opacity 150ms var(--ease-out),
transform 150ms var(--ease-out);
}
.item-exiting-collapse {
height: 0;
margin: 0;
padding: 0;
transition: height 200ms var(--ease-out) 100ms, /* delayed start */
margin 200ms var(--ease-out) 100ms,
padding 200ms var(--ease-out) 100ms;
}
```
### Tuning
There is no formula for the right opacity/height/transform combination. Adjust until it
feels right. Test by performing the action 10 times quickly — if anything feels off on
repetition, it needs work.
---
## 7. Performance Rules
Animation jank is unacceptable. These rules keep animations at 60fps.
### The compositing-only rule
Only animate properties that skip layout and paint:
- `transform` (translate, scale, rotate)
- `opacity`
- `filter` (with caveats — see below)
Everything else triggers layout recalculation (width, height, margin, padding, top, left)
or paint (background-color, box-shadow, border). Both are expensive.
### CSS vs JavaScript animations
- **CSS animations and transitions** run off the main thread on the compositor. Use them
for predetermined animations (hover effects, enter/exit, state changes).
- **Framer Motion `x`/`y` props are NOT hardware-accelerated.** They animate inline
styles, which run on the main thread. Use the full transform string or CSS-based
approaches for performance-critical animations.
- **CSS variables on parent elements** cause expensive style recalculation when updated.
If animating a CSS variable, update the `transform` property directly instead.
### Web Animations API (WAAPI)
For programmatic animations that need CSS-level performance:
```js
element.animate(
[
{ transform: 'translateY(20px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
],
{ duration: 400, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
);
```
WAAPI runs on the compositor like CSS animations but is controlled from JavaScript.
### Blur and filter performance
- Keep `blur()` under **20px**, especially on Safari where large blurs are expensive.
- `backdrop-filter: blur()` is even more expensive — use sparingly.
- Prefer pre-blurred images over real-time blur when possible.
### will-change
- Only use `will-change` for `transform`, `opacity`, or `filter`.
- **Never** use `will-change: all` — it promotes every property and wastes GPU memory.
- Add `will-change` only when you observe first-frame stutter on an animation. It is a
last resort, not a default.
- Remove `will-change` after the animation completes if the element is long-lived.
### transition: all is banned
```css
/* Bad — animates every property change, including ones you did not intend */
transition: all 200ms ease;
/* Good — explicit about what animates */
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
```
`transition: all` causes unexpected animations when other properties change and makes
debugging difficult.
---
## 8. The Sonner Principles
Sonner (the toast library) demonstrates principles that apply broadly to dynamic UI
components.
### Good defaults matter more than options
If you need 12 configuration props to make a component feel right, the defaults are
wrong. The component should feel right out of the box.
### Use transitions, not keyframes, for dynamic UI
Toasts are added rapidly and unpredictably. Keyframe animations have fixed timelines
that cannot adapt to rapid state changes. CSS transitions respond to the current state
and interpolate naturally.
### Handle edge cases invisibly
- Pause toast timers when the browser tab is hidden (the user should not miss toasts).
- When a toast is dismissed from the middle of a stack, the remaining toasts should
fill the gap smoothly.
- When multiple toasts arrive simultaneously, batch the visual update.
### Match motion personality to component personality
A success toast can be slightly bouncy. An error toast should be direct and firm.
A loading toast should feel steady and patient. The animation communicates as much as
the content.
---
## 9. @starting-style for Modern CSS Enter Animations
`@starting-style` defines the initial style of an element when it first renders.
Combined with transitions, it creates enter animations in pure CSS — no JavaScript
`useEffect` + `mounted` state needed.
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}
```
When the `.toast` element is inserted into the DOM, the browser starts from the
`@starting-style` values and transitions to the normal values.
### Works with display: none toggling
```css
.dialog {
display: block;
opacity: 1;
transition: opacity 300ms var(--ease-out), display 300ms allow-discrete;
@starting-style {
opacity: 0;
}
}
.dialog[hidden] {
display: none;
opacity: 0;
}
```
The `allow-discrete` keyword lets `display` participate in the transition timeline.
### Fallback for older browsers
When `@starting-style` is not supported, fall back to a `data-mounted` attribute pattern:
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
}
.toast:not([data-mounted]) {
opacity: 0;
transform: translateY(100%);
}
```
Add `data-mounted` via JavaScript after a single `requestAnimationFrame`.
---
## 10. Debug Techniques
### Slow motion testing
Increase animation duration by 2-5x during development. At normal speed, problems are
invisible. At 5x, you see every hitch, wrong easing, and misaligned property.
```css
:root {
--debug-speed: 1; /* Change to 5 for slow-mo */
}
.animated {
transition-duration: calc(200ms * var(--debug-speed));
}
```
### Chrome DevTools Animations panel
Open DevTools > More Tools > Animations. This panel shows:
- A timeline of all running animations
- Frame-by-frame scrubbing
- Easing curve visualization
- Duration and delay for each animation
Use the playback speed controls (25%, 10%) for detailed inspection.
### Real device testing
Touch interactions feel completely different on a real phone versus a trackpad simulator.
Always test gestures, drag interactions, and spring animations on physical devices.
### Fresh eyes check
Review animations with fresh eyes the next day. What felt right at 11pm during
development often feels too fast, too slow, or too dramatic the next morning.
### The checklist
Before shipping any animation, verify:
- Smooth color transitions (no banding or flashing)?
- Correct easing curve for the interaction type?
- Right `transform-origin` (elements scaling/rotating from the expected point)?
- All animated properties in sync (opacity and transform finishing together)?
- No layout shift during the animation?
- Works with `prefers-reduced-motion: reduce`?
- Performs at 60fps on a mid-range device?
@@ -0,0 +1,604 @@
# Component Implementation Patterns
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
---
## 1. shadcn/ui Setup
```bash
npx shadcn@latest init
npx shadcn@latest add button input form card dialog select sheet toast
```
Key concepts:
- **Not an npm package** -- components are copied into your project. You own the code and can modify it freely.
- Built on **Radix UI** primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
- Styled with **Tailwind CSS** utilities -- no CSS-in-JS runtime.
- Required dependencies:
- `class-variance-authority` (CVA) -- variant management
- `clsx` -- conditional class joining
- `tailwind-merge` -- deduplicates conflicting Tailwind classes
- `lucide-react` -- icon library
- `tailwindcss-animate` -- animation utilities
The `cn()` utility combines `clsx` and `tailwind-merge`:
```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
---
## 2. CSS Variables for Theming (HSL Format)
shadcn uses HSL values without the `hsl()` wrapper so Tailwind can apply opacity modifiers:
```css
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... remaining dark overrides */
}
}
```
Usage in `tailwind.config.ts`:
```ts
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// ...
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
}
```
---
## 3. Button Patterns
Use CVA to define variants declaratively:
```tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
```
Design rules:
- **Press feedback**: `active:scale-[0.97]` gives tactile response without layout shift.
- **Focus ring**: Always visible via `focus-visible:ring-2`. Never use `outline: none` without a replacement.
- **Loading state**: Disable the button and show a spinner inline.
```tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
)
)
```
---
## 4. Form Patterns (React Hook Form + Zod)
Schema-first validation keeps validation logic co-located and type-safe:
```tsx
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(2).max(50),
})
type FormValues = z.infer<typeof formSchema>
```
The shadcn Form components wire React Hook Form to accessible markup:
```tsx
function SignUpForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "", name: "" },
mode: "onBlur", // validate on blur, not keystroke
})
function onSubmit(values: FormValues) {
// handle submission
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ...more fields */}
<Button type="submit" isLoading={form.formState.isSubmitting}>
Sign Up
</Button>
</form>
</Form>
)
}
```
Accessibility rules:
- `FormMessage` renders error text with `aria-describedby` linked to the input.
- Inputs get `aria-invalid="true"` when in error state automatically.
- Mark required fields with `aria-required="true"`.
- Validate on **blur**, not on every keystroke -- reduces noise and respects user flow.
- Use **progressive disclosure** for complex forms: show additional fields only when relevant.
---
## 5. Card Patterns
```tsx
import {
Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter,
} from "@/components/ui/card"
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
<CardHeader>
<CardTitle>Project Settings</CardTitle>
<CardDescription>Manage your project configuration.</CardDescription>
</CardHeader>
<CardContent>
{/* form fields or content */}
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</CardFooter>
</Card>
```
Design rules:
- **Concentric border radius**: Outer radius = inner radius + padding. If inner elements have `rounded-md` (6px) and padding is 16px, outer card should be `rounded-xl` (12px) or greater.
- **Layered shadows**: Use multiple shadow values for natural depth -- `shadow-sm` at rest, `shadow-lg` on hover.
- **Hover lift**: Subtle `translateY(-2px)` on hover, never more than 4px.
- Use semantic color tokens (`bg-card`, `text-card-foreground`) so cards adapt to theme changes.
---
## 6. Dialog (Modal) Patterns
```tsx
import {
Dialog, DialogTrigger, DialogContent,
DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Make changes to your profile here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* form content */}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
Accessibility and interaction rules (handled by Radix):
- **Focus trap**: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
- **ESC to close**: Always. No exceptions.
- **Click outside overlay**: Closes the dialog by default.
- `aria-modal="true"` is set automatically.
- `aria-labelledby` points to `DialogTitle`, `aria-describedby` points to `DialogDescription`.
- **Restore focus**: When dialog closes, focus returns to the trigger element.
- **Animation origin**: `transform-origin: center` -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
---
## 7. Select/Dropdown Patterns
```tsx
import {
Select, SelectTrigger, SelectValue,
SelectContent, SelectItem, SelectGroup, SelectLabel,
} from "@/components/ui/select"
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
Interaction rules:
- **Keyboard navigation**: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
- ARIA: `aria-haspopup="listbox"` on trigger, `aria-expanded` toggles with open state.
- **Transform origin**: Popover should animate from the trigger position (origin-aware), not from center.
- **Tooltip delay skip**: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
---
## 8. Sheet (Slide-over) Patterns
```tsx
import {
Sheet, SheetTrigger, SheetContent,
SheetHeader, SheetTitle, SheetDescription,
SheetFooter, SheetClose,
} from "@/components/ui/sheet"
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open Menu</Button>
</SheetTrigger>
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Browse sections of the app.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-2 py-4">
{/* nav links */}
</nav>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
```
Use cases:
- **Mobile navigation**: Slide from left with full-height overlay.
- **Detail panels**: Slide from right to show item details without leaving the list view.
- **Filters**: Slide from bottom on mobile for filter controls.
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
---
## 9. Toast/Notification Patterns
Using the shadcn Toast (or Sonner for a lighter API):
```tsx
// With shadcn toast
import { useToast } from "@/components/ui/use-toast"
function SaveButton() {
const { toast } = useToast()
return (
<Button
onClick={() => {
toast({
title: "Changes saved",
description: "Your settings have been updated.",
})
}}
>
Save
</Button>
)
}
// With Sonner (simpler API)
import { toast } from "sonner"
toast.success("Changes saved")
toast.error("Something went wrong")
toast.promise(saveSettings(), {
loading: "Saving...",
success: "Settings saved",
error: "Could not save",
})
```
Design and accessibility rules:
- **Auto-dismiss**: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
- `aria-live="polite"` -- screen readers announce without stealing focus.
- **CSS transitions, not keyframes** -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
- **Pause timers** when the browser tab is hidden (`document.visibilityState`).
- **Swipe to dismiss**: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
---
## 10. Table Patterns
```tsx
import {
Table, TableHeader, TableBody, TableFooter,
TableHead, TableRow, TableCell, TableCaption,
} from "@/components/ui/table"
<div className="overflow-x-auto rounded-md border">
<Table>
<TableCaption>A list of recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">{invoice.id}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
```
Rules:
- **Responsive**: Wrap table in `overflow-x-auto` container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
- **Sortable columns**: Use `aria-sort="ascending"` or `aria-sort="descending"` on the active `TableHead`. Show a visual indicator (chevron icon).
- **Virtualization**: For lists exceeding ~50 items, use `@tanstack/react-virtual` or similar to render only visible rows.
- **Row distinction**: Use zebra striping (`even:bg-muted/50`) or subtle borders between rows. Never rely on color alone.
---
## 11. Chart Integration
When integrating charts (Recharts, Chart.js, or similar):
- **Match chart type to data intent**:
- Trend over time: line chart
- Comparison across categories: bar chart
- Part-of-whole: pie/donut chart
- Distribution: histogram
- Correlation: scatter plot
- **Accessible color palettes**: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
- **Always include a legend** and provide **tooltips on hover/focus** for precise values.
- **Screen reader alternative**: Provide a visually hidden `<table>` with the same data so screen readers can access it.
- **Respect `prefers-reduced-motion`**: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
```tsx
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches
<LineChart data={data}>
<Line
type="monotone"
dataKey="value"
animationDuration={prefersReducedMotion ? 0 : 500}
/>
</LineChart>
```
---
## 12. Server Component Wrapping (Next.js)
Most shadcn/ui components use React state or event handlers and require `"use client"`. Structure your components to keep data fetching in server components:
```tsx
// app/dashboard/page.tsx (Server Component -- no "use client")
import { getProjects } from "@/lib/data"
import { ProjectList } from "./project-list"
export default async function DashboardPage() {
const projects = await getProjects()
return <ProjectList projects={projects} />
}
```
```tsx
// app/dashboard/project-list.tsx (Client Component)
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface ProjectListProps {
projects: { id: string; name: string; status: string }[]
}
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card key={project.id}>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
</CardHeader>
<CardContent>
<p>{project.status}</p>
<Button variant="outline" size="sm">View</Button>
</CardContent>
</Card>
))}
</div>
)
}
```
The pattern: **Server component fetches data, passes to client component as serializable props.** This keeps the client bundle small and data fetching on the server.
---
## 13. CVA (class-variance-authority) Deep Dive
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
```ts
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
```
Key patterns:
- **Compose with `cn()`**: Always wrap CVA output with `cn()` so consumer-passed `className` can override defaults via `tailwind-merge`.
- **Type extraction**: `VariantProps<typeof badgeVariants>` generates the TypeScript type for variant props automatically.
- **Compound variants**: Handle combinations of variant values that need special styling:
```ts
const inputVariants = cva("...", {
variants: {
size: { sm: "...", lg: "..." },
state: { error: "...", success: "..." },
},
compoundVariants: [
{ size: "sm", state: "error", class: "border-2 border-red-500" },
],
})
```
- **Use CVA for any component with visual variants** -- buttons, badges, alerts, inputs, cards. It replaces manual `if/else` class concatenation with a declarative, type-safe API.
@@ -0,0 +1,204 @@
# Pre-Delivery Review Checklist
Extended 30-item checklist for UI implementation quality. Run through this before marking any UI task as complete.
## Typography (6 items)
### 1. Font Smoothing Applied
- **Check**: Root layout has `-webkit-font-smoothing: antialiased`
- **How**: Inspect `<html>` or `<body>` computed styles
- **Failing looks like**: Text appears heavy/blurry on macOS, especially at small sizes
### 2. Headings Use text-wrap: balance
- **Check**: All `<h1>``<h4>` elements have `text-wrap: balance`
- **How**: Resize viewport to trigger wrapping — headings should break evenly
- **Failing looks like**: One long line followed by a single orphan word
### 3. Body Text Uses text-wrap: pretty
- **Check**: Paragraphs and body text use `text-wrap: pretty`
- **How**: Check for orphaned words at the end of paragraphs
- **Failing looks like**: A single short word sitting alone on the last line
### 4. Dynamic Numbers Use tabular-nums
- **Check**: Counters, prices, timers, and data columns have `font-variant-numeric: tabular-nums`
- **How**: Watch numbers update — layout should not shift
- **Failing looks like**: Content jumps horizontally as digits change width
### 5. Line Length Controlled
- **Check**: Body text containers are capped at `max-width: 65ch`
- **How**: Measure character count on a full-width line
- **Failing looks like**: Text stretching edge-to-edge on wide monitors, hard to read
### 6. Type Scale Consistency
- **Check**: All text sizes come from the defined type scale (no arbitrary sizes)
- **How**: Inspect font sizes — they should match scale values (12/14/16/18/24/32/48)
- **Failing looks like**: Random sizes like 15px, 19px, 22px that aren't in the scale
## Color & Theme (5 items)
### 7. Semantic Color Tokens Only
- **Check**: No hardcoded hex/rgb values in component code
- **How**: Search for `#[0-9a-f]` or `rgb(` in component files
- **Failing looks like**: `background: #3b82f6` instead of `bg-primary` or `var(--primary)`
### 8. WCAG AA Contrast Met
- **Check**: Normal text ≥ 4.5:1, large text ≥ 3:1, UI components ≥ 3:1
- **How**: Run axe-core or Chrome DevTools contrast checker
- **Failing looks like**: Light gray text on white background, low-contrast placeholders
### 9. Dark Mode Contrast Verified
- **Check**: Contrast ratios pass in dark mode separately
- **How**: Toggle dark mode, re-run contrast checks
- **Failing looks like**: Passing in light mode but failing in dark (common with desaturated variants)
### 10. Color Not Sole Information Channel
- **Check**: Error, success, warning states use icon + text alongside color
- **How**: View the page in grayscale (browser DevTools → Rendering → Emulate vision deficiency)
- **Failing looks like**: Red border on error field with no icon or text explanation
### 11. Dark Mode Visual Review
- **Check**: All surfaces, borders, shadows, and text are legible in dark mode
- **How**: Toggle dark mode and visually scan every component
- **Failing looks like**: Invisible borders, washed-out shadows, or text-on-background collision
## Layout & Spatial (5 items)
### 12. Concentric Border Radius
- **Check**: Outer radius = inner radius + padding on all nested rounded elements
- **How**: Inspect nested cards, buttons-in-containers, input groups
- **Failing looks like**: Inner and outer corners don't follow the same curvature — looks "off"
| Before | After | Why |
|--------|-------|-----|
| Parent `rounded-lg` (12px), child `rounded-lg` (12px), padding 8px | Parent `rounded-xl` (16px), child `rounded-md` (8px), padding 8px | 8 + 8 = 16 — radii now concentric |
### 13. Spacing Follows Scale
- **Check**: All padding, margin, and gap values are multiples of 4px
- **How**: Inspect spacing values — no 5px, 7px, 13px, 19px etc.
- **Failing looks like**: Inconsistent spacing that makes the layout feel uneven
### 14. Hit Areas Meet Minimum
- **Check**: All interactive elements have at least 44×44px clickable area
- **How**: Use browser DevTools to measure element + padding dimensions
- **Failing looks like**: Tiny icon buttons, close buttons, or links that are hard to tap on mobile
### 15. Shadows Over Borders
- **Check**: Depth is created with layered box-shadows, not solid borders between sections
- **How**: Look for `border: 1px solid` between content sections
- **Failing looks like**: Hard dividing lines instead of natural depth transitions
### 16. Optical Alignment Verified
- **Check**: Icons in buttons, play triangles, and asymmetric elements are optically centered
- **How**: Squint at the element — does it look centered to the eye?
- **Failing looks like**: A play triangle that's geometrically centered but looks shifted left
## Motion & Interaction (7 items)
### 17. Animation Frequency Appropriate
- **Check**: High-frequency actions (keyboard shortcuts, command palette) have NO animation
- **How**: Review the frequency table — occasional actions get standard animation, frequent actions get none
- **Failing looks like**: A command palette with a 300ms open animation that feels sluggish after the 50th use
### 18. No `transition: all`
- **Check**: Every transition specifies exact properties
- **How**: Search for `transition: all` or `transition-property: all`
- **Failing looks like**: Unintended properties animating (color, padding, border) causing jank
### 19. Custom Easing Curves Used
- **Check**: UI animations use custom bezier curves, not built-in `ease`, `ease-in`, `ease-out`
- **How**: Inspect transition/animation easing values
- **Failing looks like**: Animations feel generic and lack punch
### 20. Enter Animations Split and Staggered
- **Check**: Multi-element entrances use 30-80ms stagger between items
- **How**: Watch page load or section reveal — elements should cascade, not appear all at once
- **Failing looks like**: An entire section popping in as one block
### 21. Press Feedback on Buttons
- **Check**: All pressable elements have subtle `scale(0.96-0.97)` on `:active`
- **How**: Click and hold buttons — they should compress slightly
- **Failing looks like**: Clicking a button with zero visual feedback
### 22. prefers-reduced-motion Respected
- **Check**: Animations reduce/simplify when the user has reduced motion enabled
- **How**: Enable reduced motion in OS settings, reload, check all animations
- **Failing looks like**: Full animations playing for users who opted out
### 23. Hover States Gated
- **Check**: Hover animations are behind `@media (hover: hover) and (pointer: fine)`
- **How**: Test on touch device or emulate touch in DevTools
- **Failing looks like**: Hover states triggering on tap on mobile, causing sticky hover effects
## Accessibility (7 items)
### 24. Keyboard Navigation Complete
- **Check**: Every interactive element is reachable and operable with keyboard only
- **How**: Unplug mouse, Tab through entire page, operate every control
- **Failing looks like**: Unreachable buttons, inoperable dropdowns, trapped focus
### 25. Focus Rings Visible
- **Check**: Every focusable element has a visible focus indicator
- **How**: Tab through the page and verify each element shows focus
- **Failing looks like**: `outline: none` with no replacement, invisible focus state
### 26. Semantic HTML Used
- **Check**: `<button>` for actions, `<a>` for links, `<nav>` for navigation, proper heading hierarchy
- **How**: Inspect the DOM — look for `<div onclick>` or `<span>` where buttons should be
- **Failing looks like**: Divs with click handlers instead of buttons, missing landmarks
### 27. ARIA Labels on Icon Buttons
- **Check**: Every icon-only button has `aria-label` describing its action
- **How**: Inspect icon buttons in DevTools or run axe-core
- **Failing looks like**: Screen reader announcing "button" with no context
### 28. Form Errors Accessible
- **Check**: Error messages use `aria-live` or `role="alert"`, linked via `aria-describedby`
- **How**: Submit an invalid form, check screen reader announces errors
- **Failing looks like**: Visual error message that screen reader users never hear
### 29. Images Have Alt Text
- **Check**: Meaningful images have descriptive `alt`, decorative images have `alt=""`
- **How**: Search for `<img>` without `alt` attribute
- **Failing looks like**: Screen reader announcing file names or nothing for important images
### 30. Skip Link Present
- **Check**: First focusable element is "Skip to main content" link
- **How**: Tab once on page load — skip link should appear
- **Failing looks like**: Keyboard users forced to Tab through entire header/nav on every page
## Quick Pass/Fail Summary
Use this table to record results:
| # | Item | Pass | Notes |
|---|------|------|-------|
| 1 | Font smoothing | | |
| 2 | text-wrap: balance | | |
| 3 | text-wrap: pretty | | |
| 4 | tabular-nums | | |
| 5 | Line length | | |
| 6 | Type scale | | |
| 7 | Semantic tokens | | |
| 8 | WCAG contrast | | |
| 9 | Dark mode contrast | | |
| 10 | Color not sole channel | | |
| 11 | Dark mode visual | | |
| 12 | Concentric radius | | |
| 13 | Spacing scale | | |
| 14 | Hit areas | | |
| 15 | Shadows over borders | | |
| 16 | Optical alignment | | |
| 17 | Animation frequency | | |
| 18 | No transition: all | | |
| 19 | Custom easing | | |
| 20 | Staggered enter | | |
| 21 | Press feedback | | |
| 22 | Reduced motion | | |
| 23 | Hover gated | | |
| 24 | Keyboard nav | | |
| 25 | Focus rings | | |
| 26 | Semantic HTML | | |
| 27 | ARIA labels | | |
| 28 | Form errors | | |
| 29 | Alt text | | |
| 30 | Skip link | | |
+4
View File
@@ -10,3 +10,7 @@ reference/
# Scratch # Scratch
*.tmp *.tmp
*.log *.log
# Test run artifacts
WhiteMagicTest/TestResults/
**/TestResults/
+1 -1
View File
@@ -89,7 +89,7 @@ Obey these rules:
4. Keep the build green on the branch. 4. Keep the build green on the branch.
5. Request a review before a merge. 5. Request a review before a merge.
6. Do not merge your own feature without a review. 6. Do not merge your own feature without a review.
7. Merge to `master` only after the review passes. 7. Merge to `develop` only after the review passes.
8. Delete the feature branch after the merge. 8. Delete the feature branch after the merge.
`master` must always build. `master` must always pass the tests. `master` must always build. `master` must always pass the tests.
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 kbe
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+3
View File
@@ -0,0 +1,3 @@
# WhiteMagic
Wite Magic is a C# library to read, write and execute remote code into a target process for analysis, debuging and mod creation.
+333
View File
@@ -0,0 +1,333 @@
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using Iced.Intel;
namespace WhiteMagic.Assembly;
/// <summary>
/// Optional <see cref="IAssembler"/> backend that assembles arbitrary x86/x64 mnemonic
/// text to machine code using the Iced library, and provides full instruction-boundary
/// decoding for detour prologue validation.
/// </summary>
/// <remarks>
/// <para>Iced ships a fluent code assembler (typed method calls) and a decoder, but no
/// text parser. This class bridges Intel-syntax text onto Iced's fluent
/// <see cref="Assembler"/> by reflection: each line's mnemonic selects the matching
/// <see cref="Assembler"/> method and its operands are bound to registers, immediates, or
/// labels. Register and immediate operands and label-relative branches are supported;
/// memory operands (<c>[reg+disp]</c>) are not — a caller needing those should emit bytes
/// directly.</para>
/// <para>This backend is entirely optional. Constructing it is the only thing that pulls
/// Iced into a behavioral path; the default <see cref="StubAssembler"/> never references it.</para>
/// </remarks>
public sealed class IcedAssembler : IAssembler
{
private const int DefaultBitness = 64;
private readonly int _bitness;
// Lowercased register name -> boxed AssemblerRegisterNN value, built once from
// Iced's AssemblerRegisters. Enables binding a text operand like "esp" to a typed
// fluent-API register argument.
private static readonly Dictionary<string, object> Registers = BuildRegisterMap();
/// <summary>Creates an assembler for the given bitness (32 or 64).</summary>
/// <param name="bitness">32 for x86, 64 for x64. Defaults to 64.</param>
public IcedAssembler(int bitness = DefaultBitness)
{
if (bitness != 32 && bitness != 64)
throw new ArgumentOutOfRangeException(nameof(bitness), "Bitness must be 32 or 64.");
_bitness = bitness;
}
/// <inheritdoc />
public byte[] Assemble(string assemblyText, ulong origin = 0)
{
ArgumentNullException.ThrowIfNull(assemblyText);
var assembler = new Assembler(_bitness);
List<(string Mnemonic, string[] Operands)> lines = Tokenize(assemblyText, out var labelNames);
// Pre-create every label so a forward branch can reference it before its definition.
var labels = new Dictionary<string, Label>(StringComparer.OrdinalIgnoreCase);
foreach (string name in labelNames)
labels[name] = assembler.CreateLabel(name);
foreach ((string mnemonic, string[] operands) in lines)
{
// A pure label definition (e.g. "loop:") marks the current position.
if (mnemonic.EndsWith(':'))
{
Label label = labels[mnemonic[..^1]];
assembler.Label(ref label);
continue;
}
EmitInstruction(assembler, mnemonic, operands, labels);
}
var writer = new ByteListCodeWriter();
assembler.Assemble(writer, origin);
return writer.Bytes.ToArray();
}
/// <summary>
/// Computes the number of whole prologue-instruction bytes that must be preserved for
/// a splice of <paramref name="requiredBytes"/> bytes, decoding arbitrary instructions
/// (not just the common prologue shapes the built-in decoder covers). Matches the
/// <c>PrologueLengthResolver</c> delegate so it can be assigned to
/// <see cref="WhiteMagic.Hooking.DetourManager.PrologueLengthResolver"/>.
/// </summary>
/// <exception cref="InvalidOperationException">A prologue byte sequence does not decode
/// to a valid instruction.</exception>
public int GetPrologueLength(byte[] prologue, int requiredBytes, bool is64Bit)
{
ArgumentNullException.ThrowIfNull(prologue);
var reader = new ByteArrayCodeReader(prologue);
var decoder = Decoder.Create(is64Bit ? 64 : 32, reader);
int total = 0;
while (total < requiredBytes)
{
decoder.Decode(out Instruction instruction);
if (instruction.IsInvalid)
{
throw new InvalidOperationException(
"The target prologue contains a byte sequence that does not decode to a valid instruction.");
}
total += instruction.Length;
}
return total;
}
private void EmitInstruction(
Assembler assembler,
string mnemonic,
string[] operandText,
Dictionary<string, Label> labels)
{
object?[] operands = new object?[operandText.Length];
for (int i = 0; i < operandText.Length; i++)
operands[i] = ParseOperand(operandText[i], labels);
// Find the fluent Assembler method whose name equals the mnemonic and whose
// parameters bind to the parsed operands.
foreach (MethodInfo method in typeof(Assembler).GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (!string.Equals(method.Name, mnemonic, StringComparison.OrdinalIgnoreCase))
continue;
ParameterInfo[] parameters = method.GetParameters();
if (parameters.Length != operands.Length)
continue;
if (TryBind(parameters, operands, out object?[]? boundArgs))
{
try
{
method.Invoke(assembler, boundArgs);
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
// Surface the real Iced failure rather than the reflection wrapper.
throw ex.InnerException;
}
return;
}
}
throw new NotSupportedException(
$"Cannot assemble '{mnemonic}{(operandText.Length > 0 ? " " + string.Join(", ", operandText) : "")}': " +
"no matching Iced assembler overload for the given operands (registers, immediates and " +
"labels are supported; memory operands are not).");
}
private static bool TryBind(ParameterInfo[] parameters, object?[] operands, out object?[]? boundArgs)
{
var args = new object?[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
Type paramType = parameters[i].ParameterType;
object? operand = operands[i];
switch (operand)
{
case Immediate imm when IsNumeric(paramType):
// An immediate that overflows this parameter's type means this overload
// is the wrong width; return false so a wider overload can be tried
// instead of crashing the whole assembly.
if (!TryChangeType(imm.Value, paramType, out object? converted))
{
boundArgs = null;
return false;
}
args[i] = converted;
break;
case not null when paramType.IsInstanceOfType(operand):
args[i] = operand;
break;
default:
boundArgs = null;
return false;
}
}
boundArgs = args;
return true;
}
private static object ParseOperand(string text, Dictionary<string, Label> labels)
{
string token = text.Trim();
if (Registers.TryGetValue(token, out object? register))
return register;
if (labels.TryGetValue(token, out Label label))
return label;
if (TryParseImmediate(token, out object? value))
return new Immediate(value!);
throw new NotSupportedException(
$"Unrecognized operand '{token}' (expected a register, an immediate, or a label).");
}
// Parses an immediate as the narrowest of long/ulong that holds it, boxed. Storing the
// widest representation lets TryChangeType later narrow it to whatever integer parameter
// the chosen overload expects — and reject (rather than crash on) values that do not fit.
private static bool TryParseImmediate(string token, out object? value)
{
value = null;
bool negative = token.StartsWith('-');
string body = negative ? token[1..] : token;
if (body.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
if (!ulong.TryParse(body[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong hex))
return false;
value = negative ? -(long)hex : hex;
return true;
}
if (negative)
{
if (!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long signed))
return false;
value = signed;
return true;
}
// Non-negative decimal: prefer long, fall back to ulong for values above long.MaxValue.
if (long.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out long asLong))
value = asLong;
else if (ulong.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong asULong))
value = asULong;
else
return false;
return true;
}
private static bool TryChangeType(object value, Type targetType, out object? result)
{
try
{
result = Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
return true;
}
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
{
result = null;
return false;
}
}
private static bool IsNumeric(Type type) => Type.GetTypeCode(type) is
TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or
TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64;
private static List<(string Mnemonic, string[] Operands)> Tokenize(string text, out List<string> labelNames)
{
var result = new List<(string, string[])>();
labelNames = new List<string>();
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine;
int comment = line.IndexOf(';');
if (comment >= 0)
line = line[..comment];
line = line.Trim();
if (line.Length == 0)
continue;
// A "name:" prefix is a label definition; keep any instruction that follows it
// on the same line as a separate entry.
int colon = line.IndexOf(':');
if (colon >= 0)
{
string labelName = line[..colon].Trim();
labelNames.Add(labelName);
result.Add((labelName + ":", Array.Empty<string>()));
line = line[(colon + 1)..].Trim();
if (line.Length == 0)
continue;
}
int space = line.IndexOfAny([' ', '\t']);
if (space < 0)
{
result.Add((line, Array.Empty<string>()));
continue;
}
string mnemonic = line[..space];
string[] operands = line[(space + 1)..]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
result.Add((mnemonic, operands));
}
return result;
}
private static Dictionary<string, object> BuildRegisterMap()
{
var map = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
foreach (FieldInfo field in typeof(AssemblerRegisters).GetFields(BindingFlags.Public | BindingFlags.Static))
{
object? value = field.GetValue(null);
if (value is not null)
map[field.Name] = value;
}
return map;
}
// A parsed immediate (boxed long or ulong), distinguished from register/label operands
// so binding can narrow it to whichever integer parameter type the chosen overload
// expects — or reject it when it does not fit.
private readonly record struct Immediate(object Value);
private sealed class ByteListCodeWriter : CodeWriter
{
public List<byte> Bytes { get; } = new();
public override void WriteByte(byte value) => Bytes.Add(value);
}
}
+201
View File
@@ -0,0 +1,201 @@
using System.ComponentModel;
using System.Diagnostics;
namespace WhiteMagic.Discovery;
/// <summary>
/// Scans process memory for a byte pattern with an optional wildcard mask.
/// </summary>
public static class PatternScanner
{
/// <summary>
/// Scans a memory range for the first occurrence of a pattern with an optional wildcard mask.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="start">The starting address of the scan range.</param>
/// <param name="end">The ending address (exclusive) of the scan range.</param>
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="pattern"/> is empty, or <paramref name="mask"/> length does not match
/// <paramref name="pattern"/> length, or <paramref name="mask"/> contains invalid characters.
/// </exception>
/// <exception cref="Win32Exception">Memory read fails with an unexpected error.</exception>
public static IntPtr Find(
MemoryBase memory,
byte[] pattern,
string? mask,
IntPtr start,
IntPtr end)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentNullException.ThrowIfNull(pattern);
if (pattern.Length == 0)
throw new ArgumentException("Pattern cannot be empty.", nameof(pattern));
// Validate and normalize mask
if (mask is not null)
{
if (mask.Length != pattern.Length)
throw new ArgumentException(
$"Mask length ({mask.Length}) must match pattern length ({pattern.Length}).",
nameof(mask));
foreach (char c in mask)
{
if (c != 'x' && c != '?')
throw new ArgumentException(
$"Mask may contain only 'x' (match) or '?' (wildcard); found '{c}'.",
nameof(mask));
}
}
// Null mask means treat all bytes as 'x' (exact match)
mask ??= new string('x', pattern.Length);
// Scan range in reasonable chunks (64 KB to avoid massive single reads)
const int chunkSize = 64 * 1024;
int patternLen = pattern.Length;
long rangeSize = (long)end - (long)start;
if (rangeSize <= 0)
return IntPtr.Zero;
// For small ranges, read all at once
if (rangeSize <= chunkSize)
{
byte[] buffer = memory.ReadBytes(start, (int)rangeSize);
return FindInBuffer(buffer, pattern, mask, start);
}
// For larger ranges, scan in chunks
long remaining = rangeSize;
IntPtr current = start;
while (remaining > 0)
{
int toRead = (int)Math.Min(chunkSize, remaining);
byte[] chunk = memory.ReadBytes(current, toRead);
// Empty read means we hit an unmapped region or read failure
if (chunk.Length == 0)
{
// Skip past this unreadable region
current += toRead;
remaining -= toRead;
continue;
}
// Search in this chunk
IntPtr found = FindInBuffer(chunk, pattern, mask, current);
if (found != IntPtr.Zero)
return found;
// Move to next chunk, leaving room for pattern that might straddle boundary
// We advance by (chunkSize - patternLen + 1) to ensure we don't miss matches
int advance = toRead - patternLen + 1;
if (advance <= 0)
advance = toRead;
current += advance;
remaining -= advance;
}
return IntPtr.Zero;
}
/// <summary>
/// Scans a module's memory region (from its base address through its size) for a pattern.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="module">The module to scan.</param>
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
public static IntPtr FindInModule(
MemoryBase memory,
byte[] pattern,
string? mask,
ProcessModule module)
{
ArgumentNullException.ThrowIfNull(module);
IntPtr start = module.BaseAddress;
IntPtr end = start + module.ModuleMemorySize;
return Find(memory, pattern, mask, start, end);
}
/// <summary>
/// Scans multiple modules for a pattern, returning the first match found.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="modules">The modules to scan, in order.</param>
/// <returns>The address of the first match, or <see cref="IntPtr.Zero"/> if not found.</returns>
public static IntPtr FindInModules(
MemoryBase memory,
byte[] pattern,
string? mask,
IEnumerable<ProcessModule> modules)
{
ArgumentNullException.ThrowIfNull(modules);
foreach (var module in modules)
{
IntPtr found = FindInModule(memory, pattern, mask, module);
if (found != IntPtr.Zero)
return found;
}
return IntPtr.Zero;
}
/// <summary>
/// Searches a buffer for the first pattern match given a mask.
/// </summary>
private static IntPtr FindInBuffer(
byte[] buffer,
byte[] pattern,
string mask,
IntPtr bufferBase)
{
if (buffer.Length < pattern.Length)
return IntPtr.Zero;
int patternLen = pattern.Length;
int maxOffset = buffer.Length - patternLen;
for (int offset = 0; offset <= maxOffset; offset++)
{
bool match = true;
for (int i = 0; i < patternLen; i++)
{
// Only compare if mask says 'x' (exact match required)
if (mask[i] == 'x' && buffer[offset + i] != pattern[i])
{
match = false;
break;
}
}
if (match)
return bufferBase + offset;
}
return IntPtr.Zero;
}
}
+180
View File
@@ -0,0 +1,180 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace WhiteMagic.Discovery;
/// <summary>
/// Caches pattern scan results to avoid repeated scans of the same memory range.
/// </summary>
public sealed class PatternScannerCache
{
private readonly ConcurrentDictionary<CacheKey, IntPtr> _cache = new();
private readonly MemoryBase _memory;
/// <summary>
/// Creates a new cache for the given memory accessor.
/// </summary>
/// <param name="memory">The memory accessor to scan.</param>
public PatternScannerCache(MemoryBase memory)
{
ArgumentNullException.ThrowIfNull(memory);
_memory = memory;
}
/// <summary>
/// Finds a pattern, returning a cached result if available.
/// </summary>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="start">The starting address of the scan range.</param>
/// <param name="end">The ending address (exclusive) of the scan range.</param>
/// <returns>
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
/// </returns>
public IntPtr FindCached(
byte[] pattern,
string? mask,
IntPtr start,
IntPtr end)
{
var key = new CacheKey(pattern, mask, start, end);
// Try to get from cache first
if (_cache.TryGetValue(key, out IntPtr cached))
return cached;
// Not in cache, perform the scan
IntPtr found = PatternScanner.Find(_memory, pattern, mask, start, end);
// Cache the result (even if Zero)
_cache[key] = found;
return found;
}
/// <summary>
/// Finds a pattern within a module, returning a cached result if available.
/// </summary>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="module">The module to scan.</param>
/// <returns>
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
/// </returns>
public IntPtr FindInModuleCached(
byte[] pattern,
string? mask,
ProcessModule module)
{
ArgumentNullException.ThrowIfNull(module);
IntPtr start = module.BaseAddress;
IntPtr end = start + module.ModuleMemorySize;
return FindCached(pattern, mask, start, end);
}
/// <summary>
/// Finds a pattern across multiple modules, returning a cached result if available.
/// </summary>
/// <param name="pattern">The byte pattern to search for.</param>
/// <param name="mask">
/// A mask string where 'x' means "match this byte exactly" and '?' means "wildcard".
/// If <see langword="null"/>, all bytes are treated as 'x' (exact match).
/// </param>
/// <param name="modules">The modules to scan, in order.</param>
/// <returns>
/// The address of the first match from cache or memory, or <see cref="IntPtr.Zero"/> if not found.
/// </returns>
public IntPtr FindInModulesCached(
byte[] pattern,
string? mask,
IEnumerable<ProcessModule> modules)
{
// For multiple modules, we use a combined key (all modules hashed together)
// This is less granular but still useful for repeated queries
var moduleList = modules.ToList();
var key = new CacheKey(pattern, mask, IntPtr.Zero, IntPtr.Zero, Modules: moduleList);
if (_cache.TryGetValue(key, out IntPtr cached))
return cached;
IntPtr found = PatternScanner.FindInModules(_memory, pattern, mask, moduleList);
_cache[key] = found;
return found;
}
/// <summary>
/// Clears all cached scan results.
/// </summary>
public void Clear()
{
_cache.Clear();
}
/// <summary>
/// Cache key combining pattern, mask, and address range.
/// </summary>
private sealed record CacheKey(
byte[] Pattern,
string? Mask,
IntPtr Start,
IntPtr End,
IReadOnlyList<ProcessModule>? Modules = null) : IEquatable<CacheKey>
{
public bool Equals(CacheKey? other)
{
if (other is null)
return false;
if (Start != other.Start || End != other.End || Mask != other.Mask)
return false;
if (!Pattern.AsSpan().SequenceEqual(other.Pattern))
return false;
if (Modules is null)
return other.Modules is null;
if (other.Modules is null || Modules.Count != other.Modules.Count)
return false;
for (int i = 0; i < Modules.Count; i++)
{
if (Modules[i].BaseAddress != other.Modules[i].BaseAddress)
return false;
}
return true;
}
// Override GetHashCode to hash the contents, not references
public override int GetHashCode()
{
var hash = new HashCode();
// Hash pattern bytes
foreach (byte b in Pattern)
hash.Add(b);
// Hash mask
hash.Add(Mask?.GetHashCode() ?? 0);
// Hash address range
hash.Add(Start.GetHashCode());
hash.Add(End.GetHashCode());
// Hash modules if present (by base address)
if (Modules is not null)
{
foreach (var m in Modules)
hash.Add(m.BaseAddress.GetHashCode());
}
return hash.ToHashCode();
}
}
}
+396
View File
@@ -0,0 +1,396 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic.Native;
namespace WhiteMagic.Discovery;
/// <summary>
/// Represents a section in a PE file.
/// </summary>
public readonly record struct PeSection
{
/// <summary>
/// The 8-byte null-terminated section name (e.g., ".text", ".data").
/// </summary>
public string Name { get; init; }
/// <summary>
/// The virtual address of the section when loaded into memory (RVA).
/// </summary>
public IntPtr VirtualAddress { get; init; }
/// <summary>
/// The size of the section in memory.
/// </summary>
public int VirtualSize { get; init; }
}
/// <summary>
/// Parses PE headers to expose section information and entry points.
/// </summary>
public sealed class PeHeaderParser
{
private readonly MemoryBase _memory;
private readonly IntPtr _baseAddress;
/// <summary>
/// Creates a new PE header parser for the module at the specified base address.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="baseAddress">The base address of the module.</param>
public PeHeaderParser(MemoryBase memory, IntPtr baseAddress)
{
ArgumentNullException.ThrowIfNull(memory);
if (baseAddress == IntPtr.Zero)
throw new ArgumentException("Base address cannot be zero.", nameof(baseAddress));
_memory = memory;
_baseAddress = baseAddress;
}
/// <summary>
/// Gets the entry point RVA (Relative Virtual Address) of the PE file.
/// </summary>
/// <returns>The entry point RVA, or <see cref="IntPtr.Zero"/> if unavailable.</returns>
/// <exception cref="Win32Exception">Reading memory fails.</exception>
/// <exception cref="InvalidDataException">The PE headers are invalid.</exception>
public IntPtr EntryPoint
{
get
{
// Read and parse PE headers
var (optionalHeader, _) = ParseOptionalHeader();
if (optionalHeader is null)
return IntPtr.Zero;
// Entry point RVA is at offset 16 in the optional header (both PE32 and PE32+)
return (IntPtr)BitConverter.ToUInt32(optionalHeader.AsSpan(16, 4));
}
}
/// <summary>
/// Enumerates all sections in the PE file.
/// </summary>
/// <returns>An enumerable of PE sections.</returns>
/// <exception cref="Win32Exception">Reading memory fails.</exception>
/// <exception cref="InvalidDataException">The PE headers are invalid.</exception>
public IEnumerable<PeSection> Sections
{
get
{
var (optionalHeader, sectionHeaders) = ParseOptionalHeaderAndSectionHeaders();
if (sectionHeaders is null || sectionHeaders.Length == 0)
yield break;
foreach (var sectionHeader in sectionHeaders)
{
// Parse section name (8-byte, null-terminated)
string name = ParseSectionName(sectionHeader);
// VirtualAddress and VirtualSize
uint virtualAddress = BitConverter.ToUInt32(sectionHeader, 12);
uint virtualSize = BitConverter.ToUInt32(sectionHeader, 8);
yield return new PeSection
{
Name = name,
VirtualAddress = (IntPtr)virtualAddress,
VirtualSize = (int)virtualSize
};
}
}
}
/// <summary>
/// Resolves an exported function's absolute address by name, following export
/// forwarders (e.g. <c>kernel32!HeapAlloc</c> → <c>NTDLL.RtlAllocateHeap</c>) into
/// other modules loaded in the same target process.
/// </summary>
/// <param name="functionName">The exported symbol name (case-sensitive, as stored
/// in the export name table).</param>
/// <returns>The absolute address of the export in the target process.</returns>
/// <remarks>
/// Forwarders are resolved by locating the target module in the process's loaded-module
/// list. API-set forwarders (virtual <c>api-ms-win-*</c> / <c>ext-ms-*</c> names) are NOT
/// supported: those are not real loaded modules, so resolution through the module list is
/// impossible without parsing the API-set schema — such a forwarder throws
/// <see cref="NotSupportedException"/>. On modern Windows many system-DLL exports forward
/// through API sets; resolve those via the OS loader (<c>GetProcAddress</c>) instead.
/// Ordinal forwarders (<c>Module.#N</c>) are likewise unsupported.
/// </remarks>
/// <exception cref="InvalidOperationException">The export is not present.</exception>
/// <exception cref="NotSupportedException">The export forwards to an ordinal or to a
/// module (such as an API set) that is not resolvable from the target's module list.</exception>
/// <exception cref="InvalidDataException">The PE export data is malformed.</exception>
public IntPtr GetExportAddress(string functionName)
{
ArgumentException.ThrowIfNullOrEmpty(functionName);
return ResolveExport(functionName, 0);
}
// Maximum forwarder hops before giving up, to bound pathological chains.
private const int MaxForwarderDepth = 16;
private IntPtr ResolveExport(string functionName, int depth)
{
if (depth > MaxForwarderDepth)
throw new InvalidDataException($"Export forwarder chain for '{functionName}' is too deep.");
var (optionalHeader, _) = ParseOptionalHeader();
if (optionalHeader is null || optionalHeader.Length < 2)
throw new InvalidDataException("Optional header unavailable.");
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit). Data directories start at a
// different offset in each: 96 for PE32, 112 for PE32+. The export table is
// directory index 0, so its 8-byte entry sits at that offset.
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
bool pe32Plus = magic == 0x20b;
int exportDirOffset = pe32Plus ? 112 : 96;
if (optionalHeader.Length < exportDirOffset + 8)
throw new InvalidDataException("Optional header does not contain the export data directory.");
uint exportRva = BitConverter.ToUInt32(optionalHeader, exportDirOffset);
uint exportSize = BitConverter.ToUInt32(optionalHeader, exportDirOffset + 4);
if (exportRva == 0 || exportSize == 0)
throw new InvalidOperationException("Module has no export table.");
// IMAGE_EXPORT_DIRECTORY is 40 bytes.
byte[] dir = _memory.ReadBytes(_baseAddress + (nint)exportRva, 40);
if (dir.Length < 40)
throw new InvalidDataException("Failed to read the export directory.");
uint numberOfFunctions = BitConverter.ToUInt32(dir, 20);
uint numberOfNames = BitConverter.ToUInt32(dir, 24);
uint addressOfFunctions = BitConverter.ToUInt32(dir, 28);
uint addressOfNames = BitConverter.ToUInt32(dir, 32);
uint addressOfNameOrdinals = BitConverter.ToUInt32(dir, 36);
// Guard against corrupt counts before allocating arrays sized from them.
if (numberOfNames > 0x10000 || numberOfFunctions > 0x10000)
throw new InvalidDataException("Export table entry count is out of range.");
if (numberOfNames == 0)
throw new InvalidOperationException($"Export '{functionName}' not found (module exports no names).");
byte[] nameRvas = _memory.ReadBytes(_baseAddress + (nint)addressOfNames, checked((int)(numberOfNames * 4)));
byte[] nameOrdinals = _memory.ReadBytes(_baseAddress + (nint)addressOfNameOrdinals, checked((int)(numberOfNames * 2)));
if (nameRvas.Length < numberOfNames * 4 || nameOrdinals.Length < numberOfNames * 2)
throw new InvalidDataException("Failed to read the export name tables.");
int nameIndex = -1;
for (int i = 0; i < numberOfNames; i++)
{
uint nameRva = BitConverter.ToUInt32(nameRvas, i * 4);
string name = _memory.ReadString(_baseAddress + (nint)nameRva, Encoding.ASCII, 512);
if (string.Equals(name, functionName, StringComparison.Ordinal))
{
nameIndex = i;
break;
}
}
if (nameIndex < 0)
throw new InvalidOperationException($"Export '{functionName}' not found in module.");
ushort ordinal = BitConverter.ToUInt16(nameOrdinals, nameIndex * 2);
if (ordinal >= numberOfFunctions)
throw new InvalidDataException("Export name ordinal is out of range.");
byte[] funcRvaBytes = _memory.ReadBytes(
_baseAddress + (nint)(addressOfFunctions + (uint)ordinal * 4u), 4);
if (funcRvaBytes.Length < 4)
throw new InvalidDataException("Failed to read the export address table entry.");
uint funcRva = BitConverter.ToUInt32(funcRvaBytes, 0);
if (funcRva == 0)
throw new InvalidOperationException($"Export '{functionName}' has no address.");
// A function RVA that lands inside the export directory region is not code but a
// null-terminated "Module.Function" forwarder string.
if (funcRva >= exportRva && funcRva < exportRva + exportSize)
{
string forwarder = _memory.ReadString(_baseAddress + (nint)funcRva, Encoding.ASCII, 512);
return ResolveForwarder(forwarder, depth);
}
return _baseAddress + (nint)funcRva;
}
private IntPtr ResolveForwarder(string forwarder, int depth)
{
// A forwarder is "Module.Function"; the module name carries no extension, so the
// FIRST dot is the boundary. Splitting on the last dot would misparse export names
// that themselves contain a dot (e.g. some C++/managed exports).
int dot = forwarder.IndexOf('.');
if (dot <= 0 || dot >= forwarder.Length - 1)
throw new InvalidDataException($"Malformed export forwarder string '{forwarder}'.");
string moduleName = forwarder[..dot];
string target = forwarder[(dot + 1)..];
if (target.StartsWith('#'))
{
throw new NotSupportedException(
$"Ordinal export forwarders are not supported (forwarder '{forwarder}').");
}
IntPtr targetBase = RemoteModule.ResolveBase(_memory.ProcessId, moduleName);
if (targetBase == IntPtr.Zero)
{
throw new NotSupportedException(
$"Export forwarder target module '{moduleName}' is not loaded in the target " +
$"process, or is an unresolvable API set (forwarder '{forwarder}').");
}
return new PeHeaderParser(_memory, targetBase).ResolveExport(target, depth + 1);
}
/// <summary>
/// Parses the DOS header, PE signature, and optional header.
/// </summary>
private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeaderAndSectionHeaders()
{
// Read DOS header (first 64 bytes)
byte[] dosHeader = _memory.ReadBytes(_baseAddress, 64);
if (dosHeader.Length < 64)
throw new InvalidDataException("Failed to read DOS header.");
// Verify DOS signature "MZ"
if (dosHeader[0] != 0x4D || dosHeader[1] != 0x5A)
throw new InvalidDataException("Invalid DOS signature (not a PE file).");
// PE header offset is at 0x3C in DOS header
int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
if (peOffset < 0 || peOffset > 0x1000) // Sanity check
throw new InvalidDataException($"Invalid PE offset: {peOffset}");
// Read PE signature (4 bytes: "PE\0\0")
IntPtr peSigAddr = _baseAddress + peOffset;
byte[] peSignature = _memory.ReadBytes(peSigAddr, 4);
if (peSignature.Length < 4)
throw new InvalidDataException("Failed to read PE signature.");
if (peSignature[0] != 0x50 || peSignature[1] != 0x45 ||
peSignature[2] != 0x00 || peSignature[3] != 0x00)
throw new InvalidDataException("Invalid PE signature.");
// COFF header follows PE signature (20 bytes)
IntPtr coffAddr = peSigAddr + 4;
byte[] coffHeader = _memory.ReadBytes(coffAddr, 20);
if (coffHeader.Length < 20)
throw new InvalidDataException("Failed to read COFF header.");
// SizeOfOptionalHeader is at offset 16 in COFF header
ushort sizeOfOptionalHeader = BitConverter.ToUInt16(coffHeader, 16);
// NumberOfSections is at offset 2 in COFF header
ushort numberOfSections = BitConverter.ToUInt16(coffHeader, 2);
if (numberOfSections == 0 || numberOfSections > 96)
return (null, null); // No sections or unreasonable number
// Optional header follows COFF header
IntPtr optAddr = coffAddr + 20;
byte[] optionalHeader = _memory.ReadBytes(optAddr, sizeOfOptionalHeader);
if (optionalHeader.Length < sizeOfOptionalHeader)
throw new InvalidDataException("Failed to read optional header.");
// Section headers follow optional header
IntPtr sectionAddr = optAddr + sizeOfOptionalHeader;
int sectionHeaderSize = 40; // IMAGE_SECTION_HEADER is 40 bytes
byte[][] sectionHeaders = new byte[numberOfSections][];
for (int i = 0; i < numberOfSections; i++)
{
byte[] section = _memory.ReadBytes(sectionAddr + (i * sectionHeaderSize), sectionHeaderSize);
if (section.Length < sectionHeaderSize)
throw new InvalidDataException($"Failed to read section header {i}.");
sectionHeaders[i] = section;
}
return (optionalHeader, sectionHeaders);
}
/// <summary>
/// Parses just the optional header (for entry point).
/// </summary>
private (byte[]? OptionalHeader, byte[][]? SectionHeaders) ParseOptionalHeader()
{
// Read DOS header (first 64 bytes)
byte[] dosHeader = _memory.ReadBytes(_baseAddress, 64);
if (dosHeader.Length < 64)
throw new InvalidDataException("Failed to read DOS header.");
// Verify DOS signature "MZ"
if (dosHeader[0] != 0x4D || dosHeader[1] != 0x5A)
throw new InvalidDataException("Invalid DOS signature (not a PE file).");
// PE header offset is at 0x3C in DOS header
int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
if (peOffset < 0 || peOffset > 0x1000) // Sanity check
throw new InvalidDataException($"Invalid PE offset: {peOffset}");
// Read PE signature (4 bytes: "PE\0\0")
IntPtr peSigAddr = _baseAddress + peOffset;
byte[] peSignature = _memory.ReadBytes(peSigAddr, 4);
if (peSignature.Length < 4)
throw new InvalidDataException("Failed to read PE signature.");
if (peSignature[0] != 0x50 || peSignature[1] != 0x45 ||
peSignature[2] != 0x00 || peSignature[3] != 0x00)
throw new InvalidDataException("Invalid PE signature.");
// COFF header follows PE signature (20 bytes)
IntPtr coffAddr = peSigAddr + 4;
byte[] coffHeader = _memory.ReadBytes(coffAddr, 20);
if (coffHeader.Length < 20)
throw new InvalidDataException("Failed to read COFF header.");
// SizeOfOptionalHeader is at offset 16 in COFF header
ushort sizeOfOptionalHeader = BitConverter.ToUInt16(coffHeader, 16);
// Optional header follows COFF header
IntPtr optAddr = coffAddr + 20;
byte[] optionalHeader = _memory.ReadBytes(optAddr, sizeOfOptionalHeader);
if (optionalHeader.Length < sizeOfOptionalHeader)
throw new InvalidDataException("Failed to read optional header.");
return (optionalHeader, null);
}
/// <summary>
/// Determines whether the PE file is PE32+ (64-bit) or PE32 (32-bit).
/// </summary>
private bool IsPe32Plus()
{
var (optionalHeader, _) = ParseOptionalHeaderAndSectionHeaders();
if (optionalHeader is null || optionalHeader.Length < 2)
throw new InvalidDataException("Optional header too short.");
// Magic is at offset 0 in optional header
// 0x10b = PE32 (32-bit), 0x20b = PE32+ (64-bit)
ushort magic = BitConverter.ToUInt16(optionalHeader, 0);
return magic == 0x20b;
}
/// <summary>
/// Parses a null-terminated 8-byte section name.
/// </summary>
private static string ParseSectionName(byte[] sectionHeader)
{
// Name is first 8 bytes
var nameBytes = new Span<byte>(sectionHeader, 0, 8);
// Find null terminator
int len = 0;
for (; len < 8; len++)
{
if (nameBytes[len] == 0)
break;
}
return System.Text.Encoding.ASCII.GetString(nameBytes[..len]);
}
}
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.Runtime.InteropServices;
namespace WhiteMagic.Execution;
/// <summary>
/// Direct native-to-managed delegate calls for the in-process scenario.
/// This is the third execution tier: no remote thread is created; the call runs
/// synchronously on the current thread.
/// </summary>
/// <remarks>
/// <para>
/// This class assumes the WhiteMagic consumer has already arranged to run inside the
/// target process. Bootstrapping the managed loader (e.g., via a CLR host or native
/// shim) that places WhiteMagic into a foreign process is a separate follow-up change
/// and is not implemented here.</para>
/// </remarks>
public sealed class InProcessInvoker
{
private readonly MemoryBase _memory;
/// <summary>Creates an invoker bound to the supplied memory reader.</summary>
public InProcessInvoker(MemoryBase memory)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
}
/// <summary>
/// Creates a managed delegate of type <typeparamref name="TDelegate"/> that calls
/// the native function at <paramref name="address"/>.
/// </summary>
/// <typeparam name="TDelegate">A delegate type whose signature matches the native function.</typeparam>
public TDelegate CreateFunction<TDelegate>(IntPtr address)
where TDelegate : Delegate
{
if (address == IntPtr.Zero)
{
throw new ArgumentException(
"Function address cannot be zero.", nameof(address));
}
return Marshal.GetDelegateForFunctionPointer<TDelegate>(address);
}
/// <summary>
/// Reads the vtable pointer stored at the start of an object in memory.
/// </summary>
/// <param name="objectAddress">The address of the object instance.</param>
/// <returns>The address of the vtable.</returns>
public IntPtr ReadVTable(IntPtr objectAddress)
{
return _memory.Read<IntPtr>(objectAddress);
}
/// <summary>
/// Reads a function pointer from a vtable by index.
/// </summary>
/// <param name="vTableAddress">The address of the vtable.</param>
/// <param name="methodIndex">The zero-based index of the method slot.</param>
/// <returns>The address in the specified vtable slot.</returns>
public IntPtr ReadVTableFunction(IntPtr vTableAddress, int methodIndex)
{
ArgumentOutOfRangeException.ThrowIfNegative(methodIndex);
int pointerSize = _memory.Is64Bit ? 8 : 4;
IntPtr slotAddress = vTableAddress + (methodIndex * pointerSize);
return _memory.Read<IntPtr>(slotAddress);
}
/// <summary>
/// Convenience helper that reads an object's vtable and returns the function
/// address at the requested method index.
/// </summary>
public IntPtr GetObjectVTableFunction(IntPtr objectAddress, int methodIndex)
{
IntPtr vTable = ReadVTable(objectAddress);
return ReadVTableFunction(vTable, methodIndex);
}
}
+191
View File
@@ -0,0 +1,191 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic.Hooking;
namespace WhiteMagic.Execution;
/// <summary>
/// A crash-safe work queue drained on the target's own thread via a detour on a
/// per-frame function. Callers queue work and receive the result (or exception)
/// on their own thread through a completion handle.
/// </summary>
/// <remarks>
/// The pump assumes the frame function is parameterless and returns an <see cref="int"/>.
/// This matches common per-frame functions such as D3D9 <c>EndScene</c>.
/// </remarks>
public sealed class MainThreadPump : IDisposable
{
private readonly DetourManager _detours;
private readonly IntPtr _frameAddress;
private readonly ConcurrentQueue<WorkItem> _queue = new();
private readonly object _gate = new();
private Detour? _detour;
private bool _installed;
private bool _disposed;
/// <summary>
/// Creates a pump that will hook the frame function at <paramref name="frameAddress"/>.
/// </summary>
public MainThreadPump(DetourManager detours, IntPtr frameAddress)
{
_detours = detours;
_frameAddress = frameAddress;
}
/// <summary>Returns <see langword="true"/> after the frame hook has been applied.</summary>
public bool IsInstalled => _installed;
/// <summary>Installs the frame-function detour.</summary>
public void Install()
{
if (_installed)
return;
_detour = _detours.Create("MainThreadPump", _frameAddress, (FrameDelegate)PumpHook);
_detour.Apply();
_installed = true;
}
/// <summary>
/// Queues work to run on the hooked thread and blocks until it completes.
/// </summary>
public TResult Execute<TResult>(Func<TResult> work)
{
var tcs = new TaskCompletionSource<object?>();
lock (_gate)
{
if (_disposed)
ThrowDisposed();
if (!_installed)
throw new InvalidOperationException("The main-thread pump is not installed. Call Install() first.");
_queue.Enqueue(new WorkItem(() => work()!, tcs));
}
object? result = tcs.Task.GetAwaiter().GetResult();
return (TResult)result!;
}
/// <summary>
/// Queues work to run on the hooked thread and returns a <see cref="Task{TResult}"/>.
/// </summary>
public Task<TResult> ExecuteAsync<TResult>(Func<TResult> work)
{
var tcs = new TaskCompletionSource<TResult>();
lock (_gate)
{
if (_disposed)
ThrowDisposed();
if (!_installed)
throw new InvalidOperationException("The main-thread pump is not installed. Call Install() first.");
object? Box() => work()!;
_queue.Enqueue(new WorkItem(Box, r => tcs.TrySetResult((TResult)r!), ex => tcs.TrySetException(ex)));
}
return tcs.Task;
}
/// <summary>Removes the frame-function detour if it is installed.</summary>
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
if (_installed && _detour is not null)
_detour.Remove();
_installed = false;
}
// Fault any caller still blocked on queued work so Execute cannot hang forever.
while (_queue.TryDequeue(out WorkItem? item))
{
item.SetException(new ObjectDisposedException(nameof(MainThreadPump)));
}
}
private static void ThrowDisposed()
=> throw new ObjectDisposedException(nameof(MainThreadPump));
private int PumpHook()
{
try
{
while (_queue.TryDequeue(out WorkItem? item))
{
try
{
object? result = item.Work();
item.SetResult(result);
}
catch (Exception ex)
{
item.SetException(ex);
}
}
// Call the original frame function so rendering/game logic continues.
return _detour is null ? 0 : (int?)_detour.CallOriginal() ?? 0;
}
catch
{
// Never let an exception escape back into the native frame caller.
return 0;
}
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int FrameDelegate();
private sealed class WorkItem
{
private readonly Action<object?>? _setResult;
private readonly Action<Exception>? _setException;
public WorkItem(Func<object?> work, Action<object?> setResult, Action<Exception> setException)
{
Work = work;
_setResult = setResult;
_setException = setException;
}
public WorkItem(Func<object?> work, TaskCompletionSource<object?> tcs)
{
Work = work;
_setResult = r => tcs.TrySetResult(r);
_setException = ex => tcs.TrySetException(ex);
}
public Func<object?> Work { get; }
public void SetResult(object? result)
{
try
{
_setResult?.Invoke(result);
}
catch (InvalidOperationException)
{
// Already completed, e.g. concurrent Dispose/PumpHook race.
}
}
public void SetException(Exception exception)
{
try
{
_setException?.Invoke(exception);
}
catch (InvalidOperationException)
{
// Already completed, e.g. concurrent Dispose/PumpHook race.
}
}
}
}
@@ -0,0 +1,514 @@
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 (long delta = 0; delta <= (long)0x7FFF; delta++)
{
long signedOffset = delta * (long)AllocationGranularity;
// Try above, then below the target. Keep the original address as the first attempt.
for (int sign = 0; sign < 2; sign++)
{
if (delta == 0 && sign != 0)
continue;
long offset = sign == 0 ? signedOffset : -signedOffset;
nuint candidate = (nuint)((long)aligned + offset);
// Avoid underflow to zero on below-target search.
if (offset < 0 && candidate >= aligned)
continue;
IntPtr result = NativeMethods.VirtualAllocEx(
handle,
(IntPtr)(nint)candidate,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (result != IntPtr.Zero)
{
long distance = (long)(nuint)(nint)result - (long)(nuint)(nint)preferredAddress;
if (distance >= int.MinValue && distance <= int.MaxValue)
return result;
// The allocator gave us a nearby candidate but on the wrong side
// of the 2 GiB boundary; treat it as unusable and keep searching.
NativeMethods.VirtualFreeEx(handle, result, 0, MemoryFreeType.Release);
}
}
}
return IntPtr.Zero;
}
}
+37 -23
View File
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Process = System.Diagnostics.Process;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using WhiteMagic.Native; using WhiteMagic.Native;
@@ -13,6 +14,8 @@ public sealed class ExternalReader : MemoryBase
{ {
private readonly SafeMemoryHandle _handle; private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase; private readonly IntPtr _imageBase;
private readonly bool _is64Bit;
private readonly int _processId;
private bool _disposed; private bool _disposed;
/// <summary> /// <summary>
@@ -31,18 +34,39 @@ public sealed class ExternalReader : MemoryBase
/// <param name="process">The target process.</param> /// <param name="process">The target process.</param>
/// <param name="desiredAccess">The access rights to request. Defaults to /// <param name="desiredAccess">The access rights to request. Defaults to
/// <see cref="DefaultAccess"/>.</param> /// <see cref="DefaultAccess"/>.</param>
public ExternalReader(Process process, ProcessAccess desiredAccess = DefaultAccess) public ExternalReader(System.Diagnostics.Process process, ProcessAccess desiredAccess = DefaultAccess)
{ {
_handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id); _processId = process.Id;
const ProcessAccess queryAccess = ProcessAccess.QueryInformation | ProcessAccess.QueryLimitedInformation;
if ((desiredAccess & queryAccess) == 0)
{
throw new ArgumentException(
"ExternalReader requires ProcessAccess.QueryInformation or ProcessAccess.QueryLimitedInformation to determine target bitness.",
nameof(desiredAccess));
}
_handle = NativeMethods.OpenProcess(desiredAccess, false, _processId);
if (_handle.IsInvalid) if (_handle.IsInvalid)
{ {
int error = Marshal.GetLastPInvokeError(); int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException( throw new InvalidOperationException(
$"OpenProcess failed for PID {process.Id}: error {error}"); $"OpenProcess failed for PID {_processId}: error {error}");
} }
// Derive target bitness. A 64-bit host sees a 32-bit target as WOW64.
// A 32-bit host can only open 32-bit targets.
if (!NativeMethods.IsWow64Process(_handle, out bool wow64))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"IsWow64Process failed for PID {_processId}: error {error}.");
}
_is64Bit = Environment.Is64BitProcess && !wow64;
// Process.MainModule throws Win32Exception for a bitness-mismatched or protected // Process.MainModule throws Win32Exception for a bitness-mismatched or protected
// target; a missing image base must not sink the whole reader. // target. A missing image base must not sink the whole reader — callers can still
// use absolute addresses when ImageBase is unknown.
try try
{ {
_imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero; _imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero;
@@ -59,24 +83,19 @@ public sealed class ExternalReader : MemoryBase
/// <inheritdoc /> /// <inheritdoc />
public override SafeMemoryHandle Handle => _handle; public override SafeMemoryHandle Handle => _handle;
/// <inheritdoc />
public override bool Is64Bit => _is64Bit;
/// <inheritdoc />
public override int ProcessId => _processId;
/// <inheritdoc /> /// <inheritdoc />
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{ {
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
byte[] buffer = new byte[count]; return RpmHelper.ReadBytes(_handle, address, count);
if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
return [];
}
if ((int)bytesRead != count)
{
Array.Resize(ref buffer, (int)bytesRead);
}
return buffer;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -85,12 +104,7 @@ public sealed class ExternalReader : MemoryBase
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written)) return RpmHelper.WriteBytes(_handle, address, bytes);
{
return 0;
}
return (int)written;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -99,7 +113,7 @@ public sealed class ExternalReader : MemoryBase
if (!_disposed) if (!_disposed)
{ {
_disposed = true; _disposed = true;
_handle.Dispose(); base.Dispose();
} }
} }
} }
+291
View File
@@ -0,0 +1,291 @@
using System;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Hooking;
/// <summary>
/// A single reversible inline detour. Replaces the start of a native function
/// with a jump to a managed hook delegate, preserves the overwritten bytes in a
/// remote trampoline, and exposes the trampoline through <see cref="CallOriginal"/>.
/// </summary>
/// <remarks>
/// Only supported in-process. The detour uses a 5-byte relative <c>jmp</c> on x86
/// targets and a 14-byte RIP-relative absolute <c>jmp</c> on x64 targets.
/// </remarks>
public sealed class Detour : IDisposable
{
private readonly MemoryBase _memory;
private readonly PrologueLengthResolver _prologueLength;
/// <summary>The unique name of this detour.</summary>
public string Name { get; }
/// <summary>The target native function address.</summary>
public IntPtr Target { get; }
/// <summary>The managed hook delegate that the detour invokes.</summary>
public Delegate Hook { get; }
/// <summary>The bytes overwritten at <see cref="Target"/>.</summary>
public byte[] OverwrittenBytes { get; private set; } = Array.Empty<byte>();
/// <summary>
/// The allocated trampoline that executes the original prologue and then jumps
/// back into the original function.
/// </summary>
public IntPtr Trampoline { get; private set; }
/// <summary>
/// A delegate wrapping <see cref="Trampoline"/> with the same type as <see cref="Hook"/>.
/// </summary>
public Delegate? Original { get; private set; }
/// <summary><see langword="true"/> while the detour bytes are live at <see cref="Target"/>.</summary>
public bool IsApplied { get; private set; }
internal Detour(
MemoryBase memory,
string name,
IntPtr target,
Delegate hook,
PrologueLengthResolver prologueLength)
{
ArgumentNullException.ThrowIfNull(hook);
ArgumentNullException.ThrowIfNull(prologueLength);
_memory = memory;
Name = name;
Target = target;
Hook = hook;
_prologueLength = prologueLength;
}
/// <summary>
/// Installs the detour after validating that the required overwrite covers whole
/// prologue instructions.
/// </summary>
/// <exception cref="InvalidOperationException">The prologue cannot be safely spliced.</exception>
public void Apply()
{
if (IsApplied)
return;
int pointerSize = _memory.Is64Bit ? 8 : 4;
int detourLength = pointerSize == 8 ? 14 : 5;
// The prologue decoder may need to see bytes past the minimum detour length
// to identify the whole instruction that crosses the splice point. Prefer a
// generous read, but if the target sits near an unmapped page boundary, read
// only up to that boundary so ReadProcessMemory does not fail entirely.
int preferredBuffer = detourLength + 16;
int pageSize = Environment.SystemPageSize;
int pageOffset = (int)(Target.ToInt64() & (pageSize - 1));
int bytesToPageBoundary = pageSize - pageOffset;
int readSize = Math.Min(preferredBuffer, bytesToPageBoundary);
byte[] prologue = _memory.ReadBytes(Target, readSize);
if (prologue.Length < detourLength)
{
throw new InvalidOperationException(
"Could not read enough bytes from the target function to install a detour.");
}
int preserveLength = _prologueLength(prologue, detourLength, _memory.Is64Bit);
OverwrittenBytes = new byte[preserveLength];
Buffer.BlockCopy(prologue, 0, OverwrittenBytes, 0, preserveLength);
IntPtr hookAddress = Marshal.GetFunctionPointerForDelegate(Hook);
byte[] hookJump = pointerSize == 8
? BuildAbsoluteJump(hookAddress)
: BuildRelativeJump(Target, hookAddress);
// Allocate and build the trampoline before touching the target.
int returnJumpSize = pointerSize == 8 ? 14 : 5;
int trampolineSize = preserveLength + returnJumpSize;
IntPtr trampoline = NativeMethods.VirtualAllocEx(
_memory.Handle,
IntPtr.Zero,
trampolineSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (trampoline == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"Failed to allocate detour trampoline: error {error}");
}
try
{
var trampolineBytes = new byte[trampolineSize];
OverwrittenBytes.CopyTo(trampolineBytes, 0);
byte[] returnJump = pointerSize == 8
? BuildAbsoluteJump(Target + preserveLength)
: BuildRelativeJump(trampoline + preserveLength, Target + preserveLength);
returnJump.CopyTo(trampolineBytes, preserveLength);
int written = _memory.WriteBytes(trampoline, trampolineBytes);
if (written != trampolineSize)
{
throw new InvalidOperationException(
"Failed to write the detour trampoline into the target process.");
}
// Make the target page writable if necessary, then write the detour jump,
// restoring the original protection regardless of success or failure.
if (!NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
preserveLength,
MemoryProtectionType.ExecuteReadWrite,
out MemoryProtectionType oldProtect))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"Failed to change target memory protection: error {error}");
}
try
{
written = _memory.WriteBytes(Target, hookJump);
if (written != hookJump.Length)
throw new InvalidOperationException("Failed to write detour jump to target.");
Trampoline = trampoline;
Original = Marshal.GetDelegateForFunctionPointer(Trampoline, Hook.GetType());
IsApplied = true;
}
finally
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
preserveLength,
oldProtect,
out _);
}
}
catch
{
NativeMethods.VirtualFreeEx(
_memory.Handle,
trampoline,
0,
MemoryFreeType.Release);
throw;
}
}
/// <summary>Restores the original bytes and releases the trampoline.</summary>
public void Remove()
{
if (!IsApplied)
return;
if (OverwrittenBytes.Length > 0 && Target != IntPtr.Zero)
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
OverwrittenBytes.Length,
MemoryProtectionType.ExecuteReadWrite,
out MemoryProtectionType oldProtect);
try
{
_memory.WriteBytes(Target, OverwrittenBytes);
}
finally
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Target,
OverwrittenBytes.Length,
oldProtect,
out _);
}
}
if (Trampoline != IntPtr.Zero)
{
NativeMethods.VirtualFreeEx(
_memory.Handle,
Trampoline,
0,
MemoryFreeType.Release);
}
Trampoline = IntPtr.Zero;
Original = null;
OverwrittenBytes = Array.Empty<byte>();
IsApplied = false;
}
/// <summary>
/// Invokes the original function through the trampoline. Pass the same arguments
/// that the native signature expects; the return value is boxed.
/// </summary>
public object? CallOriginal(params object?[] args)
{
if (Original is null)
{
throw new InvalidOperationException(
"The detour is not applied; there is no original trampoline to call.");
}
return Original.DynamicInvoke(args);
}
/// <inheritdoc />
public void Dispose()
{
Remove();
}
private static byte[] BuildRelativeJump(IntPtr source, IntPtr destination)
{
byte[] bytes = new byte[5];
bytes[0] = 0xE9;
long distance = (long)destination - ((long)source + 5);
if (distance < int.MinValue || distance > int.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(destination),
"Relative jump distance exceeds the 2 GiB range of an E8/E9 encoding.");
}
uint rel = (uint)distance;
bytes[1] = (byte)rel;
bytes[2] = (byte)(rel >> 8);
bytes[3] = (byte)(rel >> 16);
bytes[4] = (byte)(rel >> 24);
return bytes;
}
private static byte[] BuildAbsoluteJump(IntPtr destination)
{
// jmp [rip+0] followed by the absolute target address.
byte[] bytes = new byte[14];
bytes[0] = 0xFF;
bytes[1] = 0x25;
bytes[2] = 0x00;
bytes[3] = 0x00;
bytes[4] = 0x00;
bytes[5] = 0x00;
long addr = (long)destination;
bytes[6] = (byte)addr;
bytes[7] = (byte)(addr >> 8);
bytes[8] = (byte)(addr >> 16);
bytes[9] = (byte)(addr >> 24);
bytes[10] = (byte)(addr >> 32);
bytes[11] = (byte)(addr >> 40);
bytes[12] = (byte)(addr >> 48);
bytes[13] = (byte)(addr >> 56);
return bytes;
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
namespace WhiteMagic.Hooking;
/// <summary>
/// Manages named inline detours against a <see cref="MemoryBase"/>.
/// Detours only work when operating in-process; applying a detour to an
/// external target will fail because the hook delegate lives in the host process.
/// </summary>
public sealed class DetourManager
{
private readonly MemoryBase _memory;
private readonly Dictionary<string, Detour> _detours = new();
/// <summary>Creates a detour manager bound to the supplied memory reader.</summary>
public DetourManager(MemoryBase memory)
{
_memory = memory;
}
/// <summary>
/// Resolves how many whole prologue-instruction bytes a splice must preserve. Defaults
/// to the built-in <see cref="PrologueDecoder"/>, which covers only the common prologue
/// shapes and rejects anything else. Assign <c>new IcedAssembler().GetPrologueLength</c>
/// to validate arbitrary prologues via the optional Iced disassembler.
/// </summary>
public PrologueLengthResolver PrologueLengthResolver { get; set; } =
PrologueDecoder.GetWholeInstructionLength;
/// <summary>
/// Creates a new detour and registers it with the manager.
/// The <paramref name="hook"/> delegate's type must match the native signature of
/// <paramref name="target"/>.
/// </summary>
public Detour Create(string name, IntPtr target, Delegate hook)
{
var detour = new Detour(_memory, name, target, hook, PrologueLengthResolver);
_detours[name] = detour;
return detour;
}
/// <summary>Looks up a detour by name.</summary>
public Detour? this[string name]
{
get
{
_detours.TryGetValue(name, out Detour? detour);
return detour;
}
}
/// <summary>All detours registered in this manager.</summary>
public IEnumerable<Detour> All => _detours.Values;
/// <summary>Removes every applied detour, restoring original bytes.</summary>
public void RemoveAll()
{
foreach (Detour detour in _detours.Values)
{
detour.Remove();
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Hooking;
/// <summary>
/// A single reversible byte patch. Captures the original bytes when applied,
/// restores them when removed, and reports its state by comparing live memory.
/// </summary>
public sealed class Patch : IDisposable
{
private readonly MemoryBase _memory;
/// <summary>The unique name of this patch.</summary>
public string Name { get; }
/// <summary>The address the patch overwrites.</summary>
public IntPtr Address { get; }
/// <summary>The bytes written by the patch.</summary>
public byte[] PatchBytes { get; }
/// <summary>The bytes captured before the patch was applied.</summary>
public byte[]? OriginalBytes { get; private set; }
/// <summary>
/// <see langword="true"/> when the live bytes at <see cref="Address"/> match
/// <see cref="PatchBytes"/>.
/// </summary>
public bool IsApplied
{
get
{
byte[] current = _memory.ReadBytes(Address, PatchBytes.Length);
return current.SequenceEqual(PatchBytes);
}
}
internal Patch(MemoryBase memory, string name, IntPtr address, byte[] patchBytes)
{
ArgumentNullException.ThrowIfNull(patchBytes);
_memory = memory;
Name = name;
Address = address;
PatchBytes = patchBytes;
}
/// <summary>Captures the original bytes and writes the patch bytes.</summary>
public void Apply()
{
if (IsApplied)
return;
OriginalBytes = _memory.ReadBytes(Address, PatchBytes.Length);
if (!NativeMethods.VirtualProtectEx(
_memory.Handle,
Address,
PatchBytes.Length,
MemoryProtectionType.ExecuteReadWrite,
out MemoryProtectionType oldProtect))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Failed to change target memory protection: error {error}");
}
try
{
_memory.WriteBytes(Address, PatchBytes);
}
finally
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Address,
PatchBytes.Length,
oldProtect,
out _);
}
}
/// <summary>Restores the original bytes if they were captured.</summary>
public void Remove()
{
if (OriginalBytes is null)
return;
if (!NativeMethods.VirtualProtectEx(
_memory.Handle,
Address,
OriginalBytes.Length,
MemoryProtectionType.ExecuteReadWrite,
out MemoryProtectionType oldProtect))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Failed to change target memory protection: error {error}");
}
try
{
_memory.WriteBytes(Address, OriginalBytes);
}
finally
{
NativeMethods.VirtualProtectEx(
_memory.Handle,
Address,
OriginalBytes.Length,
oldProtect,
out _);
}
OriginalBytes = null;
}
/// <inheritdoc />
public void Dispose()
{
Remove();
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace WhiteMagic.Hooking;
/// <summary>
/// Manages named, reversible byte patches against a <see cref="MemoryBase"/>.
/// Every patch records the bytes it replaced and can restore them later.
/// </summary>
public sealed class PatchManager
{
private readonly MemoryBase _memory;
private readonly Dictionary<string, Patch> _patches = new();
/// <summary>Creates a patch manager bound to the supplied memory reader.</summary>
public PatchManager(MemoryBase memory)
{
_memory = memory;
}
/// <summary>Creates a new patch and registers it with the manager.</summary>
public Patch Create(string name, IntPtr address, byte[] patchBytes)
{
var patch = new Patch(_memory, name, address, patchBytes);
_patches[name] = patch;
return patch;
}
/// <summary>Looks up a patch by name.</summary>
public Patch? this[string name]
{
get
{
_patches.TryGetValue(name, out Patch? patch);
return patch;
}
}
/// <summary>All patches registered in this manager.</summary>
public IEnumerable<Patch> All => _patches.Values;
/// <summary>Removes every applied patch.</summary>
public void RestoreAll()
{
foreach (Patch patch in _patches.Values)
{
patch.Remove();
}
}
}
+99
View File
@@ -0,0 +1,99 @@
using System;
namespace WhiteMagic.Hooking;
/// <summary>
/// Resolves how many whole prologue-instruction bytes must be preserved to splice
/// <paramref name="requiredBytes"/> bytes at a target. The built-in
/// <see cref="PrologueDecoder.GetWholeInstructionLength"/> satisfies this delegate, as
/// does <c>IcedAssembler.GetPrologueLength</c> for full instruction coverage.
/// </summary>
public delegate int PrologueLengthResolver(byte[] prologue, int requiredBytes, bool is64Bit);
/// <summary>
/// Minimal instruction-length decoder for common x86/x64 prologue shapes.
/// The set is intentionally small: any opcode outside the covered set is rejected
/// rather than guessed. Full arbitrary-prologue validation is provided by the
/// optional Iced backend (Phase 8).
/// </summary>
/// <remarks>
/// Covered shapes:
/// <list type="bullet">
/// <item><c>push reg</c>: 0x50-0x57 (1 byte), including REX-prefixed forms.</item>
/// <item><c>push ebp/rbp</c>: 0x55 (1 byte).</item>
/// <item><c>mov edi, edi</c>: 8B FF (2 bytes).</item>
/// <item><c>mov ebp/rbp, esp/rsp</c>: 8B EC / 48 8B EC (2/3 bytes).</item>
/// <item><c>sub esp/rsp, imm8</c>: 83 EC imm8 / 48 83 EC imm8 (3/4 bytes).</item>
/// <item><c>sub esp/rsp, imm32</c>: 81 EC imm32 / 48 81 EC imm32 (6/7 bytes).</item>
/// </list>
/// </remarks>
internal static class PrologueDecoder
{
/// <summary>
/// Returns the length of the first instruction in <paramref name="bytes"/>
/// if it matches a covered shape; otherwise returns -1.
/// </summary>
public static int GetInstructionLength(ReadOnlySpan<byte> bytes, bool is64Bit)
{
if (bytes.Length == 0)
return 0;
int i = 0;
if (is64Bit && bytes[i] >= 0x40 && bytes[i] <= 0x4F)
{
// REX prefix.
i++;
if (bytes.Length <= i)
return -1;
}
byte op = bytes[i];
// push reg (0x50-0x57), including rbp (0x55).
if ((op & 0xF8) == 0x50)
return i + 1;
// mov r32/64, r/m32/64. Recognize only the specific forms listed above.
if (op == 0x8B && bytes.Length > i + 1)
{
byte modrm = bytes[i + 1];
if (modrm == 0xFF || modrm == 0xEC)
return i + 2;
}
// sub esp/rsp, imm8 — register-direct ModRM 0xEC only.
if (op == 0x83 && bytes.Length > i + 2 && bytes[i + 1] == 0xEC)
return i + 3;
// sub esp/rsp, imm32 — register-direct ModRM 0xEC only.
if (op == 0x81 && bytes.Length > i + 5 && bytes[i + 1] == 0xEC)
return i + 6;
return -1;
}
/// <summary>
/// Walks prologue instructions until at least <paramref name="requiredBytes"/>
/// have been covered, returning the total length of whole instructions that must
/// be preserved in the trampoline.
/// </summary>
/// <exception cref="InvalidOperationException">An opcode is outside the covered set.</exception>
public static int GetWholeInstructionLength(byte[] prologue, int requiredBytes, bool is64Bit)
{
int total = 0;
while (total < requiredBytes)
{
int len = GetInstructionLength(prologue.AsSpan(total), is64Bit);
if (len <= 0)
{
throw new InvalidOperationException(
"The target prologue contains an instruction outside the covered opcode set. " +
"Install the optional Iced backend for full instruction-boundary validation.");
}
total += len;
}
return total;
}
}
+29 -20
View File
@@ -12,10 +12,18 @@ namespace WhiteMagic;
/// empty / zero bytes) on invalid or protected addresses instead of crashing /// empty / zero bytes) on invalid or protected addresses instead of crashing
/// the host process with an <see cref="AccessViolationException"/>. /// the host process with an <see cref="AccessViolationException"/>.
/// </summary> /// </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 public sealed class InProcessReader : MemoryBase
{ {
private readonly SafeMemoryHandle _handle; private readonly SafeMemoryHandle _handle;
private readonly IntPtr _imageBase; private readonly IntPtr _imageBase;
private readonly int _processId;
private bool _disposed; private bool _disposed;
/// <summary> /// <summary>
@@ -24,8 +32,10 @@ public sealed class InProcessReader : MemoryBase
public InProcessReader() public InProcessReader()
{ {
Process current = Process.GetCurrentProcess(); Process current = Process.GetCurrentProcess();
_processId = current.Id;
_handle = NativeMethods.OpenProcess( _handle = NativeMethods.OpenProcess(
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation, ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
| ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize,
false, false,
current.Id); current.Id);
if (_handle.IsInvalid) if (_handle.IsInvalid)
@@ -35,8 +45,17 @@ public sealed class InProcessReader : MemoryBase
$"OpenProcess failed for PID {current.Id}: error {error}"); $"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; _imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
} }
catch (System.ComponentModel.Win32Exception)
{
_imageBase = IntPtr.Zero;
}
}
/// <inheritdoc /> /// <inheritdoc />
public override IntPtr ImageBase => _imageBase; public override IntPtr ImageBase => _imageBase;
@@ -44,24 +63,19 @@ public sealed class InProcessReader : MemoryBase
/// <inheritdoc /> /// <inheritdoc />
public override SafeMemoryHandle Handle => _handle; public override SafeMemoryHandle Handle => _handle;
/// <inheritdoc />
public override bool Is64Bit => Environment.Is64BitProcess;
/// <inheritdoc />
public override int ProcessId => _processId;
/// <inheritdoc /> /// <inheritdoc />
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{ {
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
byte[] buffer = new byte[count]; return RpmHelper.ReadBytes(_handle, address, count);
if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
return [];
}
if ((int)bytesRead != count)
{
Array.Resize(ref buffer, (int)bytesRead);
}
return buffer;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -70,12 +84,7 @@ public sealed class InProcessReader : MemoryBase
if (isRelative) if (isRelative)
address = GetAbsolute(address); address = GetAbsolute(address);
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written)) return RpmHelper.WriteBytes(_handle, address, bytes);
{
return 0;
}
return (int)written;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -84,7 +93,7 @@ public sealed class InProcessReader : MemoryBase
if (!_disposed) if (!_disposed)
{ {
_disposed = true; _disposed = true;
_handle.Dispose(); base.Dispose();
} }
} }
} }
+85
View File
@@ -0,0 +1,85 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagic.Injection;
/// <summary>
/// Injects raw machine code into a process's memory.
/// </summary>
public static class CodeInjector
{
/// <summary>
/// Injects code at a specific address.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="address">The target address.</param>
/// <param name="code">The machine code bytes to write.</param>
/// <returns>The address the code was written to (same as <paramref name="address"/>).</returns>
/// <exception cref="ArgumentException"><paramref name="code"/> is empty.</exception>
/// <exception cref="Win32Exception">Write fails.</exception>
public static IntPtr InjectAtAddress(MemoryBase memory, IntPtr address, byte[] code)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentNullException.ThrowIfNull(code);
if (code.Length == 0)
throw new ArgumentException("Code cannot be empty.", nameof(code));
if (address == IntPtr.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
// Write the code to the target address
int written = memory.WriteBytes(address, code);
if (written != code.Length)
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error,
$"WriteProcessMemory failed at {address} (wrote {written} of {code.Length} bytes).");
}
return address;
}
/// <summary>
/// Allocates executable memory and injects code into it.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="code">The machine code bytes to inject.</param>
/// <param name="protection">
/// The memory protection. Defaults to <see cref="MemoryProtectionType.ExecuteReadWrite"/>.
/// </param>
/// <returns>
/// The base address of the allocated memory containing the code.
/// The caller is responsible for freeing this memory (e.g., via <see cref="AllocatedMemory.Dispose"/>).
/// </returns>
/// <exception cref="ArgumentException"><paramref name="code"/> is empty.</exception>
/// <exception cref="Win32Exception">Allocation or write fails.</exception>
public static AllocatedMemory Inject(
MemoryBase memory,
byte[] code,
MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentNullException.ThrowIfNull(code);
if (code.Length == 0)
throw new ArgumentException("Code cannot be empty.", nameof(code));
// Allocate memory with the specified protection
var allocated = new AllocatedMemory(memory, code.Length, protection);
// Write the code to the allocated memory
int written = memory.WriteBytes(allocated.BaseAddress, code);
if (written != code.Length)
{
int error = Marshal.GetLastPInvokeError();
allocated.Dispose();
throw new Win32Exception(error,
$"WriteProcessMemory failed (wrote {written} of {code.Length} bytes).");
}
return allocated;
}
}
+547
View File
@@ -0,0 +1,547 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using WhiteMagic.Native;
namespace WhiteMagic.Injection;
/// <summary>
/// Injects DLLs into an open target process by creating a remote thread or by
/// hijacking an existing thread.
/// </summary>
/// <remarks>
/// <para>
/// The DLL path is sent to <c>LoadLibraryW</c>, so it is encoded as a null-terminated
/// UTF-16 string in the target process.
/// </para>
/// <para>
/// Injection requires the target process to have the same bitness as the current
/// process, because the emitted x86/x64 stubs and the captured thread context must
/// match the target architecture.
/// </para>
/// </remarks>
public sealed class DllInjector
{
private readonly MemoryBase _memory;
private readonly bool _currentIs64Bit;
/// <summary>
/// Initializes a new <see cref="DllInjector"/> for the target represented by
/// <paramref name="memory"/>.
/// </summary>
/// <param name="memory">A reader/writer for the target process.</param>
public DllInjector(MemoryBase memory)
{
ArgumentNullException.ThrowIfNull(memory);
_memory = memory;
_currentIs64Bit = Environment.Is64BitProcess;
}
/// <summary>
/// Gets the <see cref="MemoryBase"/> the injector is operating on.
/// </summary>
public MemoryBase Memory => _memory;
/// <summary>
/// Injects a DLL into the target process by creating a remote thread that loads it.
/// </summary>
/// <param name="dllPath">The path to the DLL. The file must exist.</param>
/// <returns>The base address of the loaded module in the target process.</returns>
/// <exception cref="ArgumentException"><paramref name="dllPath"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException"><paramref name="dllPath"/> does not exist.</exception>
/// <exception cref="InvalidOperationException">The target bitness does not match the caller.</exception>
/// <exception cref="InvalidOperationException">The remote load failed or timed out.</exception>
public IntPtr InjectWithRemoteThread(string dllPath)
{
ValidateAndCheckBitness(dllPath);
IntPtr loadLibrary = ResolveLoadLibraryW();
byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
int pointerSize = _currentIs64Bit ? 8 : 4;
int stubSize = _currentIs64Bit ? 39 : 18;
int pathOffset = Align(stubSize, pointerSize);
int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
int totalSize = resultOffset + pointerSize + 4096;
IntPtr remoteBase = NativeMethods.VirtualAllocEx(
_memory.Handle,
IntPtr.Zero,
totalSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (remoteBase == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
}
try
{
IntPtr pathAddress = remoteBase + pathOffset;
IntPtr resultAddress = remoteBase + resultOffset;
if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
throw new InvalidOperationException("Failed to write the DLL path into the target process.");
byte[] stub = _currentIs64Bit
? BuildRemoteThreadStubX64(pathAddress, resultAddress, loadLibrary)
: BuildRemoteThreadStubX86(pathAddress, resultAddress, loadLibrary);
if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
throw new InvalidOperationException("Failed to write the remote thread stub.");
using SafeMemoryHandle thread = NativeMethods.CreateRemoteThread(
_memory.Handle,
IntPtr.Zero,
0,
remoteBase,
IntPtr.Zero,
ThreadCreationFlags.RunImmediately,
out _);
if (thread.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"CreateRemoteThread failed (error {error}).");
}
const uint timeoutMs = 30000;
uint wait = NativeMethods.WaitForSingleObject(thread, timeoutMs);
if (wait == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"WaitForSingleObject failed (error {error}).");
}
if (wait == 0x00000102)
throw new InvalidOperationException("Remote thread timed out while loading the DLL.");
IntPtr result = _memory.Read<IntPtr>(resultAddress);
if (result == IntPtr.Zero)
throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
return result;
}
finally
{
// The DLL is already loaded; the temporary stub, path and result slot can be released.
NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
}
}
/// <summary>
/// Injects a DLL by hijacking an existing thread in the target process.
/// </summary>
/// <param name="threadId">The operating-system identifier of the thread to hijack.</param>
/// <param name="dllPath">The path to the DLL. The file must exist.</param>
/// <returns>The base address of the loaded module in the target process.</returns>
/// <exception cref="ArgumentException"><paramref name="threadId"/> is not a positive value or
/// <paramref name="dllPath"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException"><paramref name="dllPath"/> does not exist.</exception>
/// <exception cref="InvalidOperationException">The target bitness does not match the caller.</exception>
/// <exception cref="InvalidOperationException">The hijack, load, or context restore failed.</exception>
public IntPtr InjectWithThreadHijack(int threadId, string dllPath)
{
if (threadId <= 0)
throw new ArgumentException("Thread ID must be a positive value.", nameof(threadId));
ValidateAndCheckBitness(dllPath);
IntPtr loadLibrary = ResolveLoadLibraryW();
byte[] pathBytes = Encoding.Unicode.GetBytes(dllPath + '\0');
int pointerSize = _currentIs64Bit ? 8 : 4;
int stubSize = _currentIs64Bit ? 40 : 19;
int pathOffset = Align(stubSize, pointerSize);
int resultOffset = Align(pathOffset + pathBytes.Length, pointerSize);
int totalSize = resultOffset + pointerSize + 4096;
IntPtr remoteBase = NativeMethods.VirtualAllocEx(
_memory.Handle,
IntPtr.Zero,
totalSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
if (remoteBase == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"VirtualAllocEx failed (error {error}).");
}
try
{
IntPtr pathAddress = remoteBase + pathOffset;
IntPtr resultAddress = remoteBase + resultOffset;
if (_memory.WriteBytes(pathAddress, pathBytes) != pathBytes.Length)
throw new InvalidOperationException("Failed to write the DLL path into the target process.");
byte[] stub = _currentIs64Bit
? BuildHijackStubX64(pathAddress, resultAddress, loadLibrary)
: BuildHijackStubX86(pathAddress, resultAddress, loadLibrary);
if (_memory.WriteBytes(remoteBase, stub) != stub.Length)
throw new InvalidOperationException("Failed to write the hijack stub.");
nint stackTop = (nint)(remoteBase + totalSize);
stackTop = AlignDown(stackTop, pointerSize);
if (_currentIs64Bit)
stackTop = AlignDown(stackTop, 16);
using SafeMemoryHandle thread = NativeMethods.OpenThread(
ThreadAccess.SuspendResume | ThreadAccess.GetContext | ThreadAccess.SetContext | ThreadAccess.QueryInformation,
false,
threadId);
if (thread.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed (error {error}).");
}
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed (error {error}).");
}
bool restored = false;
try
{
IntPtr result;
if (_currentIs64Bit)
{
var originalContext = new Context64 { ContextFlags = ContextFlags.Amd64Full };
if (!NativeMethods.GetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed (error {error}).");
}
var redirectContext = originalContext;
redirectContext.Rip = (ulong)(nint)remoteBase;
redirectContext.Rsp = (ulong)stackTop;
if (!NativeMethods.SetThreadContext(thread, ref redirectContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread failed (error {error}).");
}
result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
}
if (!NativeMethods.SetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext restore failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
}
restored = true;
}
else
{
// 32-bit process targeting a 32-bit process. The target context is
// a native x86 CONTEXT; the WOW64 APIs are for 64-bit callers only.
var originalContext = new Context32 { ContextFlags = ContextFlags.X86Full };
if (!NativeMethods.GetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed (error {error}).");
}
var redirectContext = originalContext;
redirectContext.Eip = (uint)(nint)remoteBase;
redirectContext.Esp = (uint)(nint)stackTop;
if (!NativeMethods.SetThreadContext(thread, ref redirectContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread failed (error {error}).");
}
result = WaitForResult(resultAddress, TimeSpan.FromSeconds(5));
if (NativeMethods.SuspendThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed while capturing result (error {error}).");
}
if (!NativeMethods.SetThreadContext(thread, ref originalContext))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext restore failed (error {error}).");
}
if (NativeMethods.ResumeThread(thread) == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread restore failed (error {error}).");
}
restored = true;
}
if (result == IntPtr.Zero)
throw new InvalidOperationException("LoadLibrary returned zero; the DLL could not be loaded.");
return result;
}
catch
{
// If we never successfully restored the thread's original context, the
// thread may still be executing (or about to execute) code inside the
// injected allocation. Freeing that memory now would crash the target
// process, so leak the block and leave the thread suspended.
if (!restored)
{
remoteBase = IntPtr.Zero;
}
throw;
}
}
finally
{
NativeMethods.VirtualFreeEx(_memory.Handle, remoteBase, 0, MemoryFreeType.Release);
}
}
private void ValidateAndCheckBitness(string dllPath)
{
if (string.IsNullOrWhiteSpace(dllPath))
throw new ArgumentException("DLL path cannot be null or empty.", nameof(dllPath));
if (!File.Exists(dllPath))
throw new FileNotFoundException("The specified DLL was not found.", dllPath);
if (_memory.Is64Bit != _currentIs64Bit)
{
throw new InvalidOperationException(
"The target process bitness does not match the current process bitness.");
}
}
private static IntPtr ResolveLoadLibraryW()
{
// kernel32.dll is loaded at the same base address in every process at a given
// bitness, so resolving the export in the current process gives the correct
// target address for the remote process.
IntPtr kernel32 = NativeMethods.LoadLibrary("kernel32.dll");
if (kernel32 == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Unable to obtain a handle to kernel32.dll (error {error}).");
}
IntPtr loadLibrary = NativeMethods.GetProcAddress(kernel32, "LoadLibraryW");
if (loadLibrary == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"Unable to resolve LoadLibraryW (error {error}).");
}
return loadLibrary;
}
private IntPtr WaitForResult(IntPtr resultAddress, TimeSpan timeout)
{
Stopwatch watch = Stopwatch.StartNew();
while (watch.Elapsed < timeout)
{
IntPtr value = _memory.Read<IntPtr>(resultAddress);
if (value != IntPtr.Zero)
return value;
System.Threading.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
@@ -0,0 +1,74 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Input;
/// <summary>
/// Mouse buttons supported by <see cref="InputSimulator.SendMouseClick"/>.
/// </summary>
public enum MouseButton
{
/// <summary>The left mouse button.</summary>
Left,
/// <summary>The right mouse button.</summary>
Right,
}
/// <summary>
/// Simulates keyboard and mouse input directed at a target window via window messages.
/// </summary>
public sealed class InputSimulator
{
/// <summary>
/// Sends a sequence of character messages to <paramref name="hWnd"/>.
/// </summary>
/// <returns><see langword="true"/> if every character was posted successfully.</returns>
public bool SendKeys(IntPtr hWnd, string text)
{
if (text is null)
throw new ArgumentNullException(nameof(text));
if (hWnd == IntPtr.Zero)
return false;
foreach (char c in text)
{
if (!NativeMethods.PostMessageW(hWnd, NativeMethods.WmChar, (nuint)c, 0))
return false;
}
return true;
}
/// <summary>
/// Sends a mouse click at client-area coordinates <paramref name="x"/>,
/// <paramref name="y"/> to <paramref name="hWnd"/>.
/// </summary>
/// <returns><see langword="true"/> if the click was posted successfully.</returns>
public bool SendMouseClick(IntPtr hWnd, int x, int y, MouseButton button)
{
if (hWnd == IntPtr.Zero)
return false;
nint lParam = MakeLong(x, y);
(uint down, uint downWParam) = button switch
{
MouseButton.Left => (NativeMethods.WmLButtonDown, NativeMethods.MkLButton),
MouseButton.Right => (NativeMethods.WmRButtonDown, NativeMethods.MkRButton),
_ => throw new ArgumentOutOfRangeException(nameof(button)),
};
if (!NativeMethods.PostMessageW(hWnd, down, downWParam, lParam))
return false;
uint up = button == MouseButton.Left ? NativeMethods.WmLButtonUp : NativeMethods.WmRButtonUp;
return NativeMethods.PostMessageW(hWnd, up, 0, lParam);
}
private static nint MakeLong(int low, int high)
{
return (nint)((uint)low | ((uint)high << 16));
}
}
+113
View File
@@ -0,0 +1,113 @@
using System.Collections.Generic;
using System.Diagnostics;
using Process = System.Diagnostics.Process;
using WhiteMagic.Execution;
using WhiteMagic.Hooking;
using WhiteMagic.Memory;
using WhiteMagic.ProcessDiscovery;
using WhiteMagic.Thread;
using WhiteMagic.Windows;
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;
/// <summary>
/// Returns the memory region that contains <paramref name="address"/>.
/// </summary>
public MemoryRegion QueryRegion(IntPtr address) => Memory.QueryRegion(address);
/// <summary>
/// Enumerates the committed and reserved regions of the target process address space.
/// </summary>
public IEnumerable<MemoryRegion> Regions => Memory.EnumerateRegions();
/// <summary>
/// Factory for discovering and operating on the target process's threads.
/// </summary>
public ThreadFactory Threads => new ThreadFactory(Memory);
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(Process process)
{
return new Magic(new ExternalReader(process));
}
/// <summary>
/// Opens a target process by its image name. Throws if zero or more than one match.
/// </summary>
public static Magic Open(string processName)
{
using Process process = ApplicationFinder.OpenProcess(processName);
return Open(process);
}
/// <summary>
/// Opens the process that owns the top-level window with the specified title.
/// </summary>
public static Magic OpenByWindowTitle(string title)
{
using Process process = ApplicationFinder.OpenByWindowTitle(title);
return Open(process);
}
/// <summary>
/// Opens the process that owns the specified window handle.
/// </summary>
public static Magic OpenByWindowHandle(IntPtr handle)
{
using Process process = ApplicationFinder.OpenByWindowHandle(handle);
return Open(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();
}
}
+201
View File
@@ -0,0 +1,201 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Memory;
/// <summary>
/// Represents a chunk of remote memory subdivided into named regions.
/// </summary>
public sealed class AllocatedMemory : IDisposable
{
private readonly MemoryBase _memory;
private readonly IntPtr _baseAddress;
private readonly int _size;
private readonly Dictionary<string, int> _regions;
private bool _disposed;
/// <summary>
/// Creates a new allocated memory chunk.
/// </summary>
/// <param name="memory">The memory accessor.</param>
/// <param name="size">The size of the allocation in bytes.</param>
/// <param name="protection">The initial memory protection.</param>
/// <exception cref="Win32Exception">Allocation fails.</exception>
public AllocatedMemory(MemoryBase memory, int size, MemoryProtectionType protection = MemoryProtectionType.ExecuteReadWrite)
{
ArgumentNullException.ThrowIfNull(memory);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size);
_memory = memory;
_size = size;
_regions = new Dictionary<string, int>();
// Allocate using VirtualAllocEx
_baseAddress = NativeMethods.VirtualAllocEx(
memory.Handle,
IntPtr.Zero,
size,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
protection);
if (_baseAddress == IntPtr.Zero)
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error, $"VirtualAllocEx failed (size={size}).");
}
}
/// <summary>
/// Gets the base address of the allocated memory.
/// </summary>
public IntPtr BaseAddress => _baseAddress;
/// <summary>
/// Gets the size of the allocation in bytes.
/// </summary>
public int Size => _size;
/// <summary>
/// Adds a named region at a specific offset within the allocation.
/// </summary>
/// <param name="name">The unique name for the region.</param>
/// <param name="offset">The offset from the base address.</param>
/// <exception cref="ArgumentException">A region with this name already exists.</exception>
/// <exception cref="ArgumentOutOfRangeException">Offset is outside the allocation bounds.</exception>
public void AddRegion(string name, int offset)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(name);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(offset, _size);
if (_regions.ContainsKey(name))
throw new ArgumentException($"Region '{name}' already exists.", nameof(name));
_regions[name] = offset;
}
/// <summary>
/// Gets the absolute address of a named region.
/// </summary>
/// <param name="name">The region name.</param>
/// <returns>The absolute address of the region.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public IntPtr AddressOf(string name)
{
ObjectDisposedException.ThrowIf(_disposed, this);
int offset = GetRegionOffset(name);
return _baseAddress + offset;
}
private int GetRegionOffset(string name)
{
ArgumentNullException.ThrowIfNull(name);
if (!_regions.TryGetValue(name, out int offset))
throw new ArgumentException($"Region '{name}' does not exist.", nameof(name));
return offset;
}
/// <summary>
/// Reads a value of type <typeparamref name="T"/> from a named region.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="name">The region name.</param>
/// <returns>The value read from memory.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public T Read<T>(string name) where T : struct
{
ObjectDisposedException.ThrowIf(_disposed, this);
int offset = GetRegionOffset(name);
int size = Marshal.SizeOf<T>();
if (offset > _size - size)
throw new ArgumentOutOfRangeException(nameof(name), $"Region '{name}' read of {size} bytes exceeds allocation size {_size}.");
return _memory.Read<T>(_baseAddress + offset);
}
/// <summary>
/// Writes a value of type <typeparamref name="T"/> to a named region.
/// </summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="name">The region name.</param>
/// <param name="value">The value to write.</param>
/// <returns><see langword="true"/> if all bytes were written.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public bool Write<T>(string name, T value) where T : struct
{
ObjectDisposedException.ThrowIf(_disposed, this);
int offset = GetRegionOffset(name);
int size = Marshal.SizeOf<T>();
if (offset > _size - size)
throw new ArgumentOutOfRangeException(nameof(name), $"Region '{name}' write of {size} bytes exceeds allocation size {_size}.");
return _memory.Write(_baseAddress + offset, value);
}
/// <summary>
/// Reads bytes from a named region.
/// </summary>
/// <param name="name">The region name.</param>
/// <param name="count">The number of bytes to read.</param>
/// <returns>The bytes read from memory.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public byte[] ReadBytes(string name, int count)
{
ObjectDisposedException.ThrowIf(_disposed, this);
int offset = GetRegionOffset(name);
ArgumentOutOfRangeException.ThrowIfNegative(count);
if (offset > _size - count)
throw new ArgumentOutOfRangeException(nameof(count), $"Region '{name}' read of {count} bytes exceeds allocation size {_size}.");
return _memory.ReadBytes(_baseAddress + offset, count);
}
/// <summary>
/// Writes bytes to a named region.
/// </summary>
/// <param name="name">The region name.</param>
/// <param name="bytes">The bytes to write.</param>
/// <returns>The number of bytes written.</returns>
/// <exception cref="ArgumentException">No region with this name exists.</exception>
public int WriteBytes(string name, ReadOnlySpan<byte> bytes)
{
ObjectDisposedException.ThrowIf(_disposed, this);
int offset = GetRegionOffset(name);
if (offset > _size - bytes.Length)
throw new ArgumentOutOfRangeException(nameof(bytes), $"Region '{name}' write of {bytes.Length} bytes exceeds allocation size {_size}.");
return _memory.WriteBytes(_baseAddress + offset, bytes);
}
/// <summary>
/// Frees the allocated memory.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
// Free using VirtualFreeEx
if (_baseAddress != IntPtr.Zero)
{
NativeMethods.VirtualFreeEx(
_memory.Handle,
_baseAddress,
0,
MemoryFreeType.Release);
}
_regions.Clear();
}
}
}
+75
View File
@@ -0,0 +1,75 @@
using System;
using WhiteMagic.Native;
namespace WhiteMagic.Memory;
/// <summary>
/// An immutable snapshot of a memory region as reported by <c>VirtualQueryEx</c>.
/// </summary>
public readonly record struct MemoryRegion
{
/// <summary>The base address of the region of pages.</summary>
public IntPtr BaseAddress { get; }
/// <summary>The size of the region, in bytes.</summary>
public nuint Size { get; }
/// <summary>The access protection of the pages in the region.</summary>
public MemoryProtectionType Protection { get; }
/// <summary>The state of the pages in the region.</summary>
public MemoryState State { get; }
/// <summary>The type of pages in the region.</summary>
public MemoryType Type { get; }
/// <summary>The base address of a range of pages allocated by VirtualAllocEx.</summary>
public IntPtr AllocationBase { get; }
/// <summary>The memory protection option when the region was initially allocated.</summary>
public MemoryProtectionType AllocationProtect { get; }
/// <summary>
/// Initializes a new <see cref="MemoryRegion"/> from explicit values.
/// </summary>
public MemoryRegion(
IntPtr baseAddress,
nuint size,
MemoryProtectionType protection,
MemoryState state,
MemoryType type,
IntPtr allocationBase,
MemoryProtectionType allocationProtect)
{
BaseAddress = baseAddress;
Size = size;
Protection = protection;
State = state;
Type = type;
AllocationBase = allocationBase;
AllocationProtect = allocationProtect;
}
/// <summary>
/// Initializes a new <see cref="MemoryRegion"/> from a raw <c>MEMORY_BASIC_INFORMATION</c>.
/// </summary>
internal MemoryRegion(MemoryBasicInformation info)
{
BaseAddress = info.BaseAddress;
Size = info.RegionSize;
AllocationBase = info.AllocationBase;
AllocationProtect = (MemoryProtectionType)info.AllocationProtect;
Protection = (MemoryProtectionType)info.Protect;
State = (MemoryState)info.State;
Type = (MemoryType)info.Type;
}
/// <summary>
/// Returns <see langword="true"/> if <paramref name="address"/> is inside the region,
/// defined as <c>[BaseAddress, BaseAddress + Size)</c>.
/// </summary>
public bool Contains(IntPtr address)
{
return (nuint)(address - BaseAddress) < Size;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Memory;
/// <summary>
/// A scope that temporarily changes page protection via <c>VirtualProtectEx</c> and
/// restores the original protection when disposed, including when the guarded body throws.
/// </summary>
public sealed class ProtectionScope : IDisposable
{
private readonly MemoryBase _memory;
private readonly IntPtr _address;
private readonly nint _size;
private readonly MemoryProtectionType _originalProtection;
private bool _disposed;
/// <summary>
/// Creates a new protection scope, applying <paramref name="newProtection"/> to the
/// specified range immediately.
/// </summary>
internal ProtectionScope(MemoryBase memory, IntPtr address, nint size, MemoryProtectionType newProtection)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
if (address == IntPtr.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
if (size <= 0)
throw new ArgumentOutOfRangeException(nameof(size), "Size must be positive.");
_address = address;
_size = size;
if (!NativeMethods.VirtualProtectEx(
memory.Handle,
address,
size,
newProtection,
out _originalProtection))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"VirtualProtectEx failed to change protection: error {error}.");
}
}
/// <summary>Restores the original page protection if it has not already been restored.</summary>
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
NativeMethods.VirtualProtectEx(_memory.Handle, _address, _size, _originalProtection, out _);
}
}
}
+118 -44
View File
@@ -1,4 +1,7 @@
using WhiteMagic.Hooking;
using WhiteMagic.Memory;
using WhiteMagic.Native; using WhiteMagic.Native;
using System.Collections.Generic;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
@@ -12,12 +15,31 @@ namespace WhiteMagic;
/// </summary> /// </summary>
public abstract class MemoryBase : IDisposable 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> /// <summary>The base address of the target process's main module.</summary>
public abstract IntPtr ImageBase { get; } public abstract IntPtr ImageBase { get; }
/// <summary>The native handle to the target process.</summary> /// <summary>The native handle to the target process.</summary>
public abstract SafeMemoryHandle Handle { get; } 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 ──────────────────────────────────────────────────────── // ── Raw byte IO ────────────────────────────────────────────────────────
/// <summary>Reads a sequence of bytes from the target address.</summary> /// <summary>Reads a sequence of bytes from the target address.</summary>
@@ -153,11 +175,17 @@ public abstract class MemoryBase : IDisposable
/// chunks. Stops at the null terminator, the maximum length, or the first page boundary /// 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 /// that fails to read (avoids an atomic failure when a 512-byte window crosses an unmapped
/// region).</summary> /// region).</summary>
/// <param name="address">The address to read from.</param> /// <param name="address">The address to read from. For multi-byte encodings this must be
/// aligned to a code-unit boundary or the result is undefined.</param>
/// <param name="encoding">The text encoding.</param> /// <param name="encoding">The text encoding.</param>
/// <param name="maxLength">The maximum number of bytes to read.</param> /// <param name="maxLength">The maximum number of bytes to read.</param>
/// <param name="relative">If <see langword="true"/>, <paramref name="address"/> is relative /// <param name="relative">If <see langword="true"/>, <paramref name="address"/> is relative
/// to <see cref="ImageBase"/>.</param> /// 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) public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
{ {
if (relative) if (relative)
@@ -166,46 +194,54 @@ public abstract class MemoryBase : IDisposable
// The encoded null terminator. For ASCII/UTF-8 this is a single 0x00 byte; // 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. // for UTF-16 it is two zero bytes (0x00 0x00); for UTF-32 it is four.
byte[] nullTerminator = encoding.GetBytes("\0"); byte[] nullTerminator = encoding.GetBytes("\0");
int nullLen = nullTerminator.Length;
const int chunkSize = 64; const int chunkSize = 64;
int remaining = maxLength; int remaining = maxLength;
var accumulated = new System.Collections.Generic.List<byte[]>(); var accumulated = new System.Collections.Generic.List<byte>();
while (remaining > 0) while (remaining > 0)
{ {
int take = Math.Min(chunkSize, remaining); int take = Math.Min(chunkSize, remaining);
byte[] chunk = ReadBytes(address, take); byte[] chunk = ReadBytes(address + accumulated.Count, take);
if (chunk.Length == 0) if (chunk.Length == 0)
break; break;
int nullPos = IndexOfPattern(chunk, nullTerminator); int previousLen = accumulated.Count;
if (nullPos >= 0) accumulated.AddRange(chunk);
// Search the newly extended buffer at code-unit-aligned positions. A terminator
// can start as far back as (nullLen - 1) bytes before the new bytes, so start
// the search just before the previous end, rounded up to the next code-unit.
int firstAligned = previousLen - (previousLen % nullLen);
if (firstAligned < 0) firstAligned = 0;
int limit = accumulated.Count - nullLen;
for (int i = firstAligned; i <= limit; i += nullLen)
{ {
if (nullPos > 0) bool match = true;
accumulated.Add(chunk[..nullPos]); for (int j = 0; j < nullLen; j++)
{
if (accumulated[i + j] != nullTerminator[j])
{
match = false;
break; break;
} }
}
accumulated.Add(chunk); if (match)
// Advance by the bytes actually read, not the amount requested: a partial {
// read (chunk.Length < take) must not skip the unread tail of the window. accumulated.RemoveRange(i, accumulated.Count - i);
address += chunk.Length; remaining = 0;
break;
}
}
if (remaining > 0)
remaining -= chunk.Length; remaining -= chunk.Length;
} }
int totalLength = 0; return encoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(accumulated));
foreach (byte[] part in accumulated)
totalLength += part.Length;
byte[] combined = new byte[totalLength];
int offset = 0;
foreach (byte[] part in accumulated)
{
part.CopyTo(combined, offset);
offset += part.Length;
}
return encoding.GetString(combined);
} }
/// <summary>Writes a null-terminated string to the target address.</summary> /// <summary>Writes a null-terminated string to the target address.</summary>
@@ -234,11 +270,69 @@ public abstract class MemoryBase : IDisposable
return (IntPtr)((nint)absolute - (nint)ImageBase); return (IntPtr)((nint)absolute - (nint)ImageBase);
} }
// ── Memory region query ────────────────────────────────────────────────
/// <summary>
/// Queries the memory region that contains <paramref name="address"/> in the target
/// process using <c>VirtualQueryEx</c>.
/// </summary>
/// <returns>An immutable snapshot of the region.</returns>
/// <exception cref="InvalidOperationException">The query fails.</exception>
public MemoryRegion QueryRegion(IntPtr address)
{
nuint bufferSize = (nuint)Marshal.SizeOf<MemoryBasicInformation>();
nuint result = NativeMethods.VirtualQueryEx(Handle, address, out MemoryBasicInformation info, bufferSize);
if (result == 0)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"VirtualQueryEx failed for address 0x{address:X}: error {error}.");
}
return new MemoryRegion(info);
}
/// <summary>
/// Enumerates the memory regions of the target process from the lowest address upward.
/// The walk is lazy; callers can stop early without walking the entire address space.
/// </summary>
public IEnumerable<MemoryRegion> EnumerateRegions()
{
IntPtr address = IntPtr.Zero;
nuint bufferSize = (nuint)Marshal.SizeOf<MemoryBasicInformation>();
while (true)
{
nuint result = NativeMethods.VirtualQueryEx(Handle, address, out MemoryBasicInformation info, bufferSize);
if (result == 0)
yield break;
yield return new MemoryRegion(info);
IntPtr next = info.BaseAddress + (nint)info.RegionSize;
if (next.ToInt64() <= address.ToInt64())
yield break;
address = next;
}
}
/// <summary>
/// Changes the page protection on a region of memory and returns a disposable scope
/// that restores the original protection on dispose, including when an exception escapes
/// the guarded body.
/// </summary>
public ProtectionScope ChangeProtection(IntPtr address, nint size, MemoryProtectionType protection)
{
return new ProtectionScope(this, address, size, protection);
}
// ── Lifecycle ────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────
/// <inheritdoc /> /// <inheritdoc />
public virtual void Dispose() public virtual void Dispose()
{ {
DetourManager.RemoveAll();
PatchManager.RestoreAll();
Handle?.Dispose(); Handle?.Dispose();
} }
@@ -278,24 +372,4 @@ public abstract class MemoryBase : IDisposable
bytes.CopyTo(destination); bytes.CopyTo(destination);
} }
private static int IndexOfPattern(byte[] data, byte[] pattern)
{
int lastStart = data.Length - pattern.Length;
int stride = Math.Max(1, pattern.Length);
for (int i = 0; i <= lastStart; i += stride)
{
bool match = true;
for (int j = 0; j < pattern.Length; j++)
{
if (data[i + j] != pattern[j])
{
match = false;
break;
}
}
if (match)
return i;
}
return -1;
}
} }
+80
View File
@@ -33,6 +33,28 @@ public enum ProcessAccess : uint
AllAccess = 0x001F0000 | Synchronize | 0xFFFF, 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> /// <summary>
/// Values that control how VirtualAllocEx allocates memory. /// Values that control how VirtualAllocEx allocates memory.
/// </summary> /// </summary>
@@ -134,3 +156,61 @@ public static class ContextFlags
/// <summary>AMD64: control, integer, and segment registers.</summary> /// <summary>AMD64: control, integer, and segment registers.</summary>
public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments; public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
} }
/// <summary>
/// Values that describe the state of memory pages returned by <c>VirtualQueryEx</c>.
/// </summary>
public enum MemoryState : uint
{
/// <summary>Indicates committed pages for which physical storage has been allocated.</summary>
Commit = 0x1000,
/// <summary>Indicates reserved pages where a range of the virtual address space is reserved without any physical storage being allocated.</summary>
Reserve = 0x2000,
/// <summary>Indicates free pages not accessible to the calling process and available to be allocated.</summary>
Free = 0x10000,
}
/// <summary>
/// Values that describe the type of memory pages returned by <c>VirtualQueryEx</c>.
/// </summary>
public enum MemoryType : uint
{
/// <summary>Indicates that the memory pages within the region are private.</summary>
Private = 0x20000,
/// <summary>Indicates that the memory pages within the region are mapped into the view of a section.</summary>
Mapped = 0x40000,
/// <summary>Indicates that the memory pages within the region are mapped into the view of an image section.</summary>
Image = 0x1000000,
}
/// <summary>
/// Flags used by <c>CreateToolhelp32Snapshot</c> to specify the portions of the system to include in the snapshot.
/// </summary>
[Flags]
public enum SnapshotFlags : uint
{
/// <summary>Enumerate the heap list.</summary>
HeapList = 0x00000001,
/// <summary>Enumerate the process list.</summary>
Process = 0x00000002,
/// <summary>Enumerate the thread list.</summary>
Thread = 0x00000004,
/// <summary>Enumerate the module list.</summary>
Module = 0x00000008,
/// <summary>Enumerate the 32-bit module list for the specified process.</summary>
Module32 = 0x00000010,
/// <summary>Include all processes and threads in the system.</summary>
All = 0x0000001F,
/// <summary>Indicate that the snapshot handle is to be inheritable.</summary>
Inherit = 0x80000000,
}
+85 -4
View File
@@ -19,11 +19,32 @@ internal static partial class NativeMethods
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
int processId); 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> /// <summary>Closes an open object handle.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)] [LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(IntPtr handle); 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 ─────────────────────────────────────────────────────────────── // ── Memory ───────────────────────────────────────────────────────────────
/// <summary>Reads memory from a process.</summary> /// <summary>Reads memory from a process.</summary>
@@ -87,6 +108,22 @@ internal static partial class NativeMethods
ThreadCreationFlags creationFlags, ThreadCreationFlags creationFlags,
out uint threadId); 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> /// <summary>Sets a 64-bit thread context (AMD64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)] [LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
@@ -101,20 +138,21 @@ internal static partial class NativeMethods
SafeMemoryHandle thread, SafeMemoryHandle thread,
ref Context64 context); ref Context64 context);
/// <summary>Sets a 32-bit (WOW64) thread context.</summary> /// <summary>Sets a 32-bit thread context (x86 or WOW64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)] [LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Wow64SetThreadContext( internal static partial bool SetThreadContext(
SafeMemoryHandle thread, SafeMemoryHandle thread,
ref Context32 context); ref Context32 context);
/// <summary>Gets a 32-bit (WOW64) thread context.</summary> /// <summary>Gets a 32-bit thread context (x86 or WOW64).</summary>
[LibraryImport("kernel32.dll", SetLastError = true)] [LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Wow64GetThreadContext( internal static partial bool GetThreadContext(
SafeMemoryHandle thread, SafeMemoryHandle thread,
ref Context32 context); ref Context32 context);
// ── Modules ────────────────────────────────────────────────────────────── // ── Modules ──────────────────────────────────────────────────────────────
/// <summary>Loads a module into the calling process.</summary> /// <summary>Loads a module into the calling process.</summary>
@@ -134,4 +172,47 @@ internal static partial class NativeMethods
internal static partial uint WaitForSingleObject( internal static partial uint WaitForSingleObject(
SafeMemoryHandle handle, SafeMemoryHandle handle,
uint milliseconds); uint milliseconds);
// ── Memory query ───────────────────────────────────────────────────────
/// <summary>Retrieves information about a range of pages in the virtual address space of a specified process.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial nuint VirtualQueryEx(
SafeMemoryHandle process,
IntPtr address,
out MemoryBasicInformation buffer,
nuint length);
// ── Thread enumeration ─────────────────────────────────────────────────
/// <summary>Takes a snapshot of the specified processes, as well as the heaps, modules, and threads used by these processes.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial SafeMemoryHandle CreateToolhelp32Snapshot(
SnapshotFlags dwFlags,
int th32ProcessID);
/// <summary>Retrieves information about the first thread of any process encountered in a system snapshot.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Thread32First(
SafeMemoryHandle hSnapshot,
ref ThreadEntry32 lpte);
/// <summary>Retrieves information about the next thread of any process encountered in a system snapshot.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool Thread32Next(
SafeMemoryHandle hSnapshot,
ref ThreadEntry32 lpte);
/// <summary>Retrieves timing information for the specified thread.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GetThreadTimes(
SafeMemoryHandle thread,
out long creationTime,
out long exitTime,
out long kernelTime,
out long userTime);
} }
+60 -2
View File
@@ -29,8 +29,8 @@ public unsafe struct FloatingSaveArea32
} }
/// <summary> /// <summary>
/// A 32-bit (x86/WOW64) thread context. Use it with /// A 32-bit x86 thread context. Use it with <c>GetThreadContext</c> and
/// <c>Wow64GetThreadContext</c> and <c>Wow64SetThreadContext</c> to inspect a 32-bit thread. /// <c>SetThreadContext</c> from a 32-bit process targeting a 32-bit thread.
/// The total size is 716 bytes. /// The total size is 716 bytes.
/// </summary> /// </summary>
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
@@ -205,3 +205,61 @@ public unsafe struct Context64
/// <summary>The source RIP of the last exception.</summary> /// <summary>The source RIP of the last exception.</summary>
public ulong LastExceptionFromRip; public ulong LastExceptionFromRip;
} }
/// <summary>
/// Layout matches <c>MEMORY_BASIC_INFORMATION</c>. Uses pointer-sized fields so the
/// structure is 28 bytes on x86 and 48 bytes on x64, matching the layout the OS expects
/// from a caller of those bitnesses.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MemoryBasicInformation
{
/// <summary>A pointer to the base address of the region of pages.</summary>
public nint BaseAddress;
/// <summary>A pointer to the base address of a range of pages allocated by the VirtualAllocEx function.</summary>
public nint AllocationBase;
/// <summary>The memory protection option when the region was initially allocated.</summary>
public uint AllocationProtect;
/// <summary>The size of the region beginning at the base address, in bytes.</summary>
public nuint RegionSize;
/// <summary>The state of the pages in the region.</summary>
public uint State;
/// <summary>The access protection of the pages in the region.</summary>
public uint Protect;
/// <summary>The type of pages in the region.</summary>
public uint Type;
}
/// <summary>
/// Layout matches <c>THREADENTRY32</c> used by <c>Thread32First</c>/<c>Thread32Next</c>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ThreadEntry32
{
/// <summary>The size of the structure, in bytes.</summary>
public uint dwSize;
/// <summary>This member is no longer used and is always zero.</summary>
public uint cntUsage;
/// <summary>The thread identifier.</summary>
public uint th32ThreadID;
/// <summary>The identifier of the process that owns the thread.</summary>
public uint th32OwnerProcessID;
/// <summary>The kernel base priority level assigned to the thread.</summary>
public int tpBasePri;
/// <summary>This member is no longer used.</summary>
public int tpDeltaPri;
/// <summary>This member is reserved.</summary>
public uint dwFlags;
}
+182
View File
@@ -0,0 +1,182 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace WhiteMagic.Native;
/// <summary>
/// P/Invoke declarations for kernel32/ntdll/user32 APIs used by the high-level
/// PEB, TEB, windowing, and input helpers. These live in a separate partial file so
/// they can evolve independently of <see cref="NativeMethods"/>.
/// </summary>
internal static partial class NativeMethods
{
// ── Natives used directly by public helpers ──────────────────────────────
/// <summary>Queries information about the specified process.</summary>
[LibraryImport("ntdll.dll")]
internal static partial int NtQueryInformationProcess(
SafeMemoryHandle processHandle,
int processInformationClass,
ref ProcessBasicInformation processInformation,
uint processInformationLength,
out uint returnLength);
/// <summary>Queries information about the specified thread.</summary>
[LibraryImport("ntdll.dll")]
internal static partial int NtQueryInformationThread(
SafeMemoryHandle threadHandle,
int threadInformationClass,
ref ThreadBasicInformation threadInformation,
uint threadInformationLength,
out uint returnLength);
/// <summary>Enumerates all top-level windows on the screen.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial int EnumWindows(
nint lpEnumFunc,
IntPtr lParam);
/// <summary>Retrieves the identifier of the thread that created the window and the process id of the window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial uint GetWindowThreadProcessId(
IntPtr hWnd,
out uint lpdwProcessId);
/// <summary>Retrieves the name of the class to which the specified window belongs.</summary>
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial int GetClassNameW(
IntPtr hWnd,
[Out] char[] lpClassName,
int nMaxCount);
/// <summary>Copies the text of the specified window's title bar into a buffer.</summary>
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial int GetWindowTextW(
IntPtr hWnd,
[Out] char[] lpString,
int nMaxCount);
/// <summary>Changes the text of the specified window's title bar.</summary>
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowTextW(
IntPtr hWnd,
string lpString);
/// <summary>Changes the size, position, and Z order of a child, pop-up, or top-level window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int x,
int y,
int cx,
int cy,
uint uFlags);
/// <summary>Retrieves a handle to the foreground window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial IntPtr GetForegroundWindow();
/// <summary>Brings the thread that created the specified window into the foreground and activates the window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetForegroundWindow(IntPtr hWnd);
/// <summary>Flashes the specified window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool FlashWindowEx(ref FlashWindowInfo pwfi);
/// <summary>Attaches or detaches the input processing mechanism of one thread to that of another thread.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool AttachThreadInput(
uint idAttach,
uint idAttachTo,
[MarshalAs(UnmanagedType.Bool)] bool fAttach);
/// <summary>Places a message in the message queue associated with the thread that created the specified window.</summary>
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool PostMessageW(
IntPtr hWnd,
uint msg,
nuint wParam,
nint lParam);
// ── Window / input constants ───────────────────────────────────────────────
internal const uint WmChar = 0x0102;
internal const uint WmLButtonDown = 0x0201;
internal const uint WmLButtonUp = 0x0202;
internal const uint WmRButtonDown = 0x0204;
internal const uint WmRButtonUp = 0x0205;
// Mouse button state flags for WM_*BUTTONDOWN messages
internal const uint MkLButton = 0x0001;
internal const uint MkRButton = 0x0002;
internal static readonly IntPtr HwndTop = IntPtr.Zero;
internal const uint SwpShowWindow = 0x0040;
internal const uint SwpNoActivate = 0x0010;
internal const uint FlashwAll = 0x00000003;
internal const uint FlashwCaption = 0x00000001;
internal const uint FlashwTray = 0x00000002;
internal const uint FlashwTimer = 0x00000004;
internal const uint FlashwTimerNoFg = 0x0000000C;
}
/// <summary>
/// Layout matches <c>PROCESS_BASIC_INFORMATION</c> (ProcessBasicInformation = 0).
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ProcessBasicInformation
{
public int ExitStatus;
public IntPtr PebBaseAddress;
public UIntPtr AffinityMask;
public int BasePriority;
public UIntPtr UniqueProcessId;
public UIntPtr InheritedFromUniqueProcessId;
}
/// <summary>
/// Layout matches <c>THREAD_BASIC_INFORMATION</c> (ThreadBasicInformation = 0).
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ThreadBasicInformation
{
public int ExitStatus;
public IntPtr TebBaseAddress;
public ClientId ClientId;
public UIntPtr AffinityMask;
public int Priority;
public int BasePriority;
}
/// <summary>
/// Layout matches <c>CLIENT_ID</c>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ClientId
{
public IntPtr UniqueProcess;
public IntPtr UniqueThread;
}
/// <summary>
/// Layout matches <c>FLASHWINFO</c> used by <see cref="NativeMethods.FlashWindowEx"/>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct FlashWindowInfo
{
public uint cbSize;
public IntPtr hwnd;
public uint dwFlags;
public uint uCount;
public uint dwTimeout;
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
using WhiteMagic.Windows;
namespace WhiteMagic.ProcessDiscovery;
/// <summary>
/// Discovers running processes by name, window title, or window handle so they can be
/// attached through a <see cref="Magic"/> session.
/// </summary>
public static class ApplicationFinder
{
/// <summary>
/// Enumerates processes whose image name matches <paramref name="processName"/>
/// (extension optional).
/// </summary>
public static IEnumerable<Process> Enumerate(string processName)
{
ArgumentException.ThrowIfNullOrEmpty(processName);
return Process.GetProcessesByName(GetNameWithoutExtension(processName));
}
/// <summary>
/// Returns the unique process whose image name matches <paramref name="processName"/>.
/// </summary>
/// <exception cref="InvalidOperationException">Zero or multiple processes match.</exception>
public static Process OpenProcess(string processName)
{
Process[] candidates = Enumerate(processName).ToArray();
if (candidates.Length == 0)
{
throw new InvalidOperationException(
$"No process named '{processName}' was found.");
}
if (candidates.Length > 1)
{
string list = string.Join(", ", candidates.Select(p => $"{p.ProcessName}:{p.Id}"));
foreach (Process candidate in candidates)
candidate.Dispose();
throw new InvalidOperationException(
$"Process name '{processName}' is ambiguous ({candidates.Length} matches): {list}");
}
Process result = candidates[0];
for (int i = 1; i < candidates.Length; i++)
candidates[i].Dispose();
return result;
}
/// <summary>
/// Enumerates processes that own a top-level window whose title equals
/// <paramref name="title"/>.
/// </summary>
public static IEnumerable<Process> FindByWindowTitle(string title)
{
ArgumentException.ThrowIfNullOrEmpty(title);
var seen = new HashSet<int>();
foreach (RemoteWindow window in WindowFactory.GetWindows())
{
if (!string.Equals(window.Text, title, StringComparison.Ordinal))
continue;
uint pid = window.ProcessId;
if (pid == 0 || !seen.Add((int)pid))
continue;
Process? process;
try
{
process = global::System.Diagnostics.Process.GetProcessById((int)pid);
}
catch
{
continue;
}
yield return process;
}
}
/// <summary>
/// Returns the unique process that owns a top-level window titled <paramref name="title"/>.
/// </summary>
/// <exception cref="InvalidOperationException">Zero or multiple windows match.</exception>
public static Process OpenByWindowTitle(string title)
{
Process[] candidates = FindByWindowTitle(title).ToArray();
if (candidates.Length == 0)
{
throw new InvalidOperationException(
$"No top-level window titled '{title}' was found.");
}
if (candidates.Length > 1)
{
throw new InvalidOperationException(
$"Window title '{title}' is ambiguous ({candidates.Length} matches): " +
string.Join(", ", candidates.Select(p => $"{p.ProcessName}:{p.Id}")));
}
return candidates[0];
}
/// <summary>
/// Returns the process that owns the specified window handle.
/// </summary>
public static Process OpenByWindowHandle(IntPtr handle)
{
if (handle == IntPtr.Zero)
throw new ArgumentException("Window handle cannot be zero.", nameof(handle));
uint tid = NativeMethods.GetWindowThreadProcessId(handle, out uint processId);
if (tid == 0 || processId == 0)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"GetWindowThreadProcessId failed for handle {handle:X}: error {error}.");
}
try
{
return global::System.Diagnostics.Process.GetProcessById((int)processId);
}
catch (ArgumentException)
{
throw new InvalidOperationException(
$"Process {processId} owning window {handle:X} is no longer running.");
}
}
private static string GetNameWithoutExtension(string name)
{
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
return name[..^4];
return name;
}
}
+92
View File
@@ -0,0 +1,92 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.ProcessEnvironment;
/// <summary>
/// Managed reader for a target process's Process Environment Block (PEB).
/// </summary>
public sealed class ManagedPeb
{
private readonly MemoryBase _memory;
private readonly IntPtr _pebAddress;
/// <summary>
/// Creates a PEB reader for the process associated with the specified memory facade.
/// </summary>
public ManagedPeb(MemoryBase memory)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_pebAddress = QueryPebAddress();
}
/// <summary>Returns the native address of the PEB in the target process.</summary>
public IntPtr ReadPebAddress() => _pebAddress;
/// <summary>Reads the BeingDebugged byte from the PEB.</summary>
public byte ReadBeingDebugged()
{
return _memory.Read<byte>(_pebAddress + 2);
}
/// <summary>Reads the ImageBaseAddress pointer from the PEB.</summary>
public IntPtr ReadImageBaseAddress()
{
int offset = _memory.Is64Bit ? 0x10 : 0x08;
return ReadPointer(offset);
}
/// <summary>Reads the PEB_LDR_DATA pointer from the PEB.</summary>
public IntPtr ReadLdrAddress()
{
int offset = _memory.Is64Bit ? 0x18 : 0x0C;
return ReadPointer(offset);
}
/// <summary>
/// Determines whether the target process is running under WOW64.
/// </summary>
public bool ReadIsWow64Process()
{
if (!NativeMethods.IsWow64Process(_memory.Handle, out bool wow64))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"IsWow64Process failed with error {error}.");
}
return wow64;
}
private IntPtr QueryPebAddress()
{
var info = new ProcessBasicInformation();
int status = NativeMethods.NtQueryInformationProcess(
_memory.Handle,
0,
ref info,
(uint)Marshal.SizeOf<ProcessBasicInformation>(),
out _);
if (status < 0 || info.PebBaseAddress == IntPtr.Zero)
{
throw new InvalidOperationException(
$"NtQueryInformationProcess failed to retrieve the PEB (NTSTATUS {status:X8}).");
}
return info.PebBaseAddress;
}
private IntPtr ReadPointer(int offset)
{
IntPtr address = _pebAddress + offset;
if (_memory.Is64Bit)
{
ulong raw = _memory.Read<ulong>(address);
return new IntPtr((long)raw);
}
uint raw32 = _memory.Read<uint>(address);
return new IntPtr((int)raw32);
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Threading.Tasks;
using WhiteMagic.Assembly;
using WhiteMagic.Execution;
namespace WhiteMagic;
/// <summary>
/// An exported function resolved in the target process, obtained via
/// <c>magic["module"]["function"]</c>. Executes through one of the session's execution
/// strategies.
/// </summary>
/// <remarks>
/// The default <see cref="Execute{T}"/> path uses the always-available
/// <see cref="RemoteThreadExecutor"/> (<c>CreateRemoteThread</c>), which is safe for
/// thread-agnostic exports. For a call that touches single-threaded target state, obtain
/// the <see cref="Address"/> and route it through a <see cref="MainThreadPump"/>, or use
/// <see cref="CreateDelegate{TDelegate}"/> when running in-process.
/// </remarks>
public sealed class RemoteFunction
{
private readonly Magic _magic;
/// <summary>The export name this function was resolved from.</summary>
public string Name { get; }
/// <summary>The absolute address of the function in the target process.</summary>
public IntPtr Address { get; }
internal RemoteFunction(Magic magic, string name, IntPtr address)
{
_magic = magic;
Name = name;
Address = address;
}
/// <summary>
/// Calls the function via a remote thread and returns its result cast to
/// <typeparamref name="T"/>.
/// </summary>
/// <param name="convention">The calling convention (ignored on x64 targets).</param>
/// <param name="args">Arguments to pass; primitives, pointers, enums, strings and
/// structs are supported.</param>
public T Execute<T>(CallConvention convention, params object?[] args)
{
return _magic.RemoteThread.Execute<T>(Address, convention, args);
}
/// <summary>Asynchronous variant of <see cref="Execute{T}"/>.</summary>
public Task<T> ExecuteAsync<T>(CallConvention convention, params object?[] args)
{
return _magic.RemoteThread.ExecuteAsync<T>(Address, convention, args);
}
/// <summary>
/// Creates a managed delegate bound to this function for the in-process scenario.
/// </summary>
/// <exception cref="InvalidOperationException">The session is not in-process. The
/// resolved <see cref="Address"/> lives in the target process; a delegate to it would
/// access-violate when invoked from the host, so this is rejected for external sessions.
/// Use <see cref="Execute{T}"/> (remote thread) for external targets.</exception>
public TDelegate CreateDelegate<TDelegate>() where TDelegate : Delegate
{
if (_magic.Memory is not InProcessReader)
{
throw new InvalidOperationException(
"CreateDelegate is only valid for an in-process session (Magic.OpenInProcess). " +
"The function address is not mapped into the host process for an external target; " +
"use Execute<T> to call it via a remote thread.");
}
return new InProcessInvoker(_magic.Memory).CreateFunction<TDelegate>(Address);
}
}
+101
View File
@@ -0,0 +1,101 @@
using WhiteMagic.Discovery;
using Process = System.Diagnostics.Process;
using ProcessModule = System.Diagnostics.ProcessModule;
namespace WhiteMagic;
/// <summary>
/// A module (loaded DLL/EXE image) in the target process, obtained by indexing the
/// facade with a module name (e.g. <c>magic["user32"]</c>). Exposes the module's base
/// address and resolves exported functions by name.
/// </summary>
public sealed class RemoteModule
{
private readonly Magic _magic;
/// <summary>The module's file name as reported by the OS (e.g. <c>user32.dll</c>).</summary>
public string Name { get; }
/// <summary>The module's load address in the target process.</summary>
public IntPtr BaseAddress { get; }
internal RemoteModule(Magic magic, string moduleName)
{
ArgumentNullException.ThrowIfNull(magic);
ArgumentException.ThrowIfNullOrEmpty(moduleName);
_magic = magic;
(string name, IntPtr baseAddress) = FindModule(magic.Memory.ProcessId, moduleName);
Name = name;
BaseAddress = baseAddress;
}
/// <summary>
/// Resolves an exported function by name and returns a <see cref="RemoteFunction"/>
/// bound to its address. Export forwarders are followed.
/// </summary>
public RemoteFunction this[string functionName]
{
get
{
IntPtr address = GetExportAddress(functionName);
return new RemoteFunction(_magic, functionName, address);
}
}
/// <summary>Resolves the absolute address of an exported function by name.</summary>
public IntPtr GetExportAddress(string functionName)
{
ArgumentException.ThrowIfNullOrEmpty(functionName);
var parser = new PeHeaderParser(_magic.Memory, BaseAddress);
return parser.GetExportAddress(functionName);
}
private static (string Name, IntPtr BaseAddress) FindModule(int processId, string moduleName)
{
using Process process = Process.GetProcessById(processId);
foreach (ProcessModule module in process.Modules)
{
if (NameMatches(module.ModuleName, moduleName))
return (module.ModuleName, module.BaseAddress);
}
throw new DllNotFoundException(
$"Module '{moduleName}' is not loaded in process {processId}.");
}
/// <summary>
/// Resolves a module's base address by name within a target process, returning
/// <see cref="IntPtr.Zero"/> if it is not loaded. Used by export-forwarder resolution.
/// </summary>
internal static IntPtr ResolveBase(int processId, string moduleName)
{
using Process process = Process.GetProcessById(processId);
foreach (ProcessModule module in process.Modules)
{
if (NameMatches(module.ModuleName, moduleName))
return module.BaseAddress;
}
return IntPtr.Zero;
}
/// <summary>
/// Matches a loaded module's file name against a requested name, tolerating a missing
/// or present <c>.dll</c> extension and ignoring case (e.g. <c>KERNEL32</c> matches
/// <c>kernel32.dll</c>).
/// </summary>
private static bool NameMatches(string actual, string requested)
{
if (string.Equals(actual, requested, StringComparison.OrdinalIgnoreCase))
return true;
string actualNoExt = Path.GetFileNameWithoutExtension(actual);
string requestedNoExt = requested.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
? requested[..^4]
: requested;
return string.Equals(actualNoExt, requestedNoExt, StringComparison.OrdinalIgnoreCase);
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Text;
namespace WhiteMagic;
/// <summary>
/// A pointer-relative view over a <see cref="MemoryBase"/>. Obtained through the
/// high-level facade indexer, it provides read/write/string operations with optional
/// offsets relative to a base address.
/// </summary>
public sealed class RemotePointer
{
private readonly MemoryBase _memory;
/// <summary>The base address of this view.</summary>
public IntPtr BaseAddress { get; }
internal RemotePointer(MemoryBase memory, IntPtr baseAddress)
{
_memory = memory;
BaseAddress = baseAddress;
}
/// <summary>Reads a value of type <typeparamref name="T"/> at <c>BaseAddress + offset</c>.</summary>
public T Read<T>(nint offset = 0) where T : struct
{
return _memory.Read<T>(BaseAddress + offset);
}
/// <summary>Writes <paramref name="value"/> at <c>BaseAddress + offset</c>.</summary>
public bool Write<T>(T value, nint offset = 0) where T : struct
{
return _memory.Write(BaseAddress + offset, value);
}
/// <summary>Reads a null-terminated string at <c>BaseAddress + offset</c>.</summary>
public string ReadString(Encoding encoding, int maxLength = 512, nint offset = 0)
{
return _memory.ReadString(BaseAddress + offset, encoding, maxLength);
}
/// <summary>Writes a null-terminated string at <c>BaseAddress + offset</c>.</summary>
public bool WriteString(string value, Encoding encoding, nint offset = 0)
{
return _memory.WriteString(BaseAddress + offset, value, encoding);
}
/// <summary>Returns a new <see cref="RemotePointer"/> with the offset added.</summary>
public RemotePointer this[nint offset] => new RemotePointer(_memory, BaseAddress + offset);
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace WhiteMagic.Thread;
/// <summary>
/// A disposable scope that tracks a set of threads frozen by <see cref="ThreadFactory.Freeze"/>.
/// Disposing the scope resumes exactly those threads, in reverse order, even if the guarded
/// body throws, and then disposes the underlying thread handles.
/// </summary>
public sealed class FrozenThread : IDisposable
{
private readonly IReadOnlyList<RemoteThread> _threads;
private bool _disposed;
internal FrozenThread(IReadOnlyList<RemoteThread> threads)
{
_threads = threads ?? throw new ArgumentNullException(nameof(threads));
}
/// <summary>The threads suspended by this freeze scope.</summary>
public IEnumerable<RemoteThread> Threads => _threads;
/// <summary>
/// Resumes the frozen threads in reverse order, then disposes every thread handle.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (RemoteThread thread in _threads.Reverse())
{
try
{
thread.Resume();
}
catch
{
// Resume-on-dispose is best-effort; the handle is still disposed below.
}
}
foreach (RemoteThread thread in _threads)
{
thread.Dispose();
}
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.ThreadEnvironment;
/// <summary>
/// Managed reader for a target thread's Thread Environment Block (TEB).
/// </summary>
public sealed class ManagedTeb : IDisposable
{
private readonly MemoryBase _memory;
private readonly SafeMemoryHandle _threadHandle;
private readonly IntPtr _tebAddress;
private bool _disposed;
/// <summary>
/// Creates a TEB reader for the specified thread in the process associated
/// with the provided memory facade.
/// </summary>
public ManagedTeb(MemoryBase memory, int threadId)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_threadHandle = NativeMethods.OpenThread(
ThreadAccess.QueryInformation,
false,
threadId);
if (_threadHandle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException(
$"OpenThread failed for thread {threadId}: error {error}.");
}
_tebAddress = QueryTebAddress();
}
/// <summary>Returns the native address of the TEB in the target process.</summary>
public IntPtr ReadTebAddress() => _tebAddress;
/// <summary>Reads the stack base pointer stored in the TEB.</summary>
public IntPtr ReadStackBase()
{
int offset = _memory.Is64Bit ? 0x08 : 0x04;
return ReadPointer(offset);
}
/// <summary>Reads the stack limit pointer stored in the TEB.</summary>
public IntPtr ReadStackLimit()
{
int offset = _memory.Is64Bit ? 0x10 : 0x08;
return ReadPointer(offset);
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_threadHandle.Dispose();
}
}
private IntPtr QueryTebAddress()
{
var info = new ThreadBasicInformation();
int status = NativeMethods.NtQueryInformationThread(
_threadHandle,
0,
ref info,
(uint)Marshal.SizeOf<ThreadBasicInformation>(),
out _);
if (status < 0 || info.TebBaseAddress == IntPtr.Zero)
{
throw new InvalidOperationException(
$"NtQueryInformationThread failed to retrieve the TEB (NTSTATUS {status:X8}).");
}
return info.TebBaseAddress;
}
private IntPtr ReadPointer(int offset)
{
IntPtr address = _tebAddress + offset;
if (_memory.Is64Bit)
{
ulong raw = _memory.Read<ulong>(address);
return new IntPtr((long)raw);
}
uint raw32 = _memory.Read<uint>(address);
return new IntPtr((int)raw32);
}
}
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
using WhiteMagic.ThreadEnvironment;
namespace WhiteMagic.Thread;
/// <summary>
/// A handle to an existing thread in the target process. Provides suspend/resume,
/// context read/write, and TEB query.
/// </summary>
public sealed class RemoteThread : IDisposable
{
private readonly MemoryBase _memory;
private readonly SafeMemoryHandle _handle;
private readonly int _id;
private bool _disposed;
/// <summary>The operating-system identifier of this thread.</summary>
public int Id => _id;
/// <summary>The native thread handle.</summary>
internal SafeMemoryHandle Handle => _handle;
internal RemoteThread(MemoryBase memory, int threadId, SafeMemoryHandle handle)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_id = threadId;
_handle = handle ?? throw new ArgumentNullException(nameof(handle));
}
/// <summary>
/// Opens the thread specified by <paramref name="threadId"/> in the target process
/// represented by <paramref name="memory"/>.
/// </summary>
public RemoteThread(MemoryBase memory, int threadId)
: this(memory, threadId, OpenHandle(threadId))
{
}
private static SafeMemoryHandle OpenHandle(int threadId)
{
if (threadId <= 0)
throw new ArgumentException("Thread ID must be positive.", nameof(threadId));
const ThreadAccess requiredAccess =
ThreadAccess.SuspendResume |
ThreadAccess.GetContext |
ThreadAccess.SetContext |
ThreadAccess.QueryInformation;
SafeMemoryHandle handle = NativeMethods.OpenThread(requiredAccess, false, threadId);
if (handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
}
return handle;
}
/// <summary>
/// Suspends the thread and returns its previous suspend count.
/// </summary>
public uint Suspend()
{
uint result = NativeMethods.SuspendThread(_handle);
if (result == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SuspendThread failed for thread {_id}: error {error}.");
}
return result;
}
/// <summary>
/// Resumes the thread and returns its previous suspend count.
/// </summary>
public uint Resume()
{
uint result = NativeMethods.ResumeThread(_handle);
if (result == 0xFFFFFFFF)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"ResumeThread failed for thread {_id}: error {error}.");
}
return result;
}
/// <summary>
/// Reads the 64-bit native context of the thread. Valid only for 64-bit targets.
/// </summary>
public unsafe void GetContext64(out Context64 context)
{
nint size = Marshal.SizeOf<Context64>();
void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16);
try
{
Unsafe.InitBlock(ptr, 0, (uint)size);
((Context64*)ptr)->ContextFlags = ContextFlags.Amd64Full;
if (!NativeMethods.GetThreadContext(_handle, ref *(Context64*)ptr))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}.");
}
context = *(Context64*)ptr;
}
finally
{
NativeMemory.AlignedFree(ptr);
}
}
/// <summary>
/// Writes the 64-bit native context of the thread. Valid only for 64-bit targets.
/// </summary>
public unsafe void SetContext64(ref Context64 context)
{
nint size = Marshal.SizeOf<Context64>();
void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16);
try
{
*(Context64*)ptr = context;
if (!NativeMethods.SetThreadContext(_handle, ref *(Context64*)ptr))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}.");
}
}
finally
{
NativeMemory.AlignedFree(ptr);
}
}
/// <summary>
/// Reads the 32-bit native context of the thread. Valid only for 32-bit targets.
/// </summary>
public void GetContext32(out Context32 context)
{
if (_memory.Is64Bit)
{
context = default;
throw new InvalidOperationException(
"Use GetContext64 for 64-bit targets; GetContext32 is valid for 32-bit targets only.");
}
context = new Context32 { ContextFlags = ContextFlags.X86Full };
if (!NativeMethods.GetThreadContext(_handle, ref context))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}.");
}
}
/// <summary>
/// Writes the 32-bit native context of the thread. Valid only for 32-bit targets.
/// </summary>
public void SetContext32(ref Context32 context)
{
if (_memory.Is64Bit)
throw new InvalidOperationException(
"Use SetContext64 for 64-bit targets; SetContext32 is valid for 32-bit targets only.");
if (!NativeMethods.SetThreadContext(_handle, ref context))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}.");
}
}
/// <summary>
/// Returns a managed reader for this thread's Thread Environment Block.
/// </summary>
public ManagedTeb GetTeb()
{
return new ManagedTeb(_memory, _id);
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_handle.Dispose();
}
}
}
+264
View File
@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic.Native;
namespace WhiteMagic.Thread;
/// <summary>
/// Enumerates and selects threads belonging to the target process.
/// </summary>
public sealed class ThreadFactory
{
private readonly MemoryBase _memory;
/// <summary>Creates a factory bound to the target process represented by <paramref name="memory"/>.</summary>
public ThreadFactory(MemoryBase memory)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
}
/// <summary>
/// Enumerates every thread that belongs to the target process.
/// </summary>
public IEnumerable<RemoteThread> Enumerate()
{
foreach (int threadId in CollectThreadIds())
{
SafeMemoryHandle handle = NativeMethods.OpenThread(
ThreadAccess.SuspendResume |
ThreadAccess.GetContext |
ThreadAccess.SetContext |
ThreadAccess.QueryInformation,
false,
threadId);
if (handle.IsInvalid)
continue;
yield return new RemoteThread(_memory, threadId, handle);
}
}
private int[] CollectThreadIds()
{
using SafeMemoryHandle snapshot = NativeMethods.CreateToolhelp32Snapshot(SnapshotFlags.Thread, 0);
if (snapshot.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"CreateToolhelp32Snapshot failed: error {error}.");
}
var entry = new ThreadEntry32
{
dwSize = (uint)Marshal.SizeOf<ThreadEntry32>()
};
var ids = new List<int>();
if (!NativeMethods.Thread32First(snapshot, ref entry))
{
int error = Marshal.GetLastPInvokeError();
if (error == 18 || error == 259) // ERROR_NO_MORE_FILES / ERROR_NO_MORE_ITEMS
return ids.ToArray();
throw new InvalidOperationException($"Thread32First failed: error {error}.");
}
do
{
if (entry.th32OwnerProcessID == (uint)_memory.ProcessId)
ids.Add((int)entry.th32ThreadID);
}
while (NativeMethods.Thread32Next(snapshot, ref entry));
return ids.ToArray();
}
/// <summary>
/// Returns the thread with the specified operating-system identifier if it belongs
/// to the target process.
/// </summary>
/// <exception cref="InvalidOperationException">The thread does not belong to the target process.</exception>
public RemoteThread GetThreadById(int threadId)
{
if (threadId <= 0)
throw new ArgumentException("Thread ID must be positive.", nameof(threadId));
const ThreadAccess requiredAccess =
ThreadAccess.SuspendResume |
ThreadAccess.GetContext |
ThreadAccess.SetContext |
ThreadAccess.QueryInformation;
SafeMemoryHandle handle = NativeMethods.OpenThread(requiredAccess, false, threadId);
if (handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
}
try
{
var info = new ThreadBasicInformation();
int status = NativeMethods.NtQueryInformationThread(
handle,
0,
ref info,
(uint)Marshal.SizeOf<ThreadBasicInformation>(),
out _);
if (status < 0)
{
throw new InvalidOperationException(
$"NtQueryInformationThread failed for thread {threadId} (NTSTATUS {status:X8}).");
}
if ((uint)(nint)info.ClientId.UniqueProcess != (uint)_memory.ProcessId)
{
throw new InvalidOperationException(
$"Thread {threadId} does not belong to process {_memory.ProcessId}.");
}
// Ownership of the validated handle transfers to the RemoteThread.
return new RemoteThread(_memory, threadId, handle);
}
catch
{
handle.Dispose();
throw;
}
}
/// <summary>
/// Returns the earliest-created thread of the target process.
/// </summary>
public RemoteThread MainThread
{
get
{
RemoteThread? earliest = null;
long earliestTime = long.MaxValue;
foreach (RemoteThread thread in Enumerate())
{
long creationTime = GetCreationTime(thread.Id);
if (creationTime < earliestTime)
{
earliestTime = creationTime;
earliest?.Dispose();
earliest = thread;
}
else
{
thread.Dispose();
}
}
if (earliest is null)
{
throw new InvalidOperationException(
$"Process {_memory.ProcessId} has no observable threads.");
}
return earliest;
}
}
/// <summary>
/// Suspends the supplied threads and returns a disposable scope that resumes exactly
/// those threads when disposed, including when an exception escapes the guarded body.
/// </summary>
/// <remarks>
/// Do not freeze the target's threads while executing target code through a remote
/// thread or main-thread pump; doing so can deadlock because the frozen thread is the
/// one responsible for running the code.
/// </remarks>
public FrozenThread Freeze(IEnumerable<RemoteThread> threads)
{
ArgumentNullException.ThrowIfNull(threads);
var suspended = new List<RemoteThread>();
try
{
foreach (RemoteThread thread in threads)
{
thread.Suspend();
suspended.Add(thread);
}
return new FrozenThread(suspended);
}
catch
{
foreach (RemoteThread thread in suspended)
{
try
{
thread.Resume();
}
catch
{
// Best-effort unwind.
}
}
throw;
}
}
/// <summary>
/// Suspends all target threads selected by <paramref name="predicate"/>.
/// </summary>
public FrozenThread Freeze(Func<RemoteThread, bool> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var selected = new List<RemoteThread>();
try
{
foreach (RemoteThread thread in Enumerate())
{
try
{
if (predicate(thread))
selected.Add(thread);
else
thread.Dispose();
}
catch
{
thread.Dispose();
throw;
}
}
return Freeze(selected);
}
catch
{
foreach (RemoteThread thread in selected)
thread.Dispose();
throw;
}
}
private long GetCreationTime(int threadId)
{
using SafeMemoryHandle handle = NativeMethods.OpenThread(ThreadAccess.QueryInformation, false, threadId);
if (handle.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}.");
}
if (!NativeMethods.GetThreadTimes(handle, out long creationTime, out _, out _, out _))
{
int error = Marshal.GetLastPInvokeError();
throw new InvalidOperationException($"GetThreadTimes failed for thread {threadId}: error {error}.");
}
return creationTime;
}
}
+9
View File
@@ -13,4 +13,13 @@
<InternalsVisibleTo Include="WhiteMagicTest" /> <InternalsVisibleTo Include="WhiteMagicTest" />
</ItemGroup> </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> </Project>
+152
View File
@@ -0,0 +1,152 @@
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);
}
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
@@ -0,0 +1,74 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
using WhiteMagic.Native;
namespace WhiteMagic.Windows;
/// <summary>
/// Factory for enumerating and locating <see cref="RemoteWindow"/> instances.
/// </summary>
public static class WindowFactory
{
/// <summary>Enumerates all top-level windows.</summary>
public static unsafe IEnumerable<RemoteWindow> GetWindows()
{
var handles = new List<IntPtr>();
GCHandle gch = GCHandle.Alloc(handles);
try
{
delegate* unmanaged[Stdcall]<IntPtr, IntPtr, int> callback = &EnumWindowsCallback;
NativeMethods.EnumWindows((nint)callback, GCHandle.ToIntPtr(gch));
}
finally
{
gch.Free();
}
return handles.Select(static h => new RemoteWindow(h));
}
/// <summary>Returns all top-level windows with the specified class name.</summary>
public static IEnumerable<RemoteWindow> GetWindowsByClassName(string className)
{
if (className is null)
throw new ArgumentNullException(nameof(className));
return GetWindows().Where(w => w.ClassName.Equals(className, StringComparison.Ordinal));
}
/// <summary>Returns all top-level windows owned by the specified process.</summary>
public static IEnumerable<RemoteWindow> GetWindowsByProcessId(int processId)
{
return GetWindows().Where(w => w.ProcessId == (uint)processId);
}
/// <summary>Returns the first top-level window with the specified class name.</summary>
public static RemoteWindow? GetWindowByClassName(string className)
{
return GetWindowsByClassName(className).FirstOrDefault();
}
/// <summary>
/// Returns the main window of a process. When <see cref="Process.MainWindowHandle"/>
/// is unavailable, falls back to the first enumerated window owned by the process.
/// </summary>
public static RemoteWindow? GetMainWindow(System.Diagnostics.Process process)
{
if (process is null)
throw new ArgumentNullException(nameof(process));
if (process.MainWindowHandle != IntPtr.Zero)
return new RemoteWindow(process.MainWindowHandle);
return GetWindowsByProcessId(process.Id).FirstOrDefault();
}
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
private static int EnumWindowsCallback(IntPtr hWnd, IntPtr lParam)
{
var handles = (List<IntPtr>)GCHandle.FromIntPtr(lParam).Target!;
handles.Add(hWnd);
return 1; // Continue enumeration.
}
}
@@ -0,0 +1,159 @@
using System.Linq;
using Iced.Intel;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Hooking;
namespace WhiteMagicTest.Assembly;
/// <summary>
/// Tests for the optional <see cref="IcedAssembler"/> backend (tasks 8.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);
}
}
@@ -0,0 +1,245 @@
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Discovery;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PatternScannerCache"/>.
/// </summary>
public class PatternScannerCacheTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void FindCached_returns_same_result_on_second_call()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
// Create a buffer with a known pattern
byte[] buffer = new byte[256];
buffer[30] = 0x11;
buffer[31] = 0x22;
buffer[32] = 0x33;
buffer[33] = 0x44;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0x11, 0x22, 0x33, 0x44 };
// First call should scan memory
IntPtr first = cache.FindCached(pattern, null, addr, end);
// Second call should return cached result
IntPtr second = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 30, first);
Assert.Equal(first, second);
// Value equality must mean the second call reused the cached entry.
var cacheField = typeof(PatternScannerCache).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!;
var cacheDict = cacheField.GetValue(cache)!;
int count = (int)cacheDict.GetType().GetProperty("Count")!.GetValue(cacheDict)!;
Assert.Equal(1, count);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindCached_value_equality_uses_content_not_reference()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[10] = 0xAA;
buffer[11] = 0xBB;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern1 = { 0xAA, 0xBB };
byte[] pattern2 = { 0xAA, 0xBB };
IntPtr first = cache.FindCached(pattern1, null, addr, end);
IntPtr second = cache.FindCached(pattern2, null, addr, end);
Assert.Equal(addr + 10, first);
Assert.Equal(first, second);
var cacheField = typeof(PatternScannerCache).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!;
var cacheDict = cacheField.GetValue(cache)!;
int count = (int)cacheDict.GetType().GetProperty("Count")!.GetValue(cacheDict)!;
Assert.Equal(1, count);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindCached_different_ranges_are_cached_separately()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
// Create two separate buffers
byte[] buffer1 = new byte[128];
buffer1[10] = 0xAA;
buffer1[11] = 0xBB;
byte[] buffer2 = new byte[128];
buffer2[20] = 0xAA;
buffer2[21] = 0xBB;
GCHandle pin1 = GCHandle.Alloc(buffer1, GCHandleType.Pinned);
GCHandle pin2 = GCHandle.Alloc(buffer2, GCHandleType.Pinned);
try
{
IntPtr addr1 = pin1.AddrOfPinnedObject();
IntPtr end1 = addr1 + buffer1.Length;
IntPtr addr2 = pin2.AddrOfPinnedObject();
IntPtr end2 = addr2 + buffer2.Length;
byte[] pattern = { 0xAA, 0xBB };
IntPtr found1 = cache.FindCached(pattern, null, addr1, end1);
IntPtr found2 = cache.FindCached(pattern, null, addr2, end2);
Assert.Equal(addr1 + 10, found1);
Assert.Equal(addr2 + 20, found2);
Assert.NotEqual(found1, found2);
}
finally
{
pin1.Free();
pin2.Free();
}
}
[Fact]
public void FindCached_with_mask_caches_correctly()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[40] = 0x99;
buffer[41] = 0x88; // This is wildcard
buffer[42] = 0x77;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0x99, 0x00, 0x77 };
string mask = "x?x";
IntPtr first = cache.FindCached(pattern, mask, addr, end);
IntPtr second = cache.FindCached(pattern, mask, addr, end);
Assert.Equal(addr + 40, first);
Assert.Equal(first, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void Clear_clears_cached_results()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[50] = 0xCC;
buffer[51] = 0xDD;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0xCC, 0xDD };
// Cache a result
IntPtr first = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 50, first);
// Clear the cache
cache.Clear();
// This should rescan (not return cached result)
IntPtr second = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 50, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindInModuleCached_caches_module_scans()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
// MZ header is always at the start of the main module
byte[] pattern = { 0x4D, 0x5A };
IntPtr first = cache.FindInModuleCached(pattern, null, mainModule);
IntPtr second = cache.FindInModuleCached(pattern, null, mainModule);
Assert.Equal(mainModule.BaseAddress, first);
Assert.Equal(first, second);
}
[Fact]
public void FindInModulesCached_caches_multiple_modules()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var modules = currentProcess.Modules.Cast<System.Diagnostics.ProcessModule>().ToList();
Assert.NotEmpty(modules);
// MZ header should be present in at least one module
byte[] pattern = { 0x4D, 0x5A };
IntPtr first = cache.FindInModulesCached(pattern, null, modules);
IntPtr second = cache.FindInModulesCached(pattern, null, modules);
Assert.NotEqual(IntPtr.Zero, first);
Assert.Equal(first, second);
}
}
@@ -0,0 +1,188 @@
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Discovery;
using WhiteMagicTest;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PatternScanner"/>.
/// </summary>
public class PatternScannerTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void Find_exact_pattern_returns_correct_address()
{
using var reader = CreateReader();
// Create a buffer with known bytes
byte[] buffer = new byte[256];
buffer[10] = 0xDE;
buffer[11] = 0xAD;
buffer[12] = 0xBE;
buffer[13] = 0xEF;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search for the exact pattern
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(addr + 10, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_with_wildcard_mask_ignores_wildcard_bytes()
{
using var reader = CreateReader();
// Create a buffer with known bytes
byte[] buffer = new byte[256];
buffer[20] = 0x12;
buffer[21] = 0x34; // This byte is wildcard
buffer[22] = 0x56;
buffer[23] = 0x78;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search with wildcard mask (x = match, ? = wildcard)
byte[] pattern = { 0x12, 0x00, 0x56, 0x78 };
string mask = "x?xx"; // Second byte is wildcard
IntPtr found = PatternScanner.Find(reader, pattern, mask, addr, end);
Assert.Equal(addr + 20, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_pattern_not_found_returns_zero()
{
using var reader = CreateReader();
// Create a buffer without the target pattern
byte[] buffer = new byte[256];
for (int i = 0; i < buffer.Length; i++)
buffer[i] = 0xAA;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search for pattern that doesn't exist
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(IntPtr.Zero, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_empty_pattern_throws()
{
using var reader = CreateReader();
byte[] pattern = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, null, IntPtr.Zero, (IntPtr)1000));
Assert.Contains("Pattern cannot be empty", ex.Message);
}
[Fact]
public void Find_mask_length_mismatch_throws()
{
using var reader = CreateReader();
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
string mask = "xxx"; // Wrong length
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
Assert.Contains("Mask length", ex.Message);
}
[Fact]
public void Find_invalid_mask_char_throws()
{
using var reader = CreateReader();
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
string mask = "axxx"; // 'a' is invalid
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
}
[Fact]
public void Find_null_mask_treats_all_as_exact()
{
using var reader = CreateReader();
byte[] buffer = new byte[256];
buffer[50] = 0xAB;
buffer[51] = 0xCD;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Null mask should behave like "xx" (exact match)
byte[] pattern = { 0xAB, 0xCD };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(addr + 50, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindInModule_scans_current_process_module()
{
using var reader = CreateReader();
// Get the current process's main module
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
// MZ header is always at the start of the main module
byte[] pattern = { 0x4D, 0x5A };
IntPtr found = PatternScanner.FindInModule(reader, pattern, null, mainModule);
Assert.Equal(mainModule.BaseAddress, found);
}
}
@@ -0,0 +1,156 @@
using WhiteMagic;
using WhiteMagic.Discovery;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PeHeaderParser"/>.
/// </summary>
public class PeHeaderParserTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void EntryPoint_returns_nonzero_for_current_module()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
IntPtr entryPoint = parser.EntryPoint;
// Entry point should be a valid RVA (non-zero for a valid PE)
Assert.NotEqual(IntPtr.Zero, entryPoint);
// Entry point should be less than module size
Assert.True((nint)entryPoint < mainModule.ModuleMemorySize);
}
[Fact]
public void Sections_enumerates_at_least_text_section()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.ToList();
Assert.NotEmpty(sections);
// Every PE file should have a .text section (or similar)
var textSection = sections.FirstOrDefault(s =>
s.Name.Equals(".text", StringComparison.OrdinalIgnoreCase) ||
s.Name.Equals("TEXT", StringComparison.OrdinalIgnoreCase));
// May not find ".text" exactly, but should have at least some sections
Assert.True(sections.Count >= 1);
}
[Fact]
public void Sections_have_valid_properties()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.ToList();
foreach (var section in sections)
{
// Name should not be empty
Assert.False(string.IsNullOrWhiteSpace(section.Name));
// Virtual address should be within module bounds
Assert.True((nint)section.VirtualAddress < mainModule.ModuleMemorySize);
// Virtual size should be positive
Assert.True(section.VirtualSize > 0);
}
}
[Fact]
public void Sections_have_common_names()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.Select(s => s.Name).ToList();
// At least some common section names should be present
var commonNames = new[] { ".text", ".data", ".rdata", ".bss" };
bool hasCommonSection = commonNames.Any(name =>
sections.Contains(name, StringComparer.OrdinalIgnoreCase));
// This might not always be true, but for managed EXEs it usually is
// We'll just verify sections were enumerated
Assert.NotEmpty(sections);
}
[Fact]
public void EntryPoint_is_consistent_across_calls()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
IntPtr first = parser.EntryPoint;
IntPtr second = parser.EntryPoint;
Assert.Equal(first, second);
}
[Fact]
public void Sections_are_consistent_across_calls()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var first = parser.Sections.ToList();
var second = parser.Sections.ToList();
Assert.Equal(first.Count, second.Count);
for (int i = 0; i < first.Count; i++)
{
Assert.Equal(first[i].Name, second[i].Name);
Assert.Equal(first[i].VirtualAddress, second[i].VirtualAddress);
Assert.Equal(first[i].VirtualSize, second[i].VirtualSize);
}
}
[Fact]
public void Constructor_throws_on_zero_base_address()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentException>(() =>
new PeHeaderParser(reader, IntPtr.Zero));
Assert.Contains("Base address cannot be zero", ex.Message);
}
}
@@ -0,0 +1,77 @@
using System;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Execution;
using Xunit;
namespace WhiteMagicTest.Execution;
/// <summary>
/// Tests for <see cref="InProcessInvoker"/> operating in-process.
/// </summary>
public class InProcessInvokerTests
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AddDelegate(int a, int b);
private static int NativeAdd(int a, int b) => a + b;
[Fact]
public void CreateFunction_calls_known_in_process_function()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
var native = new AddDelegate(NativeAdd);
IntPtr functionPointer = Marshal.GetFunctionPointerForDelegate(native);
AddDelegate callable = invoker.CreateFunction<AddDelegate>(functionPointer);
int result = callable(5, 7);
Assert.Equal(12, result);
GC.KeepAlive(native);
}
[Fact]
public void CreateFunction_rejects_zero_address()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
ArgumentException ex = Assert.Throws<ArgumentException>(() => invoker.CreateFunction<AddDelegate>(IntPtr.Zero));
Assert.Equal("address", ex.ParamName);
}
[Fact]
public void Vtable_helper_reads_function_pointer_slot()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
// Build a tiny fake vtable in a pinned buffer: two slots holding known pointers.
IntPtr slot0 = Marshal.GetFunctionPointerForDelegate(new AddDelegate(NativeAdd));
IntPtr slot1 = new IntPtr(0x12345678);
IntPtr[] vtable;
if (reader.Is64Bit)
{
vtable = [slot0, slot1];
}
else
{
vtable = [slot0, slot1];
}
GCHandle pin = GCHandle.Alloc(vtable, GCHandleType.Pinned);
try
{
IntPtr vTableAddress = pin.AddrOfPinnedObject();
Assert.Equal(slot0, invoker.ReadVTableFunction(vTableAddress, 0));
Assert.Equal(slot1, invoker.ReadVTableFunction(vTableAddress, 1));
}
finally
{
pin.Free();
}
}
}
@@ -0,0 +1,329 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Execution;
using WhiteMagic.Native;
namespace WhiteMagicTest.Execution;
public sealed class RemoteThreadExecutorTests
{
// x64 payloads. Live execution tests run only on x64 because the payloads use the
// Microsoft x64 ABI (integer args in RCX, RDX, R8, R9, then stack at [rsp+0x28]).
// mov eax, ecx
// add eax, edx
// ret
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
// mov eax, ecx
// add eax, edx
// add eax, r8d
// add eax, r9d
// add eax, [rsp+0x28]
// ret
private static readonly byte[] SumFivePayload =
[
0x89, 0xC8,
0x01, 0xD0,
0x44, 0x01, 0xC0,
0x44, 0x01, 0xC8,
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
0xC3
];
// Five-arg callee that also executes an alignment-sensitive SSE instruction, proving
// the stub delivers a 16-byte-aligned stack the CPU actually accepts (movaps #GPs on a
// misaligned address) alongside correct register+stack argument placement.
// sub rsp, 24 ; entry rsp ≡ 8 (mod 16) -> rsp ≡ 0 (16-aligned), giving a
// ; 16-byte aligned scratch at [rsp..rsp+16) below the saved
// ; return address ([rsp+24]) so the store leaves it intact
// movaps [rsp], xmm0 ; aligned 16-byte store — faults unless rsp is 16-aligned
// add rsp, 24 ; restore
// mov eax, ecx
// add eax, edx
// add eax, r8d
// add eax, r9d
// add eax, [rsp+0x28] ; 5th arg above the shadow space
// ret
private static readonly byte[] SseAlignedSumPayload =
[
0x48, 0x83, 0xEC, 0x18,
0x0F, 0x29, 0x04, 0x24,
0x48, 0x83, 0xC4, 0x18,
0x89, 0xC8,
0x01, 0xD0,
0x44, 0x01, 0xC0,
0x44, 0x01, 0xC8,
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
0xC3
];
// xor eax, eax
// cmp byte ptr [rcx+rax], 0
// je done
// inc eax
// jmp loop
// done: ret
private static readonly byte[] Utf8LengthPayload =
[
0x31, 0xC0,
0x80, 0x3C, 0x01, 0x00,
0x74, 0x04,
0xFF, 0xC0,
0xEB, 0xF6,
0xC3
];
// mov eax, [rcx]
// add eax, [rcx+4]
// ret
private static readonly byte[] PointSumPayload = [0x8B, 0x01, 0x03, 0x41, 0x04, 0xC3];
// Measures the callee's entry stack alignment without faulting.
// mov eax, esp
// add eax, 8
// and eax, 0x0F
// ret
// Returns (rsp + 8) & 15, which is 0 iff callee entry rsp ≡ 8 (mod 16) — the
// Microsoft x64 ABI guarantee the stub must deliver.
private static readonly byte[] AlignProbePayload = [0x89, 0xE0, 0x83, 0xC0, 0x08, 0x83, 0xE0, 0x0F, 0xC3];
[StructLayout(LayoutKind.Sequential)]
private struct Point
{
public int X;
public int Y;
}
[Fact]
public void Execute_adds_two_integers()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(AddPayload, CallConvention.Cdecl, 10, 32);
Assert.Equal(42, result);
}
[Fact]
public void Execute_sums_register_and_stack_arguments()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(SumFivePayload, CallConvention.Cdecl, 1, 2, 3, 4, 5);
Assert.Equal(15, result);
}
[Fact]
public void Execute_delivers_16byte_aligned_stack_to_callee()
{
if (!Environment.Is64BitProcess)
{
return;
}
// Stub entry rsp ≡ 8 → sub rsp, K (K ≡ 8) → rsp ≡ 0 → call → callee entry rsp ≡ 8.
// So (rsp + 8) & 15 == 0 when the frame math is right; a bad K (e.g. 0x20) yields 8.
int misalign = RunPayload(AlignProbePayload, CallConvention.Cdecl);
Assert.Equal(0, misalign);
}
[Fact]
public void Execute_runs_sse_callee_with_five_args()
{
if (!Environment.Is64BitProcess)
{
return;
}
// Correct result (150) requires BOTH the 5th arg reaching [rsp+0x28] AND the
// aligned movaps not faulting. A broken frame size/alignment either mis-sums or
// #GPs in the callee.
int result = RunPayload(SseAlignedSumPayload, CallConvention.Cdecl, 10, 20, 30, 40, 50);
Assert.Equal(150, result);
}
[Fact]
public void Execute_marshals_string_as_utf8_pointer()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(Utf8LengthPayload, CallConvention.Cdecl, "hello");
Assert.Equal(5, result);
}
[Fact]
public void Execute_marshals_struct_as_pointer()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(PointSumPayload, CallConvention.Cdecl, new Point { X = 30, Y = 12 });
Assert.Equal(42, result);
}
[Fact]
public void Execute_throws_when_handle_is_invalid()
{
var executor = new RemoteThreadExecutor(new InvalidProcessReader());
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
{
executor.Execute<int>((IntPtr)0x1234, CallConvention.Cdecl);
});
Assert.Contains("handle", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Execute_throws_when_address_is_zero()
{
using var reader = new InProcessReader();
var executor = new RemoteThreadExecutor(reader);
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
{
executor.Execute<int>(IntPtr.Zero, CallConvention.Cdecl);
});
Assert.Equal("address", ex.ParamName);
}
[Fact]
public void InProcessReader_reports_current_process_bitness()
{
using var reader = new InProcessReader();
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
}
[Fact]
public void ExternalReader_reports_current_process_bitness()
{
using var reader = new ExternalReader(Process.GetCurrentProcess());
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
}
[Fact]
public void Execute_releases_allocated_remote_memory_on_write_failure()
{
using var reader = new WriteFailingMemoryBase();
var executor = new RemoteThreadExecutor(reader);
var allocated = new List<IntPtr>();
var freed = new List<IntPtr>();
nint next = 0x4000_0000;
executor.RemoteAllocator = size => { var p = (IntPtr)(next += 0x1000); allocated.Add(p); return p; };
executor.RemoteReleaser = p => freed.Add(p);
// The string arg is marshalled to remote scratch FIRST, then its write fails.
// Pre-fix the scratch was tracked only AFTER the write, so it escaped the finally
// free and leaked. Post-fix every allocation is released on the failure path.
Assert.Throws<InvalidOperationException>(() =>
executor.Execute<int>(new IntPtr(0x123456789ABCDEF0L), CallConvention.Stdcall, "leakme"));
Assert.NotEmpty(allocated); // the arg scratch was allocated
Assert.Equal(allocated.OrderBy(x => x), freed.OrderBy(x => x)); // and every alloc freed
}
private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
{
const nint pageSize = 4096;
const nint blockSize = pageSize * 2;
using var reader = new InProcessReader();
var executor = new RemoteThreadExecutor(reader);
// Allocate a single executable block. The payload lives at the start and the
// generated call stub is written to the second page, guaranteeing that the
// relative CALL instruction stays within its ±2 GiB range.
IntPtr block = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
blockSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, block);
IntPtr stubAddress = block + pageSize;
executor.StubAllocator = (_, size) => size <= pageSize ? stubAddress : IntPtr.Zero;
try
{
int written = reader.WriteBytes(block, payload);
Assert.Equal(payload.Length, written);
return executor.Execute<int>(block, convention, args);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
}
}
private sealed class InvalidProcessReader : MemoryBase
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle { get; } = new SafeMemoryHandle(new IntPtr(-1));
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
=> throw new NotSupportedException();
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
=> throw new NotSupportedException();
public override void Dispose()
{
}
}
/// <summary>
/// A fake reader whose WriteBytes always returns zero, forcing the executor down
/// the failure path after it has allocated remote memory.
/// Holds a valid handle to the current process so the executor passes its
/// handle-validity check without performing real memory operations.
/// </summary>
private sealed class WriteFailingMemoryBase : MemoryBase
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle { get; }
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public WriteFailingMemoryBase()
{
Handle = NativeMethods.OpenProcess(ProcessAccess.AllAccess, false, Environment.ProcessId);
}
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
=> throw new NotSupportedException();
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
=> 0;
public override void Dispose()
{
Handle?.Dispose();
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for the high-level facade (<see cref="Magic"/>) and <see cref="RemotePointer"/>.
/// </summary>
public class HighLevelTests
{
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
[Fact]
public void OpenExternal_returns_session_for_current_process()
{
using var magic = Magic.Open(Process.GetCurrentProcess());
Assert.NotNull(magic.Memory);
Assert.False(magic.Memory.Handle.IsInvalid);
Assert.Same(magic.Memory.PatchManager, magic.PatchManager);
Assert.Same(magic.Memory.DetourManager, magic.DetourManager);
}
[Fact]
public void OpenInProcess_returns_session_for_self()
{
using var magic = Magic.OpenInProcess();
Assert.IsType<InProcessReader>(magic.Memory);
Assert.False(magic.Memory.Handle.IsInvalid);
}
[Fact]
public void Indexer_returns_remote_pointer_that_reads_and_writes_relative()
{
using var magic = Magic.OpenInProcess();
byte[] slot = new byte[16];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr baseAddr = pin.AddrOfPinnedObject();
magic[baseAddr + 4].Write(0x12345678);
Assert.Equal(0x12345678, magic[baseAddr].Read<int>(4));
}
finally
{
pin.Free();
}
}
[Fact]
public async Task RemoteThread_ExecuteAsync_runs_payload_and_returns_result()
{
if (!Environment.Is64BitProcess)
{
return;
}
using var magic = Magic.OpenInProcess();
IntPtr payload = NativeMethods.VirtualAllocEx(
magic.Memory.Handle,
IntPtr.Zero,
4096,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, payload);
try
{
magic.Memory.WriteBytes(payload, AddPayload);
int result = await magic.RemoteThread.ExecuteAsync<int>(payload, CallConvention.Cdecl, 10, 32);
Assert.Equal(42, result);
}
finally
{
NativeMethods.VirtualFreeEx(magic.Memory.Handle, payload, 0, MemoryFreeType.Release);
}
}
}
+429
View File
@@ -0,0 +1,429 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic;
using WhiteMagic.Hooking;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest.Hooking;
/// <summary>
/// Tests for <see cref="PatchManager"/>, <see cref="DetourManager"/> and
/// <see cref="Execution.MainThreadPump"/> operating in-process.
/// </summary>
public class HookingTests
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int FrameFunc();
private const int FrameResult = 42;
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
/// <summary>
/// Allocates a tiny executable function whose prologue is made entirely of
/// covered instruction shapes, so detours apply cleanly in tests.
/// </summary>
private static IntPtr AllocateFrameStub(MemoryBase reader, out IntPtr allocationBase)
{
// x64: push rbp; push rdi; push rsi; push rbx; sub rsp, 0x28; sub rsp, 0x12345678;
// mov eax, 42; add rsp, 0x12345678; add rsp, 0x28; pop rbx; pop rsi; pop rdi; pop rbp; ret
byte[] code =
[
0x55, // push rbp
0x57, // push rdi
0x56, // push rsi
0x53, // push rbx
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12, // sub rsp, 0x12345678
0xB8, 0x2A, 0x00, 0x00, 0x00, // mov eax, 42
0x48, 0x81, 0xC4, 0x78, 0x56, 0x34, 0x12, // add rsp, 0x12345678
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0x5B, // pop rbx
0x5E, // pop rsi
0x5F, // pop rdi
0x5D, // pop rbp
0xC3 // ret
];
allocationBase = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
code.Length,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, allocationBase);
reader.WriteBytes(allocationBase, code);
return allocationBase;
}
[Fact]
public void Patch_apply_writes_bytes_and_remove_restores_original()
{
using var reader = CreateReader();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] original = reader.ReadBytes(addr, 4);
byte[] patchBytes = [0x90, 0x90, 0x90, 0x90];
Patch patch = reader.PatchManager.Create("nop", addr, patchBytes);
Assert.False(patch.IsApplied);
patch.Apply();
Assert.True(patch.IsApplied);
Assert.Equal(patchBytes, reader.ReadBytes(addr, 4));
patch.Remove();
Assert.False(patch.IsApplied);
Assert.Equal(original, reader.ReadBytes(addr, 4));
}
finally
{
pin.Free();
}
}
[Fact]
public void Patch_apply_on_execute_only_memory_succeeds()
{
using var reader = CreateReader();
byte[] code = [0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3]; // mov eax, 42; ret
IntPtr alloc = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
code.Length,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, alloc);
try
{
reader.WriteBytes(alloc, code);
// Remove write access. Without VirtualProtectEx in Patch.Apply,
// applying a patch would fail because the page is read-only for writes.
Assert.True(NativeMethods.VirtualProtectEx(
reader.Handle,
alloc,
code.Length,
MemoryProtectionType.ExecuteRead,
out MemoryProtectionType _));
Patch patch = reader.PatchManager.Create("nop-ret", alloc, [0x90, 0x90]);
patch.Apply();
Assert.True(patch.IsApplied);
patch.Remove();
Assert.False(patch.IsApplied);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, alloc, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_apply_redirects_callOriginal_remove_restores()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
int hookCalls = 0;
Detour? detour = null;
FrameFunc hook = () =>
{
hookCalls++;
return (int?)detour?.CallOriginal() ?? 0;
};
detour = reader.DetourManager.Create("frame", targetPtr, hook);
detour.Apply();
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
int result = routed();
Assert.True(hookCalls > 0);
Assert.Equal(FrameResult, result);
detour.Remove();
hookCalls = 0;
result = routed();
Assert.Equal(0, hookCalls);
Assert.Equal(FrameResult, result);
GC.KeepAlive(hook);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_named_lookup_returns_existing_detour()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
Detour detour = reader.DetourManager.Create("lookup", targetPtr, (FrameFunc)(() => FrameResult));
Assert.Same(detour, reader.DetourManager["lookup"]);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_aligned_prologue_applies_and_unknown_prologue_rejects()
{
using var reader = CreateReader();
// A normal JIT-compiled function has a covered prologue shape.
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr goodAllocation);
try
{
Detour good = reader.DetourManager.Create("good", targetPtr, (FrameFunc)(() => FrameResult));
good.Apply();
good.Remove();
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, goodAllocation, 0, MemoryFreeType.Release);
}
// Allocate a small executable region whose first instruction is outside the
// covered set. The decoder must refuse to splice it.
IntPtr code = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
32,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, code);
try
{
// 0x0F 0x05 = syscall (not covered), followed by padding and a ret.
byte[] unknown = [0x0F, 0x05, 0xC3, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC];
reader.WriteBytes(code, unknown);
Detour bad = reader.DetourManager.Create("bad", code, (FrameFunc)(() => FrameResult));
Assert.Throws<InvalidOperationException>(() => bad.Apply());
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, code, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Dispose_restores_active_patches_and_detours()
{
InProcessReader reader = CreateReader();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] original = reader.ReadBytes(addr, 2);
Patch patch = reader.PatchManager.Create("dispose-patch", addr, [0x90, 0x90]);
patch.Apply();
reader.Dispose();
// Verify with a fresh reader; the original handle was closed by Dispose.
using var verify = CreateReader();
Assert.Equal(original, verify.ReadBytes(addr, 2));
}
finally
{
pin.Free();
}
}
[Fact]
public async Task MainThreadPump_drains_work_on_frame_call_and_uninstalls_on_dispose()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> work = pump.ExecuteAsync(() => 123);
// Drive the frame function manually. The detoured frame runs the pump hook on
// this thread, drains the work queue, then calls the original frame function.
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
int frameResult = routed();
Assert.Equal(FrameResult, frameResult);
Assert.Equal(123, await work);
pump.Dispose();
// After uninstall, calling the frame function should behave like the original.
routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
Assert.Equal(FrameResult, routed());
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_exception_survives_and_does_not_kill_pump()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> bad = pump.ExecuteAsync<int>(() => throw new InvalidOperationException("boom"));
Task<int> good = pump.ExecuteAsync(() => 7);
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
routed();
await Assert.ThrowsAsync<InvalidOperationException>(() => bad);
Assert.Equal(7, await good);
pump.Dispose();
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_dispose_faults_pending_work()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> pending = pump.ExecuteAsync(() => 42);
pump.Dispose();
var ex = await Assert.ThrowsAsync<ObjectDisposedException>(() => pending);
Assert.Equal(nameof(WhiteMagic.Execution.MainThreadPump), ex.ObjectName);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_dispose_while_execute_blocked_does_not_deadlock()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
// Start Execute on another thread; it will block until Dispose drains the queue.
Task executeTask = Task.Run(() =>
{
try
{
pump.Execute<int>(() => 42);
}
catch (ObjectDisposedException)
{
}
});
// Give Execute time to pass the gate and block on the TCS.
await Task.Delay(50);
pump.Dispose();
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(executeTask, completed);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_concurrent_dispose_and_pump_does_not_throw()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
var tasks = new List<Task<int>>();
for (int i = 0; i < 50; i++)
{
int value = i;
tasks.Add(pump.ExecuteAsync(() => value));
}
// Drive the detoured frame while disposing from another thread.
// This stresses the race between PumpHook completing work and
// Dispose faulting still-queued work.
Task drive = Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
try { routed(); } catch { }
}
});
await Task.Delay(10);
pump.Dispose();
await drive;
// Any faulted task must have been cancelled by Dispose; no
// unhandled exceptions should escape from the pump itself.
foreach (Task<int> task in tasks)
{
if (task.IsFaulted)
{
Assert.IsType<ObjectDisposedException>(task.Exception!.InnerException);
}
}
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
}
@@ -0,0 +1,42 @@
using WhiteMagic.Hooking;
using Xunit;
namespace WhiteMagicTest.Hooking;
/// <summary>
/// Tests for <see cref="PrologueDecoder"/> covering the accepted x86/x64 prologue
/// shapes and rejection of opcodes outside the covered set.
/// </summary>
public class PrologueDecoderTests
{
[Theory]
[InlineData(false, new byte[] { 0x55 }, 1)] // push rbp
[InlineData(false, new byte[] { 0x53 }, 1)] // push rbx
[InlineData(false, new byte[] { 0x8B, 0xFF }, 2)] // mov edi, edi
[InlineData(false, new byte[] { 0x8B, 0xEC }, 2)] // mov ebp, esp
[InlineData(false, new byte[] { 0x83, 0xEC, 0x20 }, 3)] // sub esp, 0x20
[InlineData(false, new byte[] { 0x81, 0xEC, 0x00, 0x01, 0x00, 0x00 }, 6)] // sub esp, 0x100
[InlineData(true, new byte[] { 0x48, 0x8B, 0xEC }, 3)] // mov rbp, rsp
[InlineData(true, new byte[] { 0x48, 0x83, 0xEC, 0x28 }, 4)] // sub rsp, 0x28
[InlineData(true, new byte[] { 0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12 }, 7)] // sub rsp, 0x12345678
public void Decodes_covered_prologue_shapes(bool is64Bit, byte[] bytes, int expectedLength)
{
int length = PrologueDecoder.GetInstructionLength(bytes, is64Bit);
Assert.Equal(expectedLength, length);
}
[Theory]
[InlineData(false, new byte[] { 0x83, 0x05, 0x39, 0x00, 0x00, 0x00, 0x01 })] // add [rip+0x39], 1 — wrong modrm
[InlineData(false, new byte[] { 0x83, 0x3D, 0x00, 0x00, 0x00, 0x00, 0x01 })] // cmp [rip], 1 — wrong modrm
[InlineData(false, new byte[] { 0x81, 0x05, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00 })] // add [rip], 1 — wrong modrm
[InlineData(false, new byte[] { 0x0F, 0x05 })] // syscall
[InlineData(false, new byte[] { 0x90, 0x90 })] // nop
public void Rejects_unsafe_or_uncovered_shapes(bool is64Bit, byte[] bytes)
{
int length = PrologueDecoder.GetInstructionLength(bytes, is64Bit);
Assert.Equal(-1, length);
Assert.Throws<InvalidOperationException>(
() => PrologueDecoder.GetWholeInstructionLength(bytes, requiredBytes: 2, is64Bit));
}
}
+5 -3
View File
@@ -4,9 +4,11 @@ using WhiteMagic;
namespace WhiteMagicTest; namespace WhiteMagicTest;
/// <summary> /// <summary>
/// Tests for <see cref="InProcessReader"/> — direct pointer dereference against /// Tests for <see cref="InProcessReader"/>. The current implementation uses
/// the own process. Verifies the shared <see cref="MemoryBase"/> API works for /// <c>ReadProcessMemory</c>/<c>WriteProcessMemory</c> on a self-handle (per the D1
/// both external and in-process readers. /// revision — unsafe direct-pointer dereference was rejected because .NET cannot
/// catch <see cref="AccessViolationException"/>). Verifies the shared
/// <see cref="MemoryBase"/> API works for both external and in-process readers.
/// </summary> /// </summary>
public class InProcessReaderTests public class InProcessReaderTests
{ {
@@ -0,0 +1,214 @@
using System.ComponentModel;
using WhiteMagic;
using WhiteMagic.Injection;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagicTest.Injection;
/// <summary>
/// Tests for <see cref="CodeInjector"/>.
/// </summary>
public class CodeInjectorTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void InjectAtAddress_writes_code_to_specified_address()
{
using var reader = CreateReader();
// Allocate a buffer to write to
byte[] buffer = new byte[32];
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
buffer,
System.Runtime.InteropServices.GCHandleType.Pinned);
try
{
IntPtr addr = handle.AddrOfPinnedObject();
// Simple x64 payload: mov eax, 42; ret
// B8 2A 00 00 00 C3
byte[] code = { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
if (Environment.Is64BitProcess)
{
// 64-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3)
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
}
else
{
// 32-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3) - same encoding
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
}
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
Assert.Equal(addr, result);
Assert.Equal(code, buffer.Take(code.Length).ToArray());
}
finally
{
handle.Free();
}
}
[Fact]
public void InjectAtAddress_throws_on_empty_code()
{
using var reader = CreateReader();
byte[] code = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
Assert.Contains("Code cannot be empty", ex.Message);
}
[Fact]
public void InjectAtAddress_throws_on_zero_address()
{
using var reader = CreateReader();
byte[] code = { 0x90, 0x90, 0xC3 }; // nop; nop; ret
var ex = Assert.Throws<ArgumentException>(() =>
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
Assert.Contains("Address cannot be zero", ex.Message);
}
[Fact]
public void Inject_allocates_and_writes_code()
{
using var reader = CreateReader();
// Simple x64 payload: ret (C3)
byte[] code = { 0xC3 };
using var allocated = CodeInjector.Inject(reader, code);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
Assert.Equal(code.Length, allocated.Size);
// Verify the code was written
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
Assert.Equal(code, readBack);
}
[Fact]
public void Inject_with_execute_read_write_protection()
{
using var reader = CreateReader();
byte[] code = { 0xC3 }; // ret
using var allocated = CodeInjector.Inject(
reader,
code,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Inject_with_read_only_protection()
{
using var reader = CreateReader();
byte[] code = { 0xC3 }; // ret
using var allocated = CodeInjector.Inject(
reader,
code,
MemoryProtectionType.ExecuteRead);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Inject_throws_on_empty_code()
{
using var reader = CreateReader();
byte[] code = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
CodeInjector.Inject(reader, code));
Assert.Contains("Code cannot be empty", ex.Message);
}
[Fact]
public void Inject_returns_allocated_memory_that_can_be_freed()
{
using var reader = CreateReader();
byte[] code = { 0xC3 }; // ret
var allocated = CodeInjector.Inject(reader, code);
Assert.NotNull(allocated);
// Dispose should free the memory
allocated.Dispose();
// No exception should be thrown during disposal
}
[Fact]
public void InjectAtAddress_writes_all_bytes()
{
using var reader = CreateReader();
// Allocate a buffer
byte[] buffer = new byte[128];
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
buffer,
System.Runtime.InteropServices.GCHandleType.Pinned);
try
{
IntPtr addr = handle.AddrOfPinnedObject();
// Create a larger payload
byte[] code = new byte[64];
for (int i = 0; i < code.Length; i++)
code[i] = (byte)(i & 0xFF);
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
Assert.Equal(addr, result);
// Verify all bytes were written
byte[] readBack = reader.ReadBytes(addr, code.Length);
Assert.Equal(code, readBack);
}
finally
{
handle.Free();
}
}
[Fact]
public void Inject_with_complex_payload()
{
using var reader = CreateReader();
// mov eax, 12345678h; ret
// x64: B8 78 56 34 12 C3
// x86: B8 78 56 34 12 C3 (same)
byte[] code = { 0xB8, 0x78, 0x56, 0x34, 0x12, 0xC3 };
using var allocated = CodeInjector.Inject(reader, code);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
// Verify the exact payload was written
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
Assert.Equal(code, readBack);
}
}
@@ -0,0 +1,141 @@
using System.Runtime.InteropServices;
using Thread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Injection;
using WhiteMagic.Native;
namespace WhiteMagicTest.Injection;
/// <summary>
/// Tests for <see cref="DllInjector"/>.
/// </summary>
/// <remarks>
/// Real injection tests exercise the current process, because it is always available
/// and the injected DLLs are ordinary system modules that are already loaded.
/// </remarks>
public class DllInjectorTests
{
private static string GetExistingSystemDll()
{
// user32.dll exists on every Windows system and matches the host bitness.
string path = Path.Combine(Environment.SystemDirectory, "user32.dll");
Assert.True(File.Exists(path), $"{path} must exist for the test.");
return path;
}
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
public void InjectWithRemoteThread_loads_system_dll_in_current_process()
{
using var reader = new InProcessReader();
var injector = new DllInjector(reader);
IntPtr moduleBase = injector.InjectWithRemoteThread(GetExistingSystemDll());
Assert.NotEqual(IntPtr.Zero, moduleBase);
}
[Fact]
public void InjectWithRemoteThread_throws_for_missing_dll()
{
using var reader = new InProcessReader();
var injector = new DllInjector(reader);
string missingPath = Path.Combine(Path.GetTempPath(), $"wm-missing-{Guid.NewGuid()}.dll");
Assert.False(File.Exists(missingPath));
Assert.Throws<FileNotFoundException>(() => injector.InjectWithRemoteThread(missingPath));
}
[Fact]
public void InjectWithRemoteThread_rejects_bitness_mismatch()
{
// A fake reader that reports the opposite bitness from the current process.
using var reader = new FakeBitnessMemoryBase(!Environment.Is64BitProcess);
var injector = new DllInjector(reader);
var ex = Assert.Throws<InvalidOperationException>(
() => injector.InjectWithRemoteThread(GetExistingSystemDll()));
Assert.Contains("bitness", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
public void InjectWithThreadHijack_loads_system_dll_and_restores_context()
{
using var reader = new InProcessReader();
var injector = new DllInjector(reader);
using var stopEvent = new ManualResetEventSlim(false);
using var startedEvent = new ManualResetEventSlim(false);
int osThreadId = 0;
Exception? threadError = null;
var helper = new System.Threading.Thread(() =>
{
try
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
startedEvent.Set();
// Loop with short sleeps so the thread can be hijacked safely and can
// also be stopped once its original context is restored.
while (!stopEvent.IsSet)
{
System.Threading.Thread.Sleep(10);
}
}
catch (Exception ex)
{
threadError = ex;
}
});
helper.IsBackground = true;
helper.Start();
try
{
Assert.True(startedEvent.Wait(TimeSpan.FromSeconds(5)), "Helper thread did not start.");
Assert.NotEqual(0, osThreadId);
IntPtr moduleBase = injector.InjectWithThreadHijack(osThreadId, GetExistingSystemDll());
Assert.NotEqual(IntPtr.Zero, moduleBase);
// Tell the helper thread to exit. If the original context was restored correctly,
// the thread will return to its loop and observe the stop event.
stopEvent.Set();
Assert.True(helper.Join(TimeSpan.FromSeconds(5)), "Helper thread did not exit after context restore.");
Assert.Null(threadError);
}
finally
{
stopEvent.Set();
helper.Join(TimeSpan.FromSeconds(5));
}
}
/// <summary>
/// A minimal <see cref="MemoryBase"/> whose only job is to report a chosen bitness.
/// Reads and writes are not expected to be called by the rejection path.
/// </summary>
private sealed class FakeBitnessMemoryBase : MemoryBase
{
public FakeBitnessMemoryBase(bool is64Bit)
{
Is64Bit = is64Bit;
}
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle => new(IntPtr.Zero);
public override bool Is64Bit { get; }
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
=> throw new NotSupportedException();
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,41 @@
using System.Diagnostics;
using WhiteMagic.Input;
using WhiteMagic.Windows;
using Xunit;
namespace WhiteMagicTest.Input;
public sealed class InputSimulatorTests
{
[Fact(Skip = "Interactive input test - requires a visible window")]
public void SendKeys_to_current_window_does_not_throw()
{
using var process = Process.GetCurrentProcess();
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
? process.MainWindowHandle
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero)
return;
var simulator = new InputSimulator();
bool result = simulator.SendKeys(handle, "ab");
Assert.True(result);
}
[Fact(Skip = "Interactive input test - requires a visible window")]
public void SendMouseClick_to_current_window_does_not_throw()
{
using var process = Process.GetCurrentProcess();
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
? process.MainWindowHandle
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero)
return;
var simulator = new InputSimulator();
bool result = simulator.SendMouseClick(handle, 10, 20, MouseButton.Left);
Assert.True(result);
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Linq;
using WhiteMagic;
using WhiteMagic.Memory;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for the convenience accessors exposed directly on <see cref="Magic"/>.
/// </summary>
public sealed class MagicFacadeTests
{
[Fact]
public void QueryRegion_returns_region_containing_image_base()
{
using var magic = Magic.OpenInProcess();
MemoryRegion region = magic.QueryRegion(magic.Memory.ImageBase);
Assert.True(region.Contains(magic.Memory.ImageBase));
}
[Fact]
public void Regions_enumerates_region_containing_image_base()
{
using var magic = Magic.OpenInProcess();
bool found = magic.Regions.Any(r => r.Contains(magic.Memory.ImageBase));
Assert.True(found);
}
[Fact]
public void Threads_factory_enumerates_current_thread()
{
using var magic = Magic.OpenInProcess();
ThreadFactory factory = magic.Threads;
int currentOsId = (int)NativeMethods.GetCurrentThreadId();
bool found = factory.Enumerate().Any(t => t.Id == currentOsId);
Assert.True(found);
}
}
@@ -0,0 +1,323 @@
using System.ComponentModel;
using WhiteMagic;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagicTest.Memory;
/// <summary>
/// Tests for <see cref="AllocatedMemory"/>.
/// </summary>
public class AllocatedMemoryTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void Constructor_allocates_memory_with_execute_read_write_protection()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
Assert.Equal(4096, allocated.Size);
}
[Fact]
public void Constructor_with_custom_protection()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096, MemoryProtectionType.ReadOnly);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Constructor_throws_on_negative_size()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
new AllocatedMemory(reader, -1));
Assert.Equal("size", ex.ParamName);
}
[Fact]
public void Constructor_throws_on_zero_size()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
new AllocatedMemory(reader, 0));
Assert.Equal("size", ex.ParamName);
}
[Fact]
public void AddRegion_adds_named_region()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("test", 100);
Assert.Equal(100, allocated.AddressOf("test") - allocated.BaseAddress);
}
[Fact]
public void AddRegion_throws_on_duplicate_name()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("test", 100);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.AddRegion("test", 200));
Assert.Contains("already exists", ex.Message);
}
[Fact]
public void AddRegion_throws_on_negative_offset()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.AddRegion("test", -1));
Assert.Equal("offset", ex.ParamName);
}
[Fact]
public void AddRegion_throws_on_offset_exceeding_size()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.AddRegion("test", 4096));
Assert.Equal("offset", ex.ParamName);
}
[Fact]
public void AddressOf_returns_correct_address()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("region1", 0);
allocated.AddRegion("region2", 100);
allocated.AddRegion("region3", 200);
Assert.Equal(allocated.BaseAddress, allocated.AddressOf("region1"));
Assert.Equal(allocated.BaseAddress + 100, allocated.AddressOf("region2"));
Assert.Equal(allocated.BaseAddress + 200, allocated.AddressOf("region3"));
}
[Fact]
public void AddressOf_throws_on_unknown_region()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.AddressOf("unknown"));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Write_and_Read_int_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("value", 0);
int original = unchecked((int)0xDEADBEEF);
Assert.True(allocated.Write("value", original));
int read = allocated.Read<int>("value");
Assert.Equal(original, read);
}
[Fact]
public void Write_and_Read_long_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("value", 8);
long original = 0x123456789ABCDEF0;
Assert.True(allocated.Write("value", original));
long read = allocated.Read<long>("value");
Assert.Equal(original, read);
}
[Fact]
public void WriteBytes_and_ReadBytes_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("buffer", 0);
byte[] original = { 0x01, 0x02, 0x03, 0x04, 0x05 };
int written = allocated.WriteBytes("buffer", original);
Assert.Equal(original.Length, written);
byte[] read = allocated.ReadBytes("buffer", original.Length);
Assert.Equal(original, read);
}
[Fact]
public void Dispose_frees_memory()
{
using var reader = CreateReader();
var allocated = new AllocatedMemory(reader, 4096);
IntPtr baseAddr = allocated.BaseAddress;
Assert.NotEqual(IntPtr.Zero, baseAddr);
allocated.Dispose();
// After dispose, accessing properties should throw ObjectDisposedException
Assert.Throws<ObjectDisposedException>(() =>
allocated.AddressOf("any"));
}
[Fact]
public void Multiple_regions_independent_access()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("a", 0);
allocated.AddRegion("b", 4);
allocated.AddRegion("c", 8);
Assert.True(allocated.Write("a", 0x11111111));
Assert.True(allocated.Write("b", 0x22222222));
Assert.True(allocated.Write("c", 0x33333333));
Assert.Equal(0x11111111, allocated.Read<int>("a"));
Assert.Equal(0x22222222, allocated.Read<int>("b"));
Assert.Equal(0x33333333, allocated.Read<int>("c"));
}
[Fact]
public void Write_to_unknown_region_throws()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.Write("unknown", 42));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Read_from_unknown_region_throws()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.Read<int>("unknown"));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Read_T_throws_when_value_exceeds_allocation()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 100);
allocated.AddRegion("boundary", 99);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.Read<int>("boundary"));
Assert.Equal("name", ex.ParamName);
}
[Fact]
public void Write_T_throws_when_value_exceeds_allocation()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 100);
allocated.AddRegion("boundary", 99);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.Write("boundary", 42));
Assert.Equal("name", ex.ParamName);
}
[Fact]
public void ReadBytes_throws_when_count_exceeds_allocation()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 100);
allocated.AddRegion("boundary", 99);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.ReadBytes("boundary", 2));
Assert.Equal("count", ex.ParamName);
}
[Fact]
public void WriteBytes_throws_when_span_exceeds_allocation()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 100);
allocated.AddRegion("boundary", 99);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.WriteBytes("boundary", new byte[2]));
Assert.Equal("bytes", ex.ParamName);
}
[Fact]
public void Read_and_Write_at_exact_allocation_boundary_succeed()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 100);
allocated.AddRegion("boundary", 96);
const int expected = unchecked((int)0xDEADBEEF);
Assert.True(allocated.Write("boundary", expected));
Assert.Equal(expected, allocated.Read<int>("boundary"));
}
}
+159
View File
@@ -0,0 +1,159 @@
using System;
using System.Linq;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Memory;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest.Memory;
/// <summary>
/// Tests for memory-region query, enumeration and scoped protection (tasks 1.2, 1.4, 1.6, 1.8).
/// </summary>
public sealed class MemoryRegionTests
{
[Fact]
public void Contains_returns_true_for_addresses_inside_half_open_range()
{
var region = new MemoryRegion(
new IntPtr(0x10000),
0x1000,
MemoryProtectionType.ReadWrite,
MemoryState.Commit,
MemoryType.Private,
new IntPtr(0x10000),
MemoryProtectionType.ReadWrite);
Assert.True(region.Contains(new IntPtr(0x10000)));
Assert.True(region.Contains(new IntPtr(0x10FFF)));
Assert.False(region.Contains(new IntPtr(0x11000)));
Assert.False(region.Contains(new IntPtr(0x0FFF)));
}
[Fact]
public void QueryRegion_returns_region_containing_committed_address()
{
using var reader = new InProcessReader();
nint pageSize = Environment.SystemPageSize;
IntPtr block = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
pageSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ReadWrite);
Assert.NotEqual(IntPtr.Zero, block);
try
{
MemoryRegion region = reader.QueryRegion(block);
Assert.Equal(block, region.BaseAddress);
Assert.True(region.Contains(block));
Assert.True(region.Contains(block + (int)pageSize - 1));
Assert.Equal(MemoryState.Commit, region.State);
Assert.Equal(MemoryType.Private, region.Type);
Assert.Equal(MemoryProtectionType.ReadWrite, region.Protection);
Assert.Equal(MemoryProtectionType.ReadWrite, region.AllocationProtect);
Assert.Equal(block, region.AllocationBase);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
}
}
[Fact]
public void EnumerateRegions_yields_ascending_non_overlapping_regions()
{
using var reader = new InProcessReader();
MemoryRegion[] regions = reader.EnumerateRegions().Take(5).ToArray();
Assert.True(regions.Length > 0);
for (int i = 1; i < regions.Length; i++)
{
Assert.True(
(nuint)regions[i].BaseAddress >=
(nuint)regions[i - 1].BaseAddress + regions[i - 1].Size);
}
}
[Fact]
public void EnumerateRegions_is_lazy_and_stops_early()
{
using var reader = new InProcessReader();
// Taking a single item must not force a full address-space walk.
MemoryRegion first = reader.EnumerateRegions().First();
Assert.True(first.Size > 0);
}
[Fact]
public void ChangeProtection_applies_new_protection_inside_scope_and_restores_on_dispose()
{
using var reader = new InProcessReader();
nint pageSize = Environment.SystemPageSize;
IntPtr block = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
pageSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ReadWrite);
Assert.NotEqual(IntPtr.Zero, block);
try
{
Assert.Equal(MemoryProtectionType.ReadWrite, reader.QueryRegion(block).Protection);
using (reader.ChangeProtection(block, pageSize, MemoryProtectionType.ExecuteReadWrite))
{
Assert.Equal(MemoryProtectionType.ExecuteReadWrite, reader.QueryRegion(block).Protection);
}
Assert.Equal(MemoryProtectionType.ReadWrite, reader.QueryRegion(block).Protection);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
}
}
[Fact]
public void ChangeProtection_restores_original_protection_when_body_throws()
{
using var reader = new InProcessReader();
nint pageSize = Environment.SystemPageSize;
IntPtr block = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
pageSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ReadWrite);
Assert.NotEqual(IntPtr.Zero, block);
try
{
Assert.Throws<InvalidOperationException>(new Action(() =>
{
using (reader.ChangeProtection(block, pageSize, MemoryProtectionType.ExecuteReadWrite))
{
Assert.Equal(MemoryProtectionType.ExecuteReadWrite, reader.QueryRegion(block).Protection);
throw new InvalidOperationException("Intentional failure inside scope.");
}
}));
Assert.Equal(MemoryProtectionType.ReadWrite, reader.QueryRegion(block).Protection);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
}
}
}
+11
View File
@@ -151,6 +151,15 @@ public class MemoryHardeningTests
Assert.False(reader.Handle.IsInvalid); Assert.False(reader.Handle.IsInvalid);
} }
[Fact]
public void ExternalReader_throws_when_query_information_access_missing()
{
var ex = Assert.Throws<ArgumentException>(() =>
new ExternalReader(Process.GetCurrentProcess(), ProcessAccess.VmRead));
Assert.Equal("desiredAccess", ex.ParamName);
}
/// <summary> /// <summary>
/// A <see cref="MemoryBase"/> that serves bytes from an in-memory buffer and /// A <see cref="MemoryBase"/> that serves bytes from an in-memory buffer and
/// caps every read to <c>maxChunk</c> bytes, to exercise partial-read handling. /// caps every read to <c>maxChunk</c> bytes, to exercise partial-read handling.
@@ -160,6 +169,8 @@ public class MemoryHardeningTests
{ {
public override IntPtr ImageBase => IntPtr.Zero; public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle => null!; public override SafeMemoryHandle Handle => null!;
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false) public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{ {
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Diagnostics;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for <see cref="RemoteModule"/> / <see cref="RemoteFunction"/> resolution and
/// execution through the <see cref="Magic"/> facade (task 7.2).
/// </summary>
public class ModuleFunctionTests
{
// Ensure a module is loaded in this process before resolving it.
private static IntPtr Load(string module)
{
IntPtr handle = NativeMethods.LoadLibrary(module);
Assert.NotEqual(IntPtr.Zero, handle);
return handle;
}
[Fact]
public void Module_indexer_resolves_base_address()
{
IntPtr handle = Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
RemoteModule module = magic["kernel32"];
// The module handle returned by LoadLibrary is the module's base address.
Assert.Equal(handle, module.BaseAddress);
Assert.Equal("KERNEL32.DLL", module.Name, ignoreCase: true);
}
[Fact]
public void Function_indexer_resolves_direct_export()
{
IntPtr handle = Load("user32.dll");
IntPtr expected = NativeMethods.GetProcAddress(handle, "MessageBoxA");
Assert.NotEqual(IntPtr.Zero, expected);
using var magic = Magic.OpenInProcess();
RemoteFunction fn = magic["user32"]["MessageBoxA"];
Assert.Equal(expected, fn.Address);
Assert.Equal("MessageBoxA", fn.Name);
}
[Fact]
public void Function_indexer_follows_export_forwarder()
{
// kernel32!HeapAlloc is a classic forwarder to NTDLL.RtlAllocateHeap. Whatever the
// OS loader resolves it to, our parser must reach the same final address.
IntPtr handle = Load("kernel32.dll");
IntPtr expected = NativeMethods.GetProcAddress(handle, "HeapAlloc");
Assert.NotEqual(IntPtr.Zero, expected);
using var magic = Magic.OpenInProcess();
RemoteFunction fn = magic["kernel32"]["HeapAlloc"];
Assert.Equal(expected, fn.Address);
}
[Fact]
public void Module_indexer_throws_for_unloaded_module()
{
using var magic = Magic.OpenInProcess();
Assert.Throws<DllNotFoundException>(() => magic["definitely-not-loaded-xyz.dll"]);
}
[Fact]
public void Function_indexer_throws_for_unknown_export()
{
Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
Assert.Throws<InvalidOperationException>(() => magic["kernel32"]["NoSuchExport_ZZZ"]);
}
private delegate uint GetCurrentProcessIdDelegate();
[Fact]
public void CreateDelegate_throws_for_external_session()
{
Load("kernel32.dll");
// External reader (even to self): the address is not treated as host-mapped, so a
// delegate to it is rejected rather than handed back to AV on invocation.
using var magic = Magic.Open(Process.GetCurrentProcess());
RemoteFunction fn = magic["kernel32"]["GetCurrentProcessId"];
Assert.Throws<InvalidOperationException>(() => fn.CreateDelegate<GetCurrentProcessIdDelegate>());
}
[Fact]
public void CreateDelegate_invokes_function_in_process()
{
Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
var getPid = magic["kernel32"]["GetCurrentProcessId"].CreateDelegate<GetCurrentProcessIdDelegate>();
Assert.Equal((uint)Process.GetCurrentProcess().Id, getPid());
}
[Fact]
public void Resolved_function_executes_via_remote_thread()
{
Load("kernel32.dll");
using var magic = Magic.OpenInProcess();
RemoteFunction getPid = magic["kernel32"]["GetCurrentProcessId"];
// GetCurrentProcessId takes no args and is thread-agnostic; a remote thread in our
// own process must report our PID.
uint pid = getPid.Execute<uint>(CallConvention.Stdcall);
Assert.Equal((uint)Process.GetCurrentProcess().Id, pid);
}
}
+3 -3
View File
@@ -57,13 +57,13 @@ public class NativeSurfaceTests
{ {
using SafeMemoryHandle handle = OpenSelf( using SafeMemoryHandle handle = OpenSelf(
ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation); ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
ReadOnlySpan<byte> payload = BitConverter.GetBytes(0x5EED); ReadOnlySpan<byte> bytes = BitConverter.GetBytes(0x5EED);
bool ok = NativeMethods.WriteProcessMemory( bool ok = NativeMethods.WriteProcessMemory(
handle, pin.AddrOfPinnedObject(), payload, payload.Length, out nint written); handle, pin.AddrOfPinnedObject(), bytes, bytes.Length, out nint written);
Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}"); Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}");
Assert.Equal(payload.Length, (int)written); Assert.Equal(bytes.Length, (int)written);
Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject())); Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject()));
} }
finally finally
@@ -0,0 +1,105 @@
using System.Diagnostics;
using System.Linq;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.ProcessDiscovery;
using Xunit;
namespace WhiteMagicTest.ProcessDiscovery;
/// <summary>
/// Tests for process discovery via <see cref="ApplicationFinder"/> and the matching
/// <see cref="Magic.Open"/> overloads.
/// </summary>
public sealed class ApplicationFinderTests
{
[Fact]
public void Enumerate_finds_current_process_by_name()
{
string currentName = Process.GetCurrentProcess().ProcessName;
Process[] found = ApplicationFinder.Enumerate(currentName).ToArray();
Assert.True(found.Length >= 1);
Assert.Contains(found, p => p.Id == Process.GetCurrentProcess().Id);
}
[Fact]
public void Open_by_name_returns_current_process_when_unique()
{
string currentName = Process.GetCurrentProcess().ProcessName;
using Process process = ApplicationFinder.OpenProcess(currentName);
Assert.Equal(Process.GetCurrentProcess().Id, process.Id);
}
[Fact]
public void Open_throws_when_name_is_ambiguous()
{
// Look for a multi-instance system process; skip if the environment is not typical.
Process[] candidates = Process.GetProcessesByName("svchost");
if (candidates.Length <= 1)
{
return;
}
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => ApplicationFinder.OpenProcess("svchost"));
Assert.Contains("svchost", ex.Message);
Assert.Contains("ambiguous", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Open_throws_when_no_process_matches()
{
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => ApplicationFinder.OpenProcess("probably-not-loaded-xyz.exe"));
Assert.Contains("No process", ex.Message);
}
[Fact]
public void OpenByWindowHandle_returns_owning_process()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
return;
}
using Process process = ApplicationFinder.OpenByWindowHandle(handle);
Assert.Equal(Process.GetCurrentProcess().Id, process.Id);
}
[Fact]
public void OpenByWindowHandle_throws_for_zero_handle()
{
Assert.Throws<ArgumentException>("handle", () => ApplicationFinder.OpenByWindowHandle(IntPtr.Zero));
}
[Fact]
public void Magic_Open_by_name_attaches_to_current_process()
{
string currentName = Process.GetCurrentProcess().ProcessName;
using var magic = Magic.Open(currentName);
Assert.Equal(Process.GetCurrentProcess().Id, magic.Memory.ProcessId);
}
[Fact]
public void Magic_OpenByWindowHandle_attaches_to_owning_process()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
return;
}
using var magic = Magic.OpenByWindowHandle(handle);
Assert.Equal(Process.GetCurrentProcess().Id, magic.Memory.ProcessId);
}
}
@@ -0,0 +1,28 @@
using System.Diagnostics;
using WhiteMagic;
using WhiteMagic.ProcessEnvironment;
using Xunit;
namespace WhiteMagicTest.ProcessEnvironment;
public sealed class ManagedPebTests
{
[Fact]
public void Read_current_process_peb_fields_returns_plausible_values()
{
using var magic = Magic.OpenInProcess();
var peb = new ManagedPeb(magic.Memory);
Assert.NotEqual(IntPtr.Zero, peb.ReadPebAddress());
Assert.NotEqual(IntPtr.Zero, peb.ReadImageBaseAddress());
byte beingDebugged = peb.ReadBeingDebugged();
Assert.True(beingDebugged == 0 || beingDebugged == 1);
Assert.NotEqual(IntPtr.Zero, peb.ReadLdrAddress());
// The in-process test process is native to the host architecture, so
// it is not running under WOW64.
Assert.False(peb.ReadIsWow64Process());
}
}
+55
View File
@@ -157,6 +157,61 @@ public class StringReadWriteTests
} }
} }
/// <summary>
/// UTF-16 null terminator split across the 64-byte chunk boundary must still be found.
/// The first chunk ends at byte 63, so the null bytes at 64/65 are in the second chunk.
/// </summary>
[Fact]
public void ReadString_utf16_null_across_chunk_boundary_is_found()
{
using var reader = OpenSelf();
byte[] slot = new byte[256];
// 32 'A' UTF-16 chars = 64 bytes, no embedded null.
byte[] text = Encoding.Unicode.GetBytes(new string('A', 32));
Assert.Equal(64, text.Length);
text.CopyTo(slot, 0);
// Null terminator at bytes 64/65.
slot[64] = 0x00;
slot[65] = 0x00;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 256);
Assert.Equal(new string('A', 32), result);
}
finally
{
pin.Free();
}
}
/// <summary>
/// A byte sequence that looks like a null at a misaligned offset must not stop the scan.
/// "A" + U+4200 produces bytes 41 00 00 42 00 00; bytes 1-2 are an aligned-position null
/// only if scanned byte-by-byte. The aligned UTF-16 scan must see the real terminator.
/// </summary>
[Fact]
public void ReadString_utf16_does_not_stop_at_misaligned_null()
{
using var reader = OpenSelf();
byte[] slot = Encoding.Unicode.GetBytes("A\u4200\0");
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 64);
Assert.Equal("A\u4200", result);
}
finally
{
pin.Free();
}
}
[Fact] [Fact]
public void WriteString_empty_string_writes_only_null() public void WriteString_empty_string_writes_only_null()
{ {
+192
View File
@@ -0,0 +1,192 @@
using System.Linq;
using System.Threading;
using SysThread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for scoped thread freeze via <see cref="FrozenThread"/> and <see cref="ThreadFactory.Freeze"/>.
/// </summary>
public sealed class FrozenThreadTests
{
[Fact]
public void Freeze_suspends_selected_workers_until_disposed()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using var cts1 = new CancellationTokenSource();
using var cts2 = new CancellationTokenSource();
var started1 = new ManualResetEventSlim(false);
var started2 = new ManualResetEventSlim(false);
int osThreadId1 = 0;
int osThreadId2 = 0;
var worker1 = new SysThread(() =>
{
osThreadId1 = (int)NativeMethods.GetCurrentThreadId();
started1.Set();
while (!cts1.IsCancellationRequested)
SysThread.Sleep(10);
});
var worker2 = new SysThread(() =>
{
osThreadId2 = (int)NativeMethods.GetCurrentThreadId();
started2.Set();
while (!cts2.IsCancellationRequested)
SysThread.Sleep(10);
});
worker1.Start();
worker2.Start();
started1.Wait();
started2.Wait();
int[] targetIds = [osThreadId1, osThreadId2];
try
{
var selected = factory.Enumerate().Where(t => targetIds.Contains(t.Id)).ToList();
Assert.Equal(2, selected.Count);
using (factory.Freeze(selected))
{
cts1.Cancel();
cts2.Cancel();
Assert.False(worker1.Join(100));
Assert.False(worker2.Join(100));
}
Assert.True(worker1.Join(1000));
Assert.True(worker2.Join(1000));
}
finally
{
if (worker1.IsAlive)
{
cts1.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId1);
t.Resume();
worker1.Join(1000);
}
if (worker2.IsAlive)
{
cts2.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId2);
t.Resume();
worker2.Join(1000);
}
}
}
[Fact]
public void Dispose_resumes_only_frozen_threads_leaving_external_suspends_intact()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new SysThread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
SysThread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
// Suspend the worker externally first.
using (var external = new RemoteThread(magic.Memory, osThreadId))
{
external.Suspend();
var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList();
using (factory.Freeze(selected))
{
// Frozen scope adds one more suspend count.
}
// After the freeze scope disposes, the worker was resumed once.
// Because it was already externally suspended, it should still be suspended.
cts.Cancel();
Assert.False(worker.Join(100));
external.Resume();
}
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId);
t.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void Exception_in_body_still_resumes_frozen_threads()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new SysThread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
SysThread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList();
Assert.Throws<InvalidOperationException>(new Action(() =>
{
using (factory.Freeze(selected))
{
throw new InvalidOperationException("Intentional failure inside freeze scope.");
}
}));
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var t = new RemoteThread(magic.Memory, osThreadId);
t.Resume();
worker.Join(1000);
}
}
}
}
@@ -0,0 +1,131 @@
using System.Threading;
using Thread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for <see cref="RemoteThread.GetContext64"/> / <see cref="RemoteThread.SetContext64"/>.
/// 32-bit/WOW64 context is tested on a 32-bit host run.
/// </summary>
public sealed class RemoteThreadContextTests
{
[Fact]
public void GetContext64_SetContext64_round_trip_on_suspended_self_thread()
{
if (!Environment.Is64BitProcess)
return;
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
System.Threading.Thread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Suspend();
System.Threading.Thread.Sleep(100);
thread.GetContext64(out Context64 context);
Assert.NotEqual(0uL, context.Rip);
const ulong sentinel = 0x123456789ABCDEF0uL;
ulong originalRax = context.Rax;
context.Rax = sentinel;
thread.SetContext64(ref context);
thread.GetContext64(out context);
Assert.Equal(sentinel, context.Rax);
// Restore the original register before resuming so the worker keeps running.
context.Rax = originalRax;
thread.SetContext64(ref context);
thread.Resume();
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void GetContext32_SetContext32_round_trip_on_suspended_self_thread()
{
if (Environment.Is64BitProcess)
return;
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
System.Threading.Thread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Suspend();
thread.GetContext32(out Context32 context);
Assert.NotEqual(0u, context.Eip);
const uint sentinel = 0x89ABCDEFu;
uint originalEax = context.Eax;
context.Eax = sentinel;
thread.SetContext32(ref context);
thread.GetContext32(out context);
Assert.Equal(sentinel, context.Eax);
context.Eax = originalEax;
thread.SetContext32(ref context);
thread.Resume();
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using System.Threading;
using Thread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for <see cref="RemoteThread"/> open/suspend/resume and context round-trip.
/// </summary>
public sealed class RemoteThreadTests
{
[Fact]
public void Open_by_id_succeeds_for_current_thread()
{
using var magic = Magic.OpenInProcess();
int currentId = (int)NativeMethods.GetCurrentThreadId();
using var thread = new RemoteThread(magic.Memory, currentId);
Assert.Equal(currentId, thread.Id);
}
[Fact]
public void Suspend_returns_prior_count_and_stops_worker()
{
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
System.Threading.Thread.Sleep(10);
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
uint prior = thread.Suspend();
Assert.True(prior < 0xFFFFFFFF);
cts.Cancel();
// Worker cannot observe cancellation while suspended.
Assert.False(worker.Join(100));
thread.Resume();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void Resume_restarts_a_suspended_worker()
{
using var magic = Magic.OpenInProcess();
using var cts = new CancellationTokenSource();
var started = new ManualResetEventSlim(false);
var resumed = new ManualResetEventSlim(false);
int osThreadId = 0;
var worker = new System.Threading.Thread(() =>
{
osThreadId = (int)NativeMethods.GetCurrentThreadId();
started.Set();
while (!cts.IsCancellationRequested)
{
resumed.Set();
System.Threading.Thread.Sleep(10);
}
});
worker.Start();
started.Wait();
try
{
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Suspend();
resumed.Reset();
uint prior = thread.Resume();
Assert.True(prior < 0xFFFFFFFF);
// Worker must reach the resumed flag again.
Assert.True(resumed.Wait(1000));
cts.Cancel();
Assert.True(worker.Join(1000));
}
finally
{
if (worker.IsAlive)
{
cts.Cancel();
using var thread = new RemoteThread(magic.Memory, osThreadId);
thread.Resume();
worker.Join(1000);
}
}
}
[Fact]
public void GetTeb_returns_managed_teb_for_thread()
{
using var magic = Magic.OpenInProcess();
int currentId = (int)NativeMethods.GetCurrentThreadId();
using var thread = new RemoteThread(magic.Memory, currentId);
using var teb = thread.GetTeb();
Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
}
}
@@ -0,0 +1,61 @@
using System.Linq;
using System.Threading;
using SysThread = System.Threading.Thread;
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.Thread;
using Xunit;
namespace WhiteMagicTest.Thread;
/// <summary>
/// Tests for <see cref="ThreadFactory"/> enumeration and main-thread selection.
/// </summary>
public sealed class ThreadFactoryTests
{
[Fact]
public void Enumerate_returns_only_target_threads()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
int currentOsId = (int)NativeMethods.GetCurrentThreadId();
var ids = factory.Enumerate().Select(t => t.Id).ToList();
Assert.True(ids.Count > 0);
Assert.Contains(currentOsId, ids);
}
[Fact]
public void GetThreadById_returns_matching_thread()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
int currentOsId = (int)NativeMethods.GetCurrentThreadId();
using RemoteThread thread = factory.GetThreadById(currentOsId);
Assert.Equal(currentOsId, thread.Id);
}
[Fact]
public void GetThreadById_throws_for_nonexistent_thread()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
Assert.Throws<InvalidOperationException>(() => factory.GetThreadById(0x7FFFFFFF));
}
[Fact]
public void MainThread_returns_a_thread_belonging_to_the_target()
{
using var magic = Magic.OpenInProcess();
var factory = new ThreadFactory(magic.Memory);
using RemoteThread main = factory.MainThread;
Assert.NotNull(main);
var ids = factory.Enumerate().Select(t => t.Id).ToList();
Assert.Contains(main.Id, ids);
}
}
@@ -0,0 +1,23 @@
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.ThreadEnvironment;
using Xunit;
namespace WhiteMagicTest.ThreadEnvironment;
public sealed class ManagedTebTests
{
[Fact]
public void Read_current_thread_teb_fields_returns_plausible_values()
{
using var magic = Magic.OpenInProcess();
using var teb = new ManagedTeb(magic.Memory, (int)NativeMethods.GetCurrentThreadId());
Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
Assert.NotEqual(IntPtr.Zero, teb.ReadStackBase());
Assert.NotEqual(IntPtr.Zero, teb.ReadStackLimit());
// The stack grows down, so the base is above the limit on x86/x64.
Assert.True((nuint)teb.ReadStackBase() > (nuint)teb.ReadStackLimit());
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic.Windows;
using Xunit;
namespace WhiteMagicTest.Windows;
public sealed class WindowTests
{
[Fact]
public void GetWindows_returns_at_least_one_top_level_window()
{
var windows = WindowFactory.GetWindows().ToList();
Assert.NotEmpty(windows);
}
[Fact]
public void GetWindowsByClassName_filters_to_matching_classes()
{
var all = WindowFactory.GetWindows().ToList();
if (all.Count == 0)
return;
string firstClass = all[0].ClassName;
if (string.IsNullOrEmpty(firstClass))
return;
var filtered = WindowFactory.GetWindowsByClassName(firstClass).ToList();
Assert.All(filtered, w => Assert.Equal(firstClass, w.ClassName));
Assert.True(filtered.Count <= all.Count);
}
[Fact]
public void RemoteWindow_can_query_and_manipulate_a_test_window()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
// The xUnit runner may not expose a main window; skip destructive
// manipulation but still validate factory enumeration above.
return;
}
var window = new RemoteWindow(handle);
Assert.Equal(handle, window.Handle);
string text = window.Text;
Assert.NotNull(text);
string originalTitle = window.Title;
Assert.Equal(text, originalTitle);
// Move and resize, then restore the original position.
bool moved = window.MoveResize(10, 10, 400, 300);
Assert.True(moved);
bool flashed = window.Flash();
Assert.True(flashed);
// Restore a sensible size without asserting exact title restoration;
// terminal windows often ignore SetWindowText.
bool restored = window.MoveResize(0, 0, 800, 600);
Assert.True(restored);
}
}
+14 -1
View File
@@ -74,7 +74,7 @@ WhiteMagic (facade — BM-old ergonomics)
├─ Core: SafeHandle, native P/Invoke, x64 [BM current] ├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic] ├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
│ ├─ ExternalReader (RPM/WPM) │ ├─ ExternalReader (RPM/WPM)
│ └─ InProcessReader (direct deref, injected) │ └─ InProcessReader (RPM/WPM on self-handle, injected)
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic] ├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic] ├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM] ├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
@@ -91,3 +91,16 @@ WhiteMagic (facade — BM-old ergonomics)
``` ```
**Net result**: BM's modern, FASM-free, x64 core + GreyMagic's dual-mode / detour / patch / marshal-cache engine + MemorySharp's high-level ergonomics — with a three-tier execution model whose *default* for state-sensitive calls is the crash-safe main-thread pump, while `CreateRemoteThread` stays available for the payloads it is genuinely safe for. **Net result**: BM's modern, FASM-free, x64 core + GreyMagic's dual-mode / detour / patch / marshal-cache engine + MemorySharp's high-level ergonomics — with a three-tier execution model whose *default* for state-sensitive calls is the crash-safe main-thread pump, while `CreateRemoteThread` stays available for the payloads it is genuinely safe for.
### Deviations discovered during implementation
The design held, but building it surfaced corrections worth recording (each is detailed against its task in `openspec/changes/whitemagic-foundation/tasks.md`):
- **`InProcessReader` reads via RPM/WPM on a self-handle, not `unsafe` direct deref** — .NET cannot catch `AccessViolationException`, so a bad direct deref kills the host with no soft-failure path. The in-process speed win moves to the delegate-call and detour paths, not the reader (design decision D1, revised mid-Phase 2).
- **`MarshalCache<T>` splits `Size` (managed, blittable) from `MarshalSize` (`Marshal.SizeOf`, marshal path)** — a single size mis-sized structs whose unmanaged width differs (a `bool` field is managed-1 / unmanaged-4; inline `ByValTStr`/`ByValArray` under-sized the marshal buffer and corrupted the heap on write). `MemoryBase` picks per `TypeRequiresMarshal` at every IO site.
- **x64 call stub is fully MS-x64-ABI compliant** — 32-byte shadow space, 16-byte alignment at the inner `call`, full `imm64` register loads (no >4 GiB pointer truncation), stack args above the shadow window. Proven at runtime by a live SSE callee whose aligned `movaps` faults on any misalignment (task 3.8), not just by byte-level encoding tests.
- **`RemoteModule`/`RemoteFunction` follow PE export forwarders** — `kernel32!HeapAlloc``NTDLL.RtlAllocateHeap` and similar resolve into the real target module; ordinal and API-set forwarders throw `NotSupportedException` rather than returning a wrong address (task 7.2).
- **Detour prologue safety is tiered** — the default `StubAssembler` length-decoder covers only the common x86/x64 prologue shapes and refuses any opcode outside that set (zero dependency); the optional `IcedAssembler.GetPrologueLength` decodes arbitrary prologues and is plugged in via `DetourManager.PrologueLengthResolver` when full validation is wanted (tasks 4.6, 8.3).
- **Iced has no text parser** — the design assumed arbitrary text assembly could be delegated to Iced, but Iced ships only a *fluent* code assembler and a decoder. `IcedAssembler.Assemble` bridges Intel-syntax text onto the fluent API by reflection (registers, immediates, labels; memory operands unsupported), rather than depending on a parser that does not exist (task 8.2).
- **Thread-control, memory-region, and process-discovery gaps are closed** — this change adds `MemoryBase.QueryRegion`/`EnumerateRegions`/`ChangeProtection`, `RemoteThread`/`ThreadFactory`/`FrozenThread`, and `ApplicationFinder` with `Magic.Open` overloads. WhiteMagic now covers the public surfaces of all four reference libraries.
- **Bounds and protection hardening**`AllocatedMemory` range-checks typed IO against region size; `Patch` mirrors the detour's `VirtualProtectEx` dance; `MainThreadPump` guards the completion race on an already-completed `TaskCompletionSource`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-22
@@ -0,0 +1,86 @@
## Context
`whitemagic-foundation` shipped the core (dual `MemoryBase`, execution tiers, hooking, injection, discovery, high-level surface). The post-implementation review confirmed parity with GreyMagic and current BlackMagic but flagged three MemorySharp capabilities still absent: public thread control, memory-region query, and process discovery. This change closes those gaps. It is additive; nothing in `whitemagic-foundation` is reworked.
The consuming use case is unchanged — an external automation host over a legacy x86 desktop app — so every surface here must work **out-of-process** over a `SafeMemoryHandle`, and honor target bitness where the OS structures differ (thread `CONTEXT`).
## Goals / Non-Goals
**Goals:**
- Public thread surface: enumerate the target's threads, suspend/resume, read/write `CONTEXT`, read a thread's TEB, and a **scoped freeze** (`IDisposable`) that suspends a thread set and resumes on dispose even if the body throws.
- Memory-region surface: query the region containing an address, enumerate all mapped regions, and a **scoped protection change** (`IDisposable`) that restores original protection on dispose.
- Process discovery: attach a target by process name, window title, or window handle; enumerate candidate processes.
- Reuse existing `NativeMethods` (`OpenThread`, `Suspend`/`ResumeThread`, `Get`/`SetThreadContext`, `VirtualProtectEx`, `SafeMemoryHandle`) rather than duplicating them.
- Test-first for pure logic (region-contains math, freeze/dispose ordering, name/handle matching) with live-process integration tests gated on an available target (self-process).
**Non-Goals:**
- Managed-loader / in-process pump reachability (separate follow-up).
- Thread creation — `RemoteThreadExecutor` already owns `CreateRemoteThread`; `RemoteThread` here wraps *existing* target threads.
- Writing to arbitrary regions found by enumeration beyond what `MemoryBase` read/write already offers.
- Kernel-level or hidden-thread discovery — toolhelp/`NtQueryInformationThread` visibility is sufficient for the automation use case.
## Decisions
### D1: `RemoteThread` wraps an existing target thread over a `SafeMemoryHandle`
`RemoteThread` opens a thread by TID via `OpenThread(THREAD_ALL_ACCESS...)` into a `SafeMemoryHandle` and exposes `Suspend()`/`Resume()`, `GetContext()`/`SetContext()` (Wow64 variant selected by target bitness, mirroring `DllInjector`'s hijack path), `GetTeb()` (via `NtQueryInformationThread`/`ThreadBasicInformation``ManagedTeb`), and `Id`. `Suspend`/`Resume` return the prior suspend count so nested suspends are observable.
**Why**: Mirrors the proven bitness handling already in `DllInjector`; keeps thread handles inside a `SafeMemoryHandle` for deterministic cleanup like every other native handle in the library.
**Alternatives**: expose raw `System.Diagnostics.ProcessThread` — rejected: no suspend/resume/context and no soft handle ownership.
### D2: `ThreadFactory` enumerates via toolhelp snapshot
`ThreadFactory.Enumerate()` walks `CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD)` + `Thread32First`/`Thread32Next`, filtering by owning PID, yielding `RemoteThread`. `MainThread` returns the thread with the earliest creation time (via `GetThreadTimes`), matching MemorySharp's definition. `GetThreadById(id)` opens directly.
**Why**: toolhelp is the documented, x86/x64-uniform thread walk and needs no undocumented structures.
**Alternatives**: `NtQuerySystemInformation(SystemProcessInformation)` — rejected: larger undocumented surface for no gain here.
### D3: Scoped freeze is the default ergonomic
`ThreadFactory.Freeze(predicate = all-but-caller?)` suspends the selected threads and returns a `FrozenThread : IDisposable` whose `Dispose()` resumes exactly the threads it suspended, in reverse order. Individual `RemoteThread.Suspend/Resume` remain available for manual control.
**Why**: The dominant use ("freeze the target while I read/write a consistent snapshot") is a scope. An `IDisposable` makes leak-on-exception impossible: `using (factory.Freeze()) { ...edit... }`.
**Trade-off**: Freezing the target's own threads while calling *into* the target (pump/remote-thread) can deadlock. Documented: freeze is for passive read/write snapshots, not while executing target code.
### D4: `MemoryRegion` is an immutable `VirtualQueryEx` snapshot; enumeration is lazy
`MemoryRegion` holds `BaseAddress`, `Size`, `Protection`, `State`, `Type`, `AllocationBase`, `AllocationProtect`, and `Contains(address)`. `MemoryBase.QueryRegion(address)` returns the single region containing an address; `MemoryBase.EnumerateRegions()` yields regions from address 0 upward by repeatedly calling `VirtualQueryEx(base + size)` until it fails (end of address space). Enumeration is `IEnumerable<MemoryRegion>` (lazy) so a caller can stop early.
**Why**: `VirtualQueryEx` already returns contiguous non-overlapping regions; walking `base+size` is the canonical enumeration. Lazy avoids materializing the whole address space.
### D5: Protection change is a scoped, auto-restoring helper
`MemoryBase.ChangeProtection(address, size, newProtect)` calls `VirtualProtectEx`, captures the old protection, and returns a `ProtectionScope : IDisposable` that restores it on dispose. This is the same protect/restore pattern already inlined in `Detour.Apply`; extracting it lets callers guard their own writes: `using (mem.ChangeProtection(a, n, ExecuteReadWrite)) { mem.WriteBytes(a, patch); }`.
**Why**: Removes a foot-gun (leaving a page writable) and de-duplicates the pattern. `Detour`/`Patch` may later adopt it, but that refactor is out of scope here.
### D6: Process discovery via `System.Diagnostics.Process` + Win32 window queries
`ApplicationFinder` wraps `Process.GetProcessesByName`, a `GetWindow`/`EnumWindows` + `GetWindowThreadProcessId` path for window-title/handle attach, and exposes them as `Magic.Open(string processName)`, `Magic.OpenByWindowTitle(string)`, `Magic.OpenByWindowHandle(IntPtr)` overloads plus `ApplicationFinder.Enumerate()`. Ambiguous matches (multiple processes) throw with the candidate list rather than guessing.
**Why**: Managed `Process` covers name/PID; the existing `WindowFactory`/`RemoteWindow` P/Invoke already resolves windows, so window→PID reuses it. Throwing on ambiguity avoids attaching to the wrong instance.
## Risks / Trade-offs
- **Freeze-while-executing deadlock** → Documented non-use; `Freeze` default predicate can exclude the caller's own thread, but cross-process it cannot exclude the *target's* pump thread — caller must not freeze while the pump runs. (D3)
- **Suspend count skew**`Suspend`/`Resume` return prior counts; `FrozenThread` tracks exactly what it suspended and resumes only those, so external suspends are not clobbered. (D3)
- **`VirtualQueryEx` over a 64-bit address space is large** → enumeration is lazy and `IEnumerable`; callers filtering by `State == Commit` or a range stop early. (D4)
- **Ambiguous process match** → throw with candidates, never auto-pick. (D6)
- **Bitness of thread `CONTEXT`** → reuse the exact Wow64/native selection already validated in `DllInjector`. (D1)
## Migration Plan
Additive; nothing to migrate. Suggested slices:
1. **Memory-region**`MemoryRegion`, `QueryRegion`, `EnumerateRegions`, `ChangeProtection`/`ProtectionScope`. Smallest, unlocks safe writes immediately.
2. **Thread-control**`RemoteThread`, `ThreadFactory`, `FrozenThread`.
3. **Process discovery**`ApplicationFinder`, `Magic.Open*` overloads.
**Rollback**: remove the new files and the additive `Magic` members; no existing type is modified.
## Open Questions
- **`Freeze` default predicate**: all target threads, or all-but-main? Leaning all-but-none (freeze everything the caller selects; no implicit exclusion cross-process). To confirm during slice 2.
- **TEB read for a thread**: `NtQueryInformationThread(ThreadBasicInformation)` (undocumented-ish but stable) vs. deriving from `GetThreadContext`. Leaning the former to match `ManagedTeb`'s existing shape.
@@ -0,0 +1,34 @@
## Why
The `whitemagic-foundation` review found WhiteMagic is a superset of GreyMagic and current BlackMagic, but **not yet of MemorySharp**. Three genuinely useful capabilities MemorySharp (and, for threads, current BlackMagic's `SThread`) shipped are missing from WhiteMagic:
1. **Thread control** — WhiteMagic calls `SuspendThread`/`ResumeThread` only *internally* inside `DllInjector` thread-hijack. There is no public surface to enumerate a target's threads, suspend/resume them, or **freeze** them for the duration of an edit. Freezing threads is table-stakes for memory editing/trainers (MemorySharp: `ThreadFactory`/`RemoteThread`/`FrozenThread`; BlackMagic: `SThread`).
2. **Memory-region query** — WhiteMagic changes page protection inline inside `Detour` but exposes no `VirtualQueryEx` region walk, no query-region-at-address, and no reusable scoped protection helper (MemorySharp: `RemoteRegion`/`MemoryProtection`). Callers cannot inspect what is mapped, its protection, or safely flip protection around a write.
3. **Process discovery** — no way to open a target by name/window/title; the caller must obtain a PID out of band (MemorySharp: `ApplicationFinder`).
These are all **additive, low-risk** surfaces that sit on the existing `MemoryBase`/`SafeMemoryHandle` and native P/Invoke layer. None requires the deferred managed-loader work.
## What Changes
- **Thread control** (new capability `thread-control`): `RemoteThread` (open by id, suspend/resume, get/set context, get TEB, join), `ThreadFactory` (enumerate the target's threads, get main thread, get-by-id), and `FrozenThread`/`Freeze()` returning an `IDisposable` scope that suspends a set of threads and resumes them on dispose.
- **Memory-region query** (new capability `memory-region`): `MemoryRegion` (a queried `VirtualQueryEx` result — base, size, protection, state, type), region enumeration across the target's address space, query-region-containing-an-address, and a `ChangeProtection(...)` helper returning an `IDisposable` scope that restores the original protection on dispose.
- **Process discovery** (added to existing capability `high-level-api`): an `ApplicationFinder`/`Magic.Open` overloads to attach by process name, window title, or window handle, plus enumeration of candidate processes.
No behavior of existing WhiteMagic types changes; these are new types plus additive `Magic` facade members and new native imports.
## Capabilities
### New Capabilities
- `thread-control`: Enumerate, suspend/resume, freeze (scoped), and read/write the context of a target process's threads.
- `memory-region`: Query and enumerate mapped memory regions (`VirtualQueryEx`) and change page protection through a scoped, auto-restoring helper.
### Modified Capabilities
- `high-level-api`: Adds process discovery — attach a target by name/window/handle and enumerate candidates.
## Impact
- **New code**: `WhiteMagic/Thread/RemoteThread.cs`, `ThreadFactory.cs`, `FrozenThread.cs`; `WhiteMagic/Memory/MemoryRegion.cs`, `MemoryRegionEnumerator` (or methods on `MemoryBase`), `ProtectionScope`; `WhiteMagic/Process/ApplicationFinder.cs`; additive `Magic` facade members.
- **New native imports**: `Thread32First`/`Thread32Next` + `CreateToolhelp32Snapshot` (or `NtQueryInformationProcess` thread walk), `VirtualQueryEx`, `MEMORY_BASIC_INFORMATION`. `OpenThread`/`Suspend`/`Resume`/`Get`/`SetThreadContext` already exist in `NativeMethods`.
- **No dependency change**: pure P/Invoke over the existing core. No FASM, no Iced, no managed loader.
- **No changes** to BlackMagic/MemorySharp/GreyMagic or their tests.
- **Platform**: unchanged — bitness-agnostic (x86 + x64); thread context read honors the target's bitness like the existing hijack path.
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Process discovery
WhiteMagic SHALL attach to a target process discovered by process name, window title, or window handle, and SHALL enumerate candidate processes. An ambiguous match MUST fail deterministically rather than attaching to an arbitrary candidate.
#### Scenario: open by process name
- **WHEN** a target is opened by a unique process name
- **THEN** it MUST attach to that process
#### Scenario: open by window title
- **WHEN** a target is opened by a window title
- **THEN** it MUST attach to the process owning the window with that title
#### Scenario: open by window handle
- **WHEN** a target is opened by a window handle
- **THEN** it MUST attach to the process that owns that window
#### Scenario: ambiguous match is rejected
- **WHEN** more than one process matches the given name or title
- **THEN** the open MUST fail and surface the set of candidate processes rather than picking one
#### Scenario: enumerate candidates
- **WHEN** candidate processes are enumerated
- **THEN** the result MUST list the processes eligible to be opened
@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Query the region containing an address
WhiteMagic SHALL return the mapped memory region that contains a given address, including its base, size, protection, state, and type.
#### Scenario: query a committed address
- **WHEN** the region containing a known committed address is queried
- **THEN** it MUST return a region whose base and size bracket that address and whose protection reflects the page's actual protection
#### Scenario: region membership test
- **WHEN** a region is asked whether it contains an address
- **THEN** it MUST return true only for addresses within `[base, base + size)`
### Requirement: Enumerate mapped regions
WhiteMagic SHALL enumerate the mapped memory regions of the target from the lowest address upward, lazily.
#### Scenario: enumeration walks the address space
- **WHEN** the target's regions are enumerated
- **THEN** the sequence MUST yield contiguous, non-overlapping regions ascending by base address until the end of the queryable address space
#### Scenario: early stop
- **WHEN** a caller stops consuming the enumeration after the first match
- **THEN** enumeration MUST NOT query the entire address space
### Requirement: Scoped protection change
WhiteMagic SHALL change the protection of a region and restore the original protection when the returned scope is disposed.
#### Scenario: protection is applied within the scope
- **WHEN** a protection-change scope is created for a region with a new protection
- **THEN** the region's protection MUST be the requested value for the duration of the scope
#### Scenario: protection is restored on dispose
- **WHEN** the protection-change scope is disposed
- **THEN** the region's protection MUST be restored to the value it had before the scope was created
#### Scenario: restore on exception
- **WHEN** the guarded body throws before the scope is disposed
- **THEN** the original protection MUST still be restored as the scope unwinds
@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Enumerate target threads
WhiteMagic SHALL enumerate the threads belonging to the target process and expose each as a controllable thread handle.
#### Scenario: enumerate returns the target's threads
- **WHEN** the threads of an open target are enumerated
- **THEN** the result MUST contain a handle for each thread owned by the target process and none owned by other processes
#### Scenario: resolve the main thread
- **WHEN** the main thread is requested
- **THEN** it MUST return the earliest-created thread of the target process
#### Scenario: get a thread by id
- **WHEN** a thread is requested by its thread id
- **THEN** it MUST return a handle bound to that thread, or fail deterministically if the id is not a thread of the target
### Requirement: Suspend and resume a thread
WhiteMagic SHALL suspend and resume an individual target thread and report the prior suspend count.
#### Scenario: suspend increments the suspend count
- **WHEN** a running thread is suspended
- **THEN** the thread MUST stop executing and the returned prior suspend count MUST reflect its state before the call
#### Scenario: resume restores execution
- **WHEN** a previously suspended thread is resumed to a zero suspend count
- **THEN** the thread MUST resume executing
### Requirement: Read and write thread context
WhiteMagic SHALL read and write a target thread's register context, selecting the context layout that matches the target's bitness.
#### Scenario: round-trip a register value
- **WHEN** a thread's context is read, a register is modified, and the context is written back
- **THEN** a subsequent read MUST reflect the modified register value
#### Scenario: bitness-correct context
- **WHEN** the target is a 32-bit (WOW64) process
- **THEN** the WOW64 context layout MUST be used, and for a 64-bit target the native layout MUST be used
### Requirement: Scoped thread freeze
WhiteMagic SHALL provide a scoped freeze that suspends a selected set of target threads and resumes exactly those threads when the scope is disposed, including when the guarded body throws.
#### Scenario: freeze suspends selected threads
- **WHEN** a freeze scope is created over a set of threads
- **THEN** each of those threads MUST be suspended for the duration of the scope
#### Scenario: dispose resumes only the frozen threads
- **WHEN** the freeze scope is disposed
- **THEN** exactly the threads it suspended MUST be resumed, and threads suspended by other callers MUST be left unchanged
#### Scenario: exception in the body still resumes
- **WHEN** the guarded body throws before the scope is disposed
- **THEN** the frozen threads MUST still be resumed as the scope unwinds
@@ -0,0 +1,39 @@
## 1. Memory-region query (spec: memory-region)
- [x] 1.1 Add `VirtualQueryEx` `LibraryImport` and `MEMORY_BASIC_INFORMATION` to `Native/` (32/64-bit-correct layout)
- [x] 1.2 Add tests for `MemoryRegion.Contains` (in-range true, boundary `[base, base+size)`, out-of-range false)
- [x] 1.3 Implement `WhiteMagic/Memory/MemoryRegion.cs` (immutable: BaseAddress, Size, Protection, State, Type, AllocationBase, AllocationProtect, Contains) to pass 1.2
- [x] 1.4 Add tests for `MemoryBase.QueryRegion(address)` against a known committed address in the current process
- [x] 1.5 Implement `QueryRegion` to pass 1.4
- [x] 1.6 Add tests for `EnumerateRegions()`: ascending non-overlapping bases, lazy (early stop does not walk whole space — assert via a bounded take)
- [x] 1.7 Implement lazy `EnumerateRegions()` (walk `base+size` until `VirtualQueryEx` fails) to pass 1.6
- [x] 1.8 Add tests for `ChangeProtection`/`ProtectionScope`: protection applied in scope, restored on dispose, restored on exception
- [x] 1.9 Implement `MemoryBase.ChangeProtection` returning `ProtectionScope : IDisposable` to pass 1.8
## 2. Thread control (spec: thread-control)
- [x] 2.1 Add `CreateToolhelp32Snapshot`/`Thread32First`/`Thread32Next` + `THREADENTRY32`, and `GetThreadTimes`, to `Native/` (reuse existing `OpenThread`/`Suspend`/`Resume`/`Get`/`SetThreadContext`)
- [x] 2.2 Add tests for `RemoteThread`: open by id, `Suspend` returns prior count and stops the thread, `Resume` restarts it (self-process worker thread)
- [x] 2.3 Implement `WhiteMagic/Thread/RemoteThread.cs` (OpenThread → `SafeMemoryHandle`, Suspend/Resume, Id) to pass 2.2
- [x] 2.4 Add tests for `GetContext`/`SetContext` round-trip on a suspended self-thread; assert WOW64 vs native selection by target bitness
- [x] 2.5 Implement context read/write reusing `DllInjector`'s bitness selection to pass 2.4
- [x] 2.6 Add tests + implement `RemoteThread.GetTeb()` (via `NtQueryInformationThread`/`ThreadBasicInformation``ManagedTeb`)
- [x] 2.7 Add tests for `ThreadFactory`: `Enumerate()` returns only target threads, `MainThread` = earliest-created, `GetThreadById`
- [x] 2.8 Implement `WhiteMagic/Thread/ThreadFactory.cs` (toolhelp walk filtered by PID; `GetThreadTimes` for main) to pass 2.7
- [x] 2.9 Add tests for `FrozenThread`/`Freeze()`: suspends selected set, dispose resumes exactly those, body-throws still resumes, external suspends untouched
- [x] 2.10 Implement `WhiteMagic/Thread/FrozenThread.cs` + `ThreadFactory.Freeze(...)` (reverse-order resume on dispose) to pass 2.9
## 3. Process discovery (spec: high-level-api)
- [x] 3.1 Add tests for `ApplicationFinder.Enumerate()` and open-by-name against the current process
- [x] 3.2 Implement `WhiteMagic/Process/ApplicationFinder.cs` (`Process.GetProcessesByName`; window-title/handle via existing `WindowFactory` + `GetWindowThreadProcessId`)
- [x] 3.3 Add tests for ambiguous-match rejection (multiple candidates → throws with candidate list) and open-by-window-handle
- [x] 3.4 Add `Magic.Open(string processName)`, `Magic.OpenByWindowTitle(string)`, `Magic.OpenByWindowHandle(IntPtr)` overloads delegating to `ApplicationFinder`; add tests
- [x] 3.5 Wire new surface into the `Magic` facade (expose `Threads` factory and `Regions`/`QueryRegion` accessors) and document freeze-while-executing deadlock caveat in XML docs
## 4. Verification
- [x] 4.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass
- [x] 4.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
- [x] 4.3 Update `docs/memory-library-comparison.md` — mark thread-control, memory-region, and process-discovery gaps closed; note WhiteMagic is now a superset of MemorySharp's public surface (or list any remaining minor helpers deliberately skipped)
- [x] 4.4 `openspec validate add-thread-region-finder --strict` passes

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