Initial commit
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: "OPSX: Apply"
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: "OPSX: Archive"
|
||||
description: Archive a completed change in the experimental workflow
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, archive, experimental]
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: "OPSX: Explore"
|
||||
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, explore, experimental, thinking]
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: "OPSX: Propose"
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: "OPSX: Sync"
|
||||
description: Sync delta specs from a change to main specs
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, specs, experimental]
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: "OPSX: Update"
|
||||
description: Update a change - revise existing planning artifacts and keep them coherent (Experimental)
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:update` (e.g., `/opsx:update add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Get the change's artifacts**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
||||
|
||||
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
|
||||
|
||||
4. **Read and reconcile**
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Note everything that is now inconsistent, missing, or contradictory.
|
||||
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx:continue` to create them.
|
||||
- If the change is already coherent, say so and make no edits.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
- Show each proposed revision and why. Write only after the user confirms.
|
||||
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> suggest `/opsx:continue` to create them.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx:apply` to carry the delta into code.
|
||||
- Everything done and implemented -> suggest `/opsx:archive`.
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Anything deferred to `/opsx:continue` (not-yet-created artifacts or files)
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx:apply`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx:continue`'s job.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic).
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: openspec-update-change
|
||||
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Get the change's artifacts**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
||||
|
||||
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
|
||||
|
||||
4. **Read and reconcile**
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Note everything that is now inconsistent, missing, or contradictory.
|
||||
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx:continue` to create them.
|
||||
- If the change is already coherent, say so and make no edits.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
- Show each proposed revision and why. Write only after the user confirms.
|
||||
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> suggest `/opsx:continue` to create them.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx:apply` to carry the delta into code.
|
||||
- Everything done and implemented -> suggest `/opsx:archive`.
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Anything deferred to `/opsx:continue` (not-yet-created artifacts or files)
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx:apply`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx:continue`'s job.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic).
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Reference libraries — studied, not built here (each has its own upstream repo)
|
||||
reference/
|
||||
|
||||
# .NET build output
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
.vs/
|
||||
*.user
|
||||
|
||||
# Scratch
|
||||
*.tmp
|
||||
*.log
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Provided arguments**: $@
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx-archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
description: Archive a completed change in the experimental workflow
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Provided arguments**: $@
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
|
||||
**Provided arguments**: $@
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx-apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
**Provided arguments**: $@
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx-apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
description: Sync delta specs from a change to main specs
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx-sync` (e.g., `/opsx-sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Provided arguments**: $@
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
description: Update a change - revise existing planning artifacts and keep them coherent (Experimental)
|
||||
---
|
||||
|
||||
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx-update` (e.g., `/opsx-update add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Provided arguments**: $@
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Get the change's artifacts**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
||||
|
||||
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
|
||||
|
||||
4. **Read and reconcile**
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Note everything that is now inconsistent, missing, or contradictory.
|
||||
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx-continue` to create them.
|
||||
- If the change is already coherent, say so and make no edits.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
- Show each proposed revision and why. Write only after the user confirms.
|
||||
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> suggest `/opsx-continue` to create them.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx-apply` to carry the delta into code.
|
||||
- Everything done and implemented -> suggest `/opsx-archive`.
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Anything deferred to `/opsx-continue` (not-yet-created artifacts or files)
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx-apply`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx-continue`'s job.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx-new` (the "Update vs. Start Fresh" heuristic).
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx-explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx-apply
|
||||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: openspec-update-change
|
||||
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
---
|
||||
|
||||
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Get the change's artifacts**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
||||
|
||||
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||
|
||||
3. **Understand the request**
|
||||
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
|
||||
|
||||
4. **Read and reconcile**
|
||||
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||
- Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||
- Note everything that is now inconsistent, missing, or contradictory.
|
||||
- Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx-continue` to create them.
|
||||
- If the change is already coherent, say so and make no edits.
|
||||
|
||||
5. **Confirm and apply, one artifact at a time**
|
||||
- Show each proposed revision and why. Write only after the user confirms.
|
||||
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
|
||||
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||
- Artifacts still missing -> suggest `/opsx-continue` to create them.
|
||||
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx-apply` to carry the delta into code.
|
||||
- Everything done and implemented -> suggest `/opsx-archive`.
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||
- Anything deferred to `/opsx-continue` (not-yet-created artifacts or files)
|
||||
- Where the change stands and the recommended next command
|
||||
|
||||
**Guardrails**
|
||||
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx-apply`.
|
||||
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
|
||||
- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx-continue`'s job.
|
||||
- Confirm every edit with the user before writing.
|
||||
- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx-new` (the "Update vs. Start Fresh" heuristic).
|
||||
@@ -0,0 +1,110 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file gives rules to an AI agent that works in this repository.
|
||||
The text uses ASD-STE100 Simplified Technical English.
|
||||
Write short sentences. Use the active voice. Give one instruction in each sentence.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
WhiteMagic is a .NET 8 process-introspection library for Windows.
|
||||
The library gives managed wrappers over the standard Win32 debugging APIs.
|
||||
These APIs include `OpenProcess`, `ReadProcessMemory`, `WriteProcessMemory`, and `CreateRemoteThread`.
|
||||
Debuggers, profilers, diagnostic tools, and CLR hosts use the same APIs.
|
||||
Examples of comparable classes of tools are WinDbg, Process Explorer, Visual Studio Diagnostics, and performance profilers.
|
||||
|
||||
The consumer of the library is a diagnostic and automation client.
|
||||
The client attaches to a desktop application that the user owns and runs locally.
|
||||
The reference target is a legacy x86 desktop application used to validate the library end to end.
|
||||
The target CPU architecture is x86 or x64.
|
||||
|
||||
The library is a general-purpose interop layer.
|
||||
It does the same class of work as a debugger extension:
|
||||
read and write memory in an attached process, marshal managed types to raw bytes,
|
||||
generate small trampoline routines, and invoke routines in the attached process.
|
||||
All operations need the same access rights that a debugger needs.
|
||||
All operations run on the local machine with the consent of the user.
|
||||
|
||||
## 2. Repository layout
|
||||
|
||||
- `WhiteMagic/` — the library. Write the product code here.
|
||||
- `WhiteMagicTest/` — the xUnit test project. Write the tests here.
|
||||
- `openspec/` — the design documents. Read these before you write code.
|
||||
- `docs/` — the reference study `memory-library-comparison.md`.
|
||||
- `reference/` — four older libraries. Git ignores this folder. Read these for study only. Do not build these projects. Do not reference these assemblies.
|
||||
|
||||
## 3. Build and test
|
||||
|
||||
Run each command from the repository root.
|
||||
|
||||
- Build the solution: `dotnet build WhiteMagic.slnx`
|
||||
- Run the tests: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj`
|
||||
|
||||
The build must show zero errors and zero warnings.
|
||||
`TreatWarningsAsErrors` is on. A warning stops the build.
|
||||
|
||||
## 4. Code rules
|
||||
|
||||
- Target framework: `net8.0-windows`.
|
||||
- Enable nullable reference types. Do not disable nullable.
|
||||
- Enable unsafe blocks only where a pointer dereference needs them.
|
||||
- Keep the library bitness-agnostic. Support x86 and x64.
|
||||
- Use `LibraryImport` for a new P/Invoke declaration. Do not use `DllImport` for new code.
|
||||
- Match the style of the code near your change.
|
||||
|
||||
## 5. Test-first rule
|
||||
|
||||
Write the test before you write the product code.
|
||||
|
||||
Do these steps in order:
|
||||
|
||||
1. Write a test that fails.
|
||||
2. Write the smallest code that makes the test pass.
|
||||
3. Clean the code. Keep the test green.
|
||||
|
||||
Test all pure logic. Pure logic includes the assembler bytes, the marshal cache, the pattern matcher, the stub builder, and the pump queue.
|
||||
A test that needs a live process is an integration test. Gate an integration test on an available target.
|
||||
|
||||
## 6. Design source
|
||||
|
||||
The plan lives in `openspec/changes/whitemagic-foundation/`.
|
||||
|
||||
- Read `proposal.md` for the goal.
|
||||
- Read `design.md` for the decisions.
|
||||
- Read `tasks.md` for the ordered task list.
|
||||
- Read the file in `specs/` that matches your feature. Each requirement uses SHALL. Each scenario uses WHEN and THEN.
|
||||
|
||||
Make the code agree with the specification.
|
||||
If you must change the plan, update the specification first.
|
||||
Validate the change: `openspec validate whitemagic-foundation --strict`.
|
||||
|
||||
## 7. Branch and review workflow
|
||||
|
||||
The team builds one feature at a time.
|
||||
|
||||
Obey these rules:
|
||||
|
||||
1. Make one branch for one feature. Name the branch `feature/<short-name>`.
|
||||
2. Start the branch from `master`.
|
||||
3. Write the tests and the code on the branch.
|
||||
4. Keep the build green on the branch.
|
||||
5. Request a review before a merge.
|
||||
6. Do not merge your own feature without a review.
|
||||
7. Merge to `master` only after the review passes.
|
||||
8. Delete the feature branch after the merge.
|
||||
|
||||
`master` must always build. `master` must always pass the tests.
|
||||
|
||||
## 8. Commit rules
|
||||
|
||||
- Write a clear commit message. Use the present tense.
|
||||
- Describe what the commit changes. Describe why the commit changes it.
|
||||
- Make a small commit for one logical change.
|
||||
- Do not commit build output. Git ignores `bin/` and `obj/`.
|
||||
|
||||
## 9. Scope limits
|
||||
|
||||
- Do not add application-specific constants to the library. The consumer holds the offsets.
|
||||
- Do not add automation or application-specific logic to the library. The library stays a general interop layer.
|
||||
- Keep the library's operation transparent. Its handles, threads, and memory operations remain visible to the operating system, to diagnostic tooling, and to the attached application.
|
||||
- Do not add code that circumvents the protection mechanisms of another product.
|
||||
- Add the optional Iced backend only behind the `IAssembler` seam. Keep the default backend free of a third-party dependency.
|
||||
@@ -0,0 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="WhiteMagic/WhiteMagic.csproj" />
|
||||
<Project Path="WhiteMagicTest/WhiteMagicTest.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace WhiteMagic.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// x86/x86-64 calling conventions for call-stub generation.
|
||||
/// Named <c>CallConvention</c> (not <c>CallingConvention</c>) to avoid ambiguity with
|
||||
/// <see cref="System.Runtime.InteropServices.CallingConvention"/>.
|
||||
/// </summary>
|
||||
public enum CallConvention
|
||||
{
|
||||
/// <summary>Caller pushes args right-to-left and cleans the stack (x86).</summary>
|
||||
Cdecl,
|
||||
|
||||
/// <summary>Caller pushes args right-to-left; callee cleans the stack (x86).</summary>
|
||||
Stdcall,
|
||||
|
||||
/// <summary>ECX receives the <c>this</c> pointer; remaining args on stack right-to-left; callee cleans (x86).</summary>
|
||||
Thiscall,
|
||||
|
||||
/// <summary>ECX/EDX receive the first two args; remaining on stack right-to-left; callee cleans (x86).</summary>
|
||||
Fastcall,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace WhiteMagic.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over an x86/x64 assembler. The default <see cref="StubAssembler"/>
|
||||
/// hand-emits calling-convention trampolines (no parsing, zero dep). An optional
|
||||
/// <see cref="IcedAssembler"/> (Phase 8) handles arbitrary mnemonics via the Iced
|
||||
/// library.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This seam covers text assembly only (<see cref="Assemble"/>). Call-stub building
|
||||
/// (<c>BuildCallStub</c>, <c>EmitU8</c>/<c>EmitU32</c>/<c>EmitU64</c>) is a
|
||||
/// <see cref="StubAssembler"/> capability — not all backends need it.
|
||||
/// </remarks>
|
||||
public interface IAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles text mnemonics into machine code.
|
||||
/// </summary>
|
||||
/// <param name="assemblyText">The assembly text (Intel syntax).</param>
|
||||
/// <param name="origin">The base address for relative encodings.</param>
|
||||
byte[] Assemble(string assemblyText, ulong origin = 0);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
namespace WhiteMagic.Assembly;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IAssembler"/> backend. Hand-emits calling-convention
|
||||
/// trampolines and remote-execution stubs using deterministic byte emitters
|
||||
/// (<see cref="EmitU8"/>, <see cref="EmitU32"/>, <see cref="EmitU64"/>). Has
|
||||
/// no native or third-party dependency — no FASM, no Iced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Assemble"/> is not supported by this backend (it is a parse-free
|
||||
/// emitter, not a text assembler). Use <see cref="IcedAssembler"/> (Phase 8) for
|
||||
/// arbitrary mnemonics.
|
||||
/// </remarks>
|
||||
public sealed class StubAssembler : IAssembler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public byte[] Assemble(string assemblyText, ulong origin = 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"StubAssembler does not parse text assembly. " +
|
||||
"Use IcedAssembler (Phase 8) for arbitrary mnemonics.");
|
||||
}
|
||||
|
||||
// ── Emit primitives ────────────────────────────────────────────────────
|
||||
|
||||
public void EmitU8(List<byte> buffer, byte value) => buffer.Add(value);
|
||||
|
||||
public void EmitU32(List<byte> buffer, uint value)
|
||||
{
|
||||
buffer.Add((byte)value);
|
||||
buffer.Add((byte)(value >> 8));
|
||||
buffer.Add((byte)(value >> 16));
|
||||
buffer.Add((byte)(value >> 24));
|
||||
}
|
||||
|
||||
public void EmitU64(List<byte> buffer, ulong value)
|
||||
{
|
||||
EmitU32(buffer, (uint)value);
|
||||
EmitU32(buffer, (uint)(value >> 32));
|
||||
}
|
||||
|
||||
// ── Call-stub builders ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a calling-convention call stub for x86 or x64.
|
||||
/// </summary>
|
||||
/// <param name="stubAddress">Where the stub lands (for E8 rel32 encoding).</param>
|
||||
/// <param name="targetAddress">Function to call.</param>
|
||||
/// <param name="arguments">Argument values (uint[] — each 4 or 8 bytes per pointerSize).</param>
|
||||
/// <param name="pointerSize">4 (x86) or 8 (x64).</param>
|
||||
/// <param name="convention">Calling convention.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="pointerSize"/> is not 4 or 8,
|
||||
/// or <paramref name="convention"/> is not known, or the distance between stub and target
|
||||
/// exceeds the E8 rel32 range.</exception>
|
||||
public byte[] BuildCallStub(IntPtr stubAddress, IntPtr targetAddress,
|
||||
uint[] arguments, int pointerSize, CallConvention convention)
|
||||
{
|
||||
var buffer = new List<byte>(64);
|
||||
|
||||
if (pointerSize == 4)
|
||||
{
|
||||
BuildX86Stub(buffer, checked((uint)stubAddress), checked((uint)targetAddress),
|
||||
arguments, convention);
|
||||
}
|
||||
else if (pointerSize == 8)
|
||||
{
|
||||
// Windows x64 uses a single ABI — the convention parameter is unused.
|
||||
BuildX64Stub(buffer, (ulong)(nint)stubAddress, (ulong)(nint)targetAddress, arguments);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pointerSize), pointerSize,
|
||||
$"Expected 4 (x86) or 8 (x64), got {pointerSize}.");
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private void BuildX86Stub(List<byte> buffer, uint stubAddr,
|
||||
uint target, uint[] args, CallConvention convention)
|
||||
{
|
||||
uint current = stubAddr;
|
||||
int argIndex = 0;
|
||||
|
||||
switch (convention)
|
||||
{
|
||||
case CallConvention.Thiscall when args.Length - argIndex >= 1:
|
||||
EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
|
||||
argIndex++;
|
||||
break;
|
||||
|
||||
case CallConvention.Fastcall:
|
||||
if (args.Length - argIndex >= 1)
|
||||
{
|
||||
EmitMovRegImm32(buffer, 0xB9, args[argIndex], ref current); // mov ecx, arg0
|
||||
argIndex++;
|
||||
}
|
||||
if (args.Length - argIndex >= 1)
|
||||
{
|
||||
EmitMovRegImm32(buffer, 0xBA, args[argIndex], ref current); // mov edx, arg1
|
||||
argIndex++;
|
||||
}
|
||||
break;
|
||||
|
||||
case CallConvention.Cdecl:
|
||||
case CallConvention.Stdcall:
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(convention), convention,
|
||||
$"Unsupported calling convention: {convention}.");
|
||||
}
|
||||
|
||||
// Push remaining args in reverse order (right-to-left)
|
||||
for (int i = args.Length - 1; i >= argIndex; i--)
|
||||
{
|
||||
current += 5;
|
||||
buffer.Add(0x68); // push imm32
|
||||
EmitU32(buffer, args[i]);
|
||||
}
|
||||
|
||||
// call rel32
|
||||
long distance = (long)target - (long)(current + 5);
|
||||
if (distance < int.MinValue || distance > int.MaxValue)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
$"target (0x{target:X}) is >2 GiB from stub (0x{stubAddr:X}); " +
|
||||
"E8 rel32 cannot encode this distance. Place the stub closer to the target.");
|
||||
}
|
||||
buffer.Add(0xE8);
|
||||
EmitU32(buffer, (uint)distance);
|
||||
current += 5;
|
||||
|
||||
// Caller cleanup (cdecl only)
|
||||
int stackCount = args.Length - argIndex;
|
||||
if (convention == CallConvention.Cdecl && stackCount > 0)
|
||||
{
|
||||
int cleanup = stackCount * 4;
|
||||
if (cleanup <= 127)
|
||||
{
|
||||
buffer.Add(0x83); // add esp, imm8
|
||||
buffer.Add(0xC4);
|
||||
buffer.Add((byte)cleanup);
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer.Add(0x81); // add esp, imm32
|
||||
buffer.Add(0xC4);
|
||||
EmitU32(buffer, (uint)cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
buffer.Add(0xC3); // ret
|
||||
}
|
||||
|
||||
private void BuildX64Stub(List<byte> buffer, ulong stubAddr,
|
||||
ulong target, uint[] args)
|
||||
{
|
||||
// Windows x64 single ABI: first 4 args in RCX, RDX, R8D, R9D.
|
||||
ulong current = stubAddr;
|
||||
|
||||
var regCodes = new byte[] { 0xB9, 0xBA, 0xB8, 0xB9 };
|
||||
var rexBytes = new byte[] { 0x00, 0x00, 0x41, 0x41 };
|
||||
|
||||
int regCount = Math.Min(args.Length, 4);
|
||||
for (int i = 0; i < regCount; i++)
|
||||
{
|
||||
if (rexBytes[i] != 0)
|
||||
buffer.Add(rexBytes[i]);
|
||||
buffer.Add(regCodes[i]);
|
||||
EmitU32(buffer, args[i]);
|
||||
current += (rexBytes[i] != 0 ? 6u : 5u);
|
||||
}
|
||||
|
||||
// Push remaining args in reverse order
|
||||
for (int i = args.Length - 1; i >= 4; i--)
|
||||
{
|
||||
current += 5;
|
||||
buffer.Add(0x68);
|
||||
EmitU32(buffer, args[i]);
|
||||
}
|
||||
|
||||
// call rel32
|
||||
long distance = (long)target - (long)(current + 5);
|
||||
if (distance < int.MinValue || distance > int.MaxValue)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
"target and stub are >2 GiB apart; E8 rel32 cannot encode this distance.");
|
||||
}
|
||||
buffer.Add(0xE8);
|
||||
EmitU32(buffer, (uint)distance);
|
||||
|
||||
// Pop any args pushed on stack (x64 is caller-clean)
|
||||
int stackArgs = args.Length > 4 ? args.Length - 4 : 0;
|
||||
if (stackArgs > 0)
|
||||
{
|
||||
int bytes = stackArgs * 8;
|
||||
buffer.Add(0x48); // REX.W
|
||||
buffer.Add(bytes <= 127 ? (byte)0x83 : (byte)0x81); // add r/m64, imm8/imm32
|
||||
buffer.Add(0xC4); // rsp
|
||||
if (bytes <= 127)
|
||||
buffer.Add((byte)bytes);
|
||||
else
|
||||
EmitU32(buffer, (uint)bytes);
|
||||
}
|
||||
|
||||
buffer.Add(0xC3);
|
||||
}
|
||||
|
||||
// ── Instruction helpers ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Emit <c>mov reg32, imm32</c> and advances <paramref name="ip"/> by 5.</summary>
|
||||
private static void EmitMovRegImm32(List<byte> buffer, byte opcode, uint imm32, ref uint ip)
|
||||
{
|
||||
buffer.Add(opcode);
|
||||
buffer.Add((byte)imm32);
|
||||
buffer.Add((byte)(imm32 >> 8));
|
||||
buffer.Add((byte)(imm32 >> 16));
|
||||
buffer.Add((byte)(imm32 >> 24));
|
||||
ip += 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Out-of-process memory reader that accesses the target's memory through
|
||||
/// <see cref="NativeMethods.ReadProcessMemory"/> and
|
||||
/// <see cref="NativeMethods.WriteProcessMemory"/>.
|
||||
/// </summary>
|
||||
public sealed class ExternalReader : MemoryBase
|
||||
{
|
||||
private readonly SafeMemoryHandle _handle;
|
||||
private readonly IntPtr _imageBase;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// The default access rights: enough to read, write, allocate, query, run a remote
|
||||
/// thread, and wait on it. This deliberately omits <see cref="ProcessAccess.AllAccess"/>,
|
||||
/// which over-requests and makes <c>OpenProcess</c> fail on protected processes where
|
||||
/// these narrower rights would succeed.
|
||||
/// </summary>
|
||||
public const ProcessAccess DefaultAccess =
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation
|
||||
| ProcessAccess.QueryInformation | ProcessAccess.CreateThread | ProcessAccess.Synchronize;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a process for external memory access.
|
||||
/// </summary>
|
||||
/// <param name="process">The target process.</param>
|
||||
/// <param name="desiredAccess">The access rights to request. Defaults to
|
||||
/// <see cref="DefaultAccess"/>.</param>
|
||||
public ExternalReader(Process process, ProcessAccess desiredAccess = DefaultAccess)
|
||||
{
|
||||
_handle = NativeMethods.OpenProcess(desiredAccess, false, process.Id);
|
||||
if (_handle.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"OpenProcess failed for PID {process.Id}: error {error}");
|
||||
}
|
||||
|
||||
// Process.MainModule throws Win32Exception for a bitness-mismatched or protected
|
||||
// target; a missing image base must not sink the whole reader.
|
||||
try
|
||||
{
|
||||
_imageBase = process.MainModule?.BaseAddress ?? IntPtr.Zero;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
_imageBase = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IntPtr ImageBase => _imageBase;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override SafeMemoryHandle Handle => _handle;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
byte[] buffer = new byte[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 />
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)written;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_handle.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// In-process memory reader that accesses the owning process's memory through
|
||||
/// <see cref="NativeMethods.ReadProcessMemory"/> and
|
||||
/// <see cref="NativeMethods.WriteProcessMemory"/> on a handle to the current
|
||||
/// process. Unlike the unsafe-deref approach, this fails softly (returns
|
||||
/// empty / zero bytes) on invalid or protected addresses instead of crashing
|
||||
/// the host process with an <see cref="AccessViolationException"/>.
|
||||
/// </summary>
|
||||
public sealed class InProcessReader : MemoryBase
|
||||
{
|
||||
private readonly SafeMemoryHandle _handle;
|
||||
private readonly IntPtr _imageBase;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-process reader for the current process.
|
||||
/// </summary>
|
||||
public InProcessReader()
|
||||
{
|
||||
Process current = Process.GetCurrentProcess();
|
||||
_handle = NativeMethods.OpenProcess(
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation,
|
||||
false,
|
||||
current.Id);
|
||||
if (_handle.IsInvalid)
|
||||
{
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"OpenProcess failed for PID {current.Id}: error {error}");
|
||||
}
|
||||
|
||||
_imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IntPtr ImageBase => _imageBase;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override SafeMemoryHandle Handle => _handle;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
byte[] buffer = new byte[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 />
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)written;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_handle.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Computes and caches marshal-related metadata for type <typeparamref name="T"/>
|
||||
/// exactly once. <see cref="MemoryBase.Read{T}"/> and <see cref="MemoryBase.Write{T}"/>
|
||||
/// branch on these cached flags to decide between blittable <c>Span</c>/<c>MemoryMarshal</c>
|
||||
/// paths and the fallback marshal path.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to cache metadata for.</typeparam>
|
||||
public static class MarshalCache<T>
|
||||
{
|
||||
/// <summary>The unmanaged size of <typeparamref name="T"/> in bytes.</summary>
|
||||
public static readonly int Size;
|
||||
|
||||
/// <summary>The unmanaged size of <typeparamref name="T"/> as an unsigned integer.</summary>
|
||||
public static readonly uint SizeU;
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through the
|
||||
/// blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path and must
|
||||
/// use <see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/> instead.
|
||||
/// This is the case when a top-level field carries <see cref="MarshalAsAttribute"/>, or
|
||||
/// when <typeparamref name="T"/> contains a managed reference
|
||||
/// (<see cref="System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a
|
||||
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not detected.
|
||||
/// Reference-containing nested structs are still caught, because the reference check
|
||||
/// propagates through nested value types.
|
||||
/// </remarks>
|
||||
public static readonly bool TypeRequiresMarshal;
|
||||
|
||||
/// <summary><see langword="true"/> when <typeparamref name="T"/> is <see cref="IntPtr"/>.</summary>
|
||||
public static readonly bool IsIntPtr;
|
||||
|
||||
/// <summary>The underlying type code of <typeparamref name="T"/>.</summary>
|
||||
public static readonly TypeCode TypeCode;
|
||||
|
||||
/// <summary>
|
||||
/// The effective type that the marshaler uses. For an enum this is the underlying
|
||||
/// integer type; for all other types it is <typeparamref name="T"/> itself.
|
||||
/// </summary>
|
||||
public static readonly Type RealType;
|
||||
|
||||
static MarshalCache()
|
||||
{
|
||||
TypeCode = Type.GetTypeCode(typeof(T));
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
{
|
||||
Size = 1;
|
||||
RealType = typeof(T);
|
||||
}
|
||||
else if (typeof(T) == typeof(char))
|
||||
{
|
||||
// Marshal.SizeOf(char) is 1 (ANSI), but the blittable path reads/writes a
|
||||
// char as a 2-byte UTF-16 code unit. Size must match the blittable width.
|
||||
Size = 2;
|
||||
RealType = typeof(T);
|
||||
}
|
||||
else if (typeof(T).IsEnum)
|
||||
{
|
||||
Type underlying = typeof(T).GetEnumUnderlyingType();
|
||||
Size = Marshal.SizeOf(underlying);
|
||||
RealType = underlying;
|
||||
TypeCode = Type.GetTypeCode(underlying);
|
||||
}
|
||||
else
|
||||
{
|
||||
Size = Marshal.SizeOf(typeof(T));
|
||||
RealType = typeof(T);
|
||||
}
|
||||
|
||||
SizeU = (uint)Size;
|
||||
IsIntPtr = RealType == typeof(IntPtr);
|
||||
|
||||
bool hasMarshalAsField =
|
||||
RealType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
.Any(f => f.GetCustomAttributes(typeof(MarshalAsAttribute), true).Length != 0);
|
||||
|
||||
TypeRequiresMarshal =
|
||||
hasMarshalAsField || System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences<T>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using WhiteMagic.Native;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base for all memory-access readers and writers. Provides typed
|
||||
/// <see cref="Read{T}"/>/<see cref="Write{T}"/>, array IO, string IO, and
|
||||
/// relative/absolute addressing. Subclasses implement the concrete
|
||||
/// <see cref="ReadBytes"/> and <see cref="WriteBytes"/> methods.
|
||||
/// </summary>
|
||||
public abstract class MemoryBase : IDisposable
|
||||
{
|
||||
/// <summary>The base address of the target process's main module.</summary>
|
||||
public abstract IntPtr ImageBase { get; }
|
||||
|
||||
/// <summary>The native handle to the target process.</summary>
|
||||
public abstract SafeMemoryHandle Handle { get; }
|
||||
|
||||
// ── Raw byte IO ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Reads a sequence of bytes from the target address.</summary>
|
||||
public abstract byte[] ReadBytes(IntPtr address, int count, bool isRelative = false);
|
||||
|
||||
/// <summary>Writes a sequence of bytes to the target address.</summary>
|
||||
/// <returns>The number of bytes written.</returns>
|
||||
public abstract int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false);
|
||||
|
||||
// ── Typed IO ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Reads a value of type <typeparamref name="T"/> from the target address.</summary>
|
||||
/// <returns>The value, or <c>default(T)</c> when the read fails or returns fewer bytes than
|
||||
/// <see cref="MarshalCache{T}.Size"/>.</returns>
|
||||
public T Read<T>(IntPtr address, bool isRelative = false) where T : struct
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int size = MarshalCache<T>.Size;
|
||||
byte[] raw = ReadBytes(address, size);
|
||||
|
||||
if (raw.Length < size)
|
||||
return default;
|
||||
|
||||
if (MarshalCache<T>.TypeRequiresMarshal)
|
||||
return MarshalByteArrayToStructure<T>(raw);
|
||||
|
||||
return MemoryMarshal.Read<T>(raw.AsSpan());
|
||||
}
|
||||
|
||||
/// <summary>Writes a value of type <typeparamref name="T"/> to the target address.</summary>
|
||||
/// <returns><see langword="true"/> if all bytes were written.</returns>
|
||||
public bool Write<T>(IntPtr address, T value, bool isRelative = false) where T : struct
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int size = MarshalCache<T>.Size;
|
||||
|
||||
byte[] raw;
|
||||
if (MarshalCache<T>.TypeRequiresMarshal)
|
||||
raw = StructureToByteArray(value, size);
|
||||
else
|
||||
{
|
||||
raw = new byte[size];
|
||||
MemoryMarshal.Write(raw.AsSpan(), in value);
|
||||
}
|
||||
|
||||
int written = WriteBytes(address, raw, false);
|
||||
return written == size;
|
||||
}
|
||||
|
||||
/// <summary>Reads an array of values of type <typeparamref name="T"/> from the target address.</summary>
|
||||
/// <returns>An array of at most <paramref name="count"/> elements. May be shorter when the read
|
||||
/// returns fewer bytes than expected.</returns>
|
||||
public T[] Read<T>(IntPtr address, int count, bool isRelative = false) where T : struct
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(count);
|
||||
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int elementSize = MarshalCache<T>.Size;
|
||||
long totalSize = (long)elementSize * count;
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
|
||||
|
||||
byte[] raw = ReadBytes(address, (int)totalSize);
|
||||
int actualCount = Math.Min(count, raw.Length / elementSize);
|
||||
|
||||
var result = new T[actualCount];
|
||||
|
||||
if (actualCount == 0)
|
||||
return result;
|
||||
|
||||
if (MarshalCache<T>.TypeRequiresMarshal)
|
||||
{
|
||||
GCHandle pin = GCHandle.Alloc(raw, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr basePtr = pin.AddrOfPinnedObject();
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
result[i] = Marshal.PtrToStructure<T>(basePtr + (i * elementSize));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadOnlySpan<byte> span = raw;
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
result[i] = MemoryMarshal.Read<T>(span.Slice(i * elementSize, elementSize));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Writes an array of values of type <typeparamref name="T"/> to the target address.</summary>
|
||||
/// <returns><see langword="true"/> if all bytes were written.</returns>
|
||||
public bool Write<T>(IntPtr address, T[] values, bool isRelative = false) where T : struct
|
||||
{
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
if (values is null || values.Length == 0)
|
||||
return true;
|
||||
|
||||
int elementSize = MarshalCache<T>.Size;
|
||||
long total = (long)elementSize * values.Length;
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
|
||||
int totalSize = (int)total;
|
||||
byte[] raw = new byte[totalSize];
|
||||
|
||||
Span<byte> span = raw;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
Span<byte> slice = span.Slice(i * elementSize, elementSize);
|
||||
if (MarshalCache<T>.TypeRequiresMarshal)
|
||||
StructureToByteArray(values[i], slice, elementSize);
|
||||
else
|
||||
MemoryMarshal.Write(slice, in values[i]);
|
||||
}
|
||||
|
||||
int written = WriteBytes(address, raw, false);
|
||||
return written == totalSize;
|
||||
}
|
||||
|
||||
// ── String IO ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Reads a null-terminated string from the target address by scanning in small
|
||||
/// chunks. Stops at the null terminator, the maximum length, or the first page boundary
|
||||
/// that fails to read (avoids an atomic failure when a 512-byte window crosses an unmapped
|
||||
/// region).</summary>
|
||||
/// <param name="address">The address to read from.</param>
|
||||
/// <param name="encoding">The text encoding.</param>
|
||||
/// <param name="maxLength">The maximum number of bytes to read.</param>
|
||||
/// <param name="relative">If <see langword="true"/>, <paramref name="address"/> is relative
|
||||
/// to <see cref="ImageBase"/>.</param>
|
||||
public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
|
||||
{
|
||||
if (relative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
// The encoded null terminator. For ASCII/UTF-8 this is a single 0x00 byte;
|
||||
// for UTF-16 it is two zero bytes (0x00 0x00); for UTF-32 it is four.
|
||||
byte[] nullTerminator = encoding.GetBytes("\0");
|
||||
|
||||
const int chunkSize = 64;
|
||||
int remaining = maxLength;
|
||||
var accumulated = new System.Collections.Generic.List<byte[]>();
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int take = Math.Min(chunkSize, remaining);
|
||||
byte[] chunk = ReadBytes(address, take);
|
||||
if (chunk.Length == 0)
|
||||
break;
|
||||
|
||||
int nullPos = IndexOfPattern(chunk, nullTerminator);
|
||||
if (nullPos >= 0)
|
||||
{
|
||||
if (nullPos > 0)
|
||||
accumulated.Add(chunk[..nullPos]);
|
||||
break;
|
||||
}
|
||||
|
||||
accumulated.Add(chunk);
|
||||
// Advance by the bytes actually read, not the amount requested: a partial
|
||||
// read (chunk.Length < take) must not skip the unread tail of the window.
|
||||
address += chunk.Length;
|
||||
remaining -= chunk.Length;
|
||||
}
|
||||
|
||||
int totalLength = 0;
|
||||
foreach (byte[] part in accumulated)
|
||||
totalLength += part.Length;
|
||||
|
||||
byte[] combined = new byte[totalLength];
|
||||
int offset = 0;
|
||||
foreach (byte[] part in accumulated)
|
||||
{
|
||||
part.CopyTo(combined, offset);
|
||||
offset += part.Length;
|
||||
}
|
||||
|
||||
return encoding.GetString(combined);
|
||||
}
|
||||
|
||||
/// <summary>Writes a null-terminated string to the target address.</summary>
|
||||
public virtual bool WriteString(IntPtr address, string value, Encoding encoding, bool relative = false)
|
||||
{
|
||||
if (value.Length == 0 || value[^1] != '\0')
|
||||
value += '\0';
|
||||
|
||||
byte[] bytes = encoding.GetBytes(value);
|
||||
int written = WriteBytes(address, bytes, relative);
|
||||
return written == bytes.Length;
|
||||
}
|
||||
|
||||
// ── Addressing ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Converts a relative offset to an absolute address relative to <see cref="ImageBase"/>.</summary>
|
||||
public IntPtr GetAbsolute(IntPtr relative)
|
||||
{
|
||||
return ImageBase + (nint)relative;
|
||||
}
|
||||
|
||||
/// <summary>Converts an absolute address to a relative offset from <see cref="ImageBase"/>.
|
||||
/// This is the inverse of <see cref="GetAbsolute"/>: <c>GetAbsolute(GetRelative(a)) == a</c>.</summary>
|
||||
public IntPtr GetRelative(IntPtr absolute)
|
||||
{
|
||||
return (IntPtr)((nint)absolute - (nint)ImageBase);
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void Dispose()
|
||||
{
|
||||
Handle?.Dispose();
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private static T MarshalByteArrayToStructure<T>(byte[] bytes) where T : struct
|
||||
{
|
||||
GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
return Marshal.PtrToStructure<T>(pin.AddrOfPinnedObject());
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] StructureToByteArray<T>(T value, int size) where T : struct
|
||||
{
|
||||
byte[] bytes = new byte[size];
|
||||
GCHandle pin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void StructureToByteArray<T>(T value, Span<byte> destination, int size) where T : struct
|
||||
{
|
||||
byte[] bytes = StructureToByteArray(value, size);
|
||||
bytes.CopyTo(destination);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace WhiteMagic.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Access rights that open a process object.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ProcessAccess : uint
|
||||
{
|
||||
/// <summary>The right to terminate the process with TerminateProcess.</summary>
|
||||
Terminate = 0x0001,
|
||||
/// <summary>The right to create a thread in the process.</summary>
|
||||
CreateThread = 0x0002,
|
||||
/// <summary>The right to operate on the address space of the process.</summary>
|
||||
VmOperation = 0x0008,
|
||||
/// <summary>The right to read memory with ReadProcessMemory.</summary>
|
||||
VmRead = 0x0010,
|
||||
/// <summary>The right to write memory with WriteProcessMemory.</summary>
|
||||
VmWrite = 0x0020,
|
||||
/// <summary>The right to duplicate a handle with DuplicateHandle.</summary>
|
||||
DupHandle = 0x0040,
|
||||
/// <summary>The right to set information about the process.</summary>
|
||||
SetInformation = 0x0200,
|
||||
/// <summary>The right to read information about the process, such as the exit code.</summary>
|
||||
QueryInformation = 0x0400,
|
||||
/// <summary>The right to suspend or resume the process.</summary>
|
||||
SuspendResume = 0x0800,
|
||||
/// <summary>The right to read a limited set of information about the process.</summary>
|
||||
QueryLimitedInformation = 0x1000,
|
||||
/// <summary>The right to use the process object for synchronization.</summary>
|
||||
Synchronize = 0x00100000,
|
||||
|
||||
/// <summary>All access rights for a process object.</summary>
|
||||
AllAccess = 0x001F0000 | Synchronize | 0xFFFF,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Values that control how VirtualAllocEx allocates memory.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MemoryAllocationType : uint
|
||||
{
|
||||
/// <summary>Commit physical storage for the reserved pages. The pages start as zero.</summary>
|
||||
Commit = 0x00001000,
|
||||
/// <summary>Reserve a range of address space without physical storage.</summary>
|
||||
Reserve = 0x00002000,
|
||||
/// <summary>Reset the data in the range to indicate that it is no longer of interest.</summary>
|
||||
Reset = 0x00080000,
|
||||
/// <summary>Allocate memory at the highest possible address.</summary>
|
||||
TopDown = 0x00100000,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Values that protect a block of memory.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MemoryProtectionType : uint
|
||||
{
|
||||
/// <summary>No access to the committed pages.</summary>
|
||||
NoAccess = 0x01,
|
||||
/// <summary>Read access to the committed pages.</summary>
|
||||
ReadOnly = 0x02,
|
||||
/// <summary>Read and write access to the committed pages.</summary>
|
||||
ReadWrite = 0x04,
|
||||
/// <summary>Copy-on-write access to the committed pages.</summary>
|
||||
WriteCopy = 0x08,
|
||||
/// <summary>Execute access to the committed pages.</summary>
|
||||
Execute = 0x10,
|
||||
/// <summary>Execute and read access to the committed pages.</summary>
|
||||
ExecuteRead = 0x20,
|
||||
/// <summary>Execute, read, and write access to the committed pages.</summary>
|
||||
ExecuteReadWrite = 0x40,
|
||||
/// <summary>Execute and copy-on-write access to the committed pages.</summary>
|
||||
ExecuteWriteCopy = 0x80,
|
||||
/// <summary>The pages in the range become guard pages.</summary>
|
||||
Guard = 0x100,
|
||||
/// <summary>The system does not cache the committed pages.</summary>
|
||||
NoCache = 0x200,
|
||||
/// <summary>The system uses write-combined access for the pages.</summary>
|
||||
WriteCombine = 0x400,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Values that control how VirtualFreeEx frees memory.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MemoryFreeType : uint
|
||||
{
|
||||
/// <summary>Decommit the committed pages. The address range stays reserved.</summary>
|
||||
Decommit = 0x4000,
|
||||
/// <summary>Release the range of pages. The size must be zero.</summary>
|
||||
Release = 0x8000,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Values that set the initial state of a new thread.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ThreadCreationFlags : uint
|
||||
{
|
||||
/// <summary>The thread runs immediately after creation.</summary>
|
||||
RunImmediately = 0,
|
||||
/// <summary>The thread starts in a suspended state. Call ResumeThread to start it.</summary>
|
||||
CreateSuspended = 0x00000004,
|
||||
/// <summary>The stack-size parameter sets the reserve size of the stack.</summary>
|
||||
StackSizeParamIsAReservation = 0x00010000,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flags that select the registers that the thread-context functions read or write.
|
||||
/// There are separate constants for 32-bit (x86/WOW64) and 64-bit (AMD64) contexts.
|
||||
/// </summary>
|
||||
public static class ContextFlags
|
||||
{
|
||||
/// <summary>Architecture identifier for x86 contexts.</summary>
|
||||
public const uint X86 = 0x00010000;
|
||||
/// <summary>Architecture identifier for AMD64 contexts.</summary>
|
||||
public const uint Amd64 = 0x00100000;
|
||||
|
||||
/// <summary>x86: SS:SP, CS:IP, FLAGS, and BP.</summary>
|
||||
public const uint X86Control = X86 | 0x01;
|
||||
/// <summary>x86: AX, BX, CX, DX, SI, and DI.</summary>
|
||||
public const uint X86Integer = X86 | 0x02;
|
||||
/// <summary>x86: DS, ES, FS, and GS.</summary>
|
||||
public const uint X86Segments = X86 | 0x04;
|
||||
/// <summary>x86: control, integer, and segment registers.</summary>
|
||||
public const uint X86Full = X86Control | X86Integer | X86Segments;
|
||||
|
||||
/// <summary>AMD64: SegSs, Rsp, SegCs, Rip, and EFlags.</summary>
|
||||
public const uint Amd64Control = Amd64 | 0x01;
|
||||
/// <summary>AMD64: Rax, Rcx, Rdx, Rbx, Rbp, Rsi, Rdi, and R8 to R15.</summary>
|
||||
public const uint Amd64Integer = Amd64 | 0x02;
|
||||
/// <summary>AMD64: SegDs, SegEs, SegFs, and SegGs.</summary>
|
||||
public const uint Amd64Segments = Amd64 | 0x04;
|
||||
/// <summary>AMD64: control, integer, and segment registers.</summary>
|
||||
public const uint Amd64Full = Amd64Control | Amd64Integer | Amd64Segments;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WhiteMagic.Native;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke declarations for the Win32 process, memory, thread, and module
|
||||
/// APIs that WhiteMagic uses. Every declaration uses <see cref="LibraryImportAttribute"/>
|
||||
/// (source-generated interop). SetLastError is enabled on all calls that the
|
||||
/// Win32 API documents as setting a thread-local last-error value.
|
||||
/// </summary>
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
// ── Process ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Opens an existing process and returns a handle to it.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial SafeMemoryHandle OpenProcess(
|
||||
ProcessAccess desiredAccess,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool inheritHandle,
|
||||
int processId);
|
||||
|
||||
/// <summary>Closes an open object handle.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool CloseHandle(IntPtr handle);
|
||||
|
||||
// ── Memory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Reads memory from a process.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool ReadProcessMemory(
|
||||
SafeMemoryHandle process,
|
||||
IntPtr baseAddress,
|
||||
Span<byte> buffer,
|
||||
int size,
|
||||
out nint bytesRead);
|
||||
|
||||
/// <summary>Writes memory to a process.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool WriteProcessMemory(
|
||||
SafeMemoryHandle process,
|
||||
IntPtr baseAddress,
|
||||
ReadOnlySpan<byte> buffer,
|
||||
int size,
|
||||
out nint bytesWritten);
|
||||
|
||||
/// <summary>Reserves or commits a region of memory in a process.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial IntPtr VirtualAllocEx(
|
||||
SafeMemoryHandle process,
|
||||
IntPtr address,
|
||||
nint size,
|
||||
MemoryAllocationType allocationType,
|
||||
MemoryProtectionType protect);
|
||||
|
||||
/// <summary>Changes the protection on a committed region of memory.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool VirtualProtectEx(
|
||||
SafeMemoryHandle process,
|
||||
IntPtr address,
|
||||
nint size,
|
||||
MemoryProtectionType newProtect,
|
||||
out MemoryProtectionType oldProtect);
|
||||
|
||||
/// <summary>Releases or decommits a region of memory in a process.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool VirtualFreeEx(
|
||||
SafeMemoryHandle process,
|
||||
IntPtr address,
|
||||
nint size,
|
||||
MemoryFreeType freeType);
|
||||
|
||||
// ── Threading ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a thread that runs in the virtual address space of a process.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial SafeMemoryHandle CreateRemoteThread(
|
||||
SafeMemoryHandle process,
|
||||
IntPtr threadAttributes,
|
||||
nint stackSize,
|
||||
IntPtr startAddress,
|
||||
IntPtr parameter,
|
||||
ThreadCreationFlags creationFlags,
|
||||
out uint threadId);
|
||||
|
||||
/// <summary>Sets a 64-bit thread context (AMD64).</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetThreadContext(
|
||||
SafeMemoryHandle thread,
|
||||
ref Context64 context);
|
||||
|
||||
/// <summary>Gets a 64-bit thread context (AMD64).</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool GetThreadContext(
|
||||
SafeMemoryHandle thread,
|
||||
ref Context64 context);
|
||||
|
||||
/// <summary>Sets a 32-bit (WOW64) thread context.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool Wow64SetThreadContext(
|
||||
SafeMemoryHandle thread,
|
||||
ref Context32 context);
|
||||
|
||||
/// <summary>Gets a 32-bit (WOW64) thread context.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool Wow64GetThreadContext(
|
||||
SafeMemoryHandle thread,
|
||||
ref Context32 context);
|
||||
|
||||
// ── Modules ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Loads a module into the calling process.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true, EntryPoint = "LoadLibraryW")]
|
||||
internal static partial IntPtr LoadLibrary(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
|
||||
|
||||
/// <summary>Returns the address of a function or variable from a loaded module.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial IntPtr GetProcAddress(
|
||||
IntPtr hModule,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string lpProcName);
|
||||
|
||||
/// <summary>Waits until an object is signaled or the timeout elapses. Returns a
|
||||
/// <c>WAIT_*</c> status (DWORD); <c>WAIT_FAILED</c> is <c>0xFFFFFFFF</c>.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
internal static partial uint WaitForSingleObject(
|
||||
SafeMemoryHandle handle,
|
||||
uint milliseconds);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WhiteMagic.Native;
|
||||
|
||||
/// <summary>
|
||||
/// The x87 and MMX state inside a 32-bit thread context.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public unsafe struct FloatingSaveArea32
|
||||
{
|
||||
/// <summary>The x87 FPU control word.</summary>
|
||||
public uint ControlWord;
|
||||
/// <summary>The x87 FPU status word.</summary>
|
||||
public uint StatusWord;
|
||||
/// <summary>The x87 FPU tag word.</summary>
|
||||
public uint TagWord;
|
||||
/// <summary>The offset of the instruction that caused the last FPU exception.</summary>
|
||||
public uint ErrorOffset;
|
||||
/// <summary>The selector of the instruction that caused the last FPU exception.</summary>
|
||||
public uint ErrorSelector;
|
||||
/// <summary>The offset of the operand that caused the last FPU exception.</summary>
|
||||
public uint DataOffset;
|
||||
/// <summary>The selector of the operand that caused the last FPU exception.</summary>
|
||||
public uint DataSelector;
|
||||
/// <summary>The 80-byte register area.</summary>
|
||||
public fixed byte RegisterArea[80];
|
||||
/// <summary>The CR0 numeric-processor-extension state.</summary>
|
||||
public uint Cr0NpxState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 32-bit (x86/WOW64) thread context. Use it with
|
||||
/// <c>Wow64GetThreadContext</c> and <c>Wow64SetThreadContext</c> to inspect a 32-bit thread.
|
||||
/// The total size is 716 bytes.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public unsafe struct Context32
|
||||
{
|
||||
/// <summary>Selects which parts of the context are valid. See <see cref="ContextFlags"/>.</summary>
|
||||
public uint ContextFlags;
|
||||
|
||||
/// <summary>Debug register 0.</summary>
|
||||
public uint Dr0;
|
||||
/// <summary>Debug register 1.</summary>
|
||||
public uint Dr1;
|
||||
/// <summary>Debug register 2.</summary>
|
||||
public uint Dr2;
|
||||
/// <summary>Debug register 3.</summary>
|
||||
public uint Dr3;
|
||||
/// <summary>Debug register 6.</summary>
|
||||
public uint Dr6;
|
||||
/// <summary>Debug register 7.</summary>
|
||||
public uint Dr7;
|
||||
|
||||
/// <summary>The floating-point state.</summary>
|
||||
public FloatingSaveArea32 FloatSave;
|
||||
|
||||
/// <summary>The GS segment.</summary>
|
||||
public uint SegGs;
|
||||
/// <summary>The FS segment.</summary>
|
||||
public uint SegFs;
|
||||
/// <summary>The ES segment.</summary>
|
||||
public uint SegEs;
|
||||
/// <summary>The DS segment.</summary>
|
||||
public uint SegDs;
|
||||
|
||||
/// <summary>The EDI register.</summary>
|
||||
public uint Edi;
|
||||
/// <summary>The ESI register.</summary>
|
||||
public uint Esi;
|
||||
/// <summary>The EBX register.</summary>
|
||||
public uint Ebx;
|
||||
/// <summary>The EDX register.</summary>
|
||||
public uint Edx;
|
||||
/// <summary>The ECX register.</summary>
|
||||
public uint Ecx;
|
||||
/// <summary>The EAX register.</summary>
|
||||
public uint Eax;
|
||||
|
||||
/// <summary>The base (frame) pointer.</summary>
|
||||
public uint Ebp;
|
||||
/// <summary>The instruction pointer.</summary>
|
||||
public uint Eip;
|
||||
/// <summary>The CS segment.</summary>
|
||||
public uint SegCs;
|
||||
/// <summary>The flags register.</summary>
|
||||
public uint EFlags;
|
||||
/// <summary>The stack pointer.</summary>
|
||||
public uint Esp;
|
||||
/// <summary>The SS segment.</summary>
|
||||
public uint SegSs;
|
||||
|
||||
/// <summary>The extended (processor-specific) registers. The size is 512 bytes.</summary>
|
||||
public fixed byte ExtendedRegisters[512];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 64-bit (AMD64) thread context. Use it with the native
|
||||
/// <c>GetThreadContext</c> and <c>SetThreadContext</c> from a 64-bit process.
|
||||
/// The structure needs 16-byte alignment. The total size is 1232 bytes.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 16)]
|
||||
public unsafe struct Context64
|
||||
{
|
||||
/// <summary>Home storage for a register parameter.</summary>
|
||||
public ulong P1Home;
|
||||
/// <summary>Home storage for a register parameter.</summary>
|
||||
public ulong P2Home;
|
||||
/// <summary>Home storage for a register parameter.</summary>
|
||||
public ulong P3Home;
|
||||
/// <summary>Home storage for a register parameter.</summary>
|
||||
public ulong P4Home;
|
||||
/// <summary>Home storage for a register parameter.</summary>
|
||||
public ulong P5Home;
|
||||
/// <summary>Home storage for a register parameter.</summary>
|
||||
public ulong P6Home;
|
||||
|
||||
/// <summary>Selects which parts of the context are valid. See <see cref="ContextFlags"/>.</summary>
|
||||
public uint ContextFlags;
|
||||
/// <summary>The MXCSR register.</summary>
|
||||
public uint MxCsr;
|
||||
|
||||
/// <summary>The CS segment.</summary>
|
||||
public ushort SegCs;
|
||||
/// <summary>The DS segment.</summary>
|
||||
public ushort SegDs;
|
||||
/// <summary>The ES segment.</summary>
|
||||
public ushort SegEs;
|
||||
/// <summary>The FS segment.</summary>
|
||||
public ushort SegFs;
|
||||
/// <summary>The GS segment.</summary>
|
||||
public ushort SegGs;
|
||||
/// <summary>The SS segment.</summary>
|
||||
public ushort SegSs;
|
||||
|
||||
/// <summary>The flags register.</summary>
|
||||
public uint EFlags;
|
||||
|
||||
/// <summary>Debug register 0.</summary>
|
||||
public ulong Dr0;
|
||||
/// <summary>Debug register 1.</summary>
|
||||
public ulong Dr1;
|
||||
/// <summary>Debug register 2.</summary>
|
||||
public ulong Dr2;
|
||||
/// <summary>Debug register 3.</summary>
|
||||
public ulong Dr3;
|
||||
/// <summary>Debug register 6.</summary>
|
||||
public ulong Dr6;
|
||||
/// <summary>Debug register 7.</summary>
|
||||
public ulong Dr7;
|
||||
|
||||
/// <summary>The RAX register.</summary>
|
||||
public ulong Rax;
|
||||
/// <summary>The RCX register.</summary>
|
||||
public ulong Rcx;
|
||||
/// <summary>The RDX register.</summary>
|
||||
public ulong Rdx;
|
||||
/// <summary>The RBX register.</summary>
|
||||
public ulong Rbx;
|
||||
/// <summary>The stack pointer.</summary>
|
||||
public ulong Rsp;
|
||||
/// <summary>The base (frame) pointer.</summary>
|
||||
public ulong Rbp;
|
||||
/// <summary>The RSI register.</summary>
|
||||
public ulong Rsi;
|
||||
/// <summary>The RDI register.</summary>
|
||||
public ulong Rdi;
|
||||
/// <summary>The R8 register.</summary>
|
||||
public ulong R8;
|
||||
/// <summary>The R9 register.</summary>
|
||||
public ulong R9;
|
||||
/// <summary>The R10 register.</summary>
|
||||
public ulong R10;
|
||||
/// <summary>The R11 register.</summary>
|
||||
public ulong R11;
|
||||
/// <summary>The R12 register.</summary>
|
||||
public ulong R12;
|
||||
/// <summary>The R13 register.</summary>
|
||||
public ulong R13;
|
||||
/// <summary>The R14 register.</summary>
|
||||
public ulong R14;
|
||||
/// <summary>The R15 register.</summary>
|
||||
public ulong R15;
|
||||
|
||||
/// <summary>The instruction pointer.</summary>
|
||||
public ulong Rip;
|
||||
|
||||
/// <summary>The XMM save area. The size is 512 bytes.</summary>
|
||||
public fixed byte FltSave[512];
|
||||
|
||||
/// <summary>The vector registers (26 entries of 16 bytes, stored as 52 entries of 8 bytes).</summary>
|
||||
public fixed ulong VectorRegister[52];
|
||||
|
||||
/// <summary>The vector control register.</summary>
|
||||
public ulong VectorControl;
|
||||
|
||||
/// <summary>The debug-control MSR.</summary>
|
||||
public ulong DebugControl;
|
||||
/// <summary>The target RIP of the last branch.</summary>
|
||||
public ulong LastBranchToRip;
|
||||
/// <summary>The source RIP of the last branch.</summary>
|
||||
public ulong LastBranchFromRip;
|
||||
/// <summary>The target RIP of the last exception.</summary>
|
||||
public ulong LastExceptionToRip;
|
||||
/// <summary>The source RIP of the last exception.</summary>
|
||||
public ulong LastExceptionFromRip;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace WhiteMagic.Native;
|
||||
|
||||
/// <summary>
|
||||
/// A Win32 handle (process, thread, or snapshot) with a managed lifetime.
|
||||
/// The handle closes with <c>CloseHandle</c>, even after an exception or a thread abort.
|
||||
/// </summary>
|
||||
/// <remarks>The pattern comes from MemorySharp's SafeMemoryHandle.</remarks>
|
||||
public sealed class SafeMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes an empty handle. The interop marshaller uses this constructor for a
|
||||
/// handle that a system call returns (for example, <see cref="NativeMethods.OpenProcess"/>).
|
||||
/// </summary>
|
||||
public SafeMemoryHandle() : base(true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a raw handle and takes ownership of the handle.
|
||||
/// </summary>
|
||||
/// <param name="handle">The handle to own.</param>
|
||||
public SafeMemoryHandle(IntPtr handle) : base(true)
|
||||
{
|
||||
SetHandle(handle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
return NativeMethods.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Platforms>x86;x64;AnyCPU</Platforms>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="WhiteMagicTest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for relative/absolute addressing in <see cref="MemoryBase"/>.
|
||||
/// GetAbsolute(relative) = ImageBase + relative.
|
||||
/// GetRelative(absolute) = absolute - ImageBase (inverse of GetAbsolute).
|
||||
/// </summary>
|
||||
public class AddressingTests
|
||||
{
|
||||
private static ExternalReader OpenSelf()
|
||||
{
|
||||
return new ExternalReader(
|
||||
Process.GetCurrentProcess(),
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAbsolute_resolves_relative_offset()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
IntPtr imageBase = reader.ImageBase;
|
||||
IntPtr result = reader.GetAbsolute((IntPtr)0x1000);
|
||||
Assert.Equal(imageBase + 0x1000, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRelative_returns_absolute_minus_image_base()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
IntPtr imageBase = reader.ImageBase;
|
||||
IntPtr absolute = imageBase + 0x2000;
|
||||
IntPtr relative = reader.GetRelative(absolute);
|
||||
Assert.Equal((IntPtr)((nint)absolute - (nint)imageBase), relative);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAbsolute_and_GetRelative_are_inverses()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
IntPtr offset = (IntPtr)0x3000;
|
||||
|
||||
// Round-trip: offset -> absolute -> back to offset
|
||||
IntPtr absolute = reader.GetAbsolute(offset);
|
||||
IntPtr back = reader.GetRelative(absolute);
|
||||
Assert.Equal(offset, back);
|
||||
|
||||
// Reverse round-trip: absolute -> offset -> back to absolute
|
||||
IntPtr relative = reader.GetRelative(absolute);
|
||||
IntPtr absoluteAgain = reader.GetAbsolute(relative);
|
||||
Assert.Equal(absolute, absoluteAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRelative_on_ImageBase_returns_zero()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
IntPtr relative = reader.GetRelative(reader.ImageBase);
|
||||
Assert.Equal(IntPtr.Zero, relative);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_with_isRelative_true_uses_image_base()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
// DOS header 'MZ' at the image base
|
||||
byte firstByte = reader.Read<byte>(IntPtr.Zero, isRelative: true);
|
||||
Assert.Equal(0x4D, firstByte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_with_isRelative_true_resolves_correctly()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
int slot = 0;
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr absolute = pin.AddrOfPinnedObject();
|
||||
IntPtr relative = reader.GetRelative(absolute);
|
||||
|
||||
Assert.True(reader.Write(relative, 42, isRelative: true));
|
||||
Assert.Equal(42, reader.Read<int>(absolute));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadBytes_with_isRelative_true_resolves_correctly()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] data = reader.ReadBytes(IntPtr.Zero, 2, isRelative: true);
|
||||
Assert.Equal(0x4D, data[0]);
|
||||
Assert.Equal(0x5A, data[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="InProcessReader"/> — direct pointer dereference against
|
||||
/// the own process. Verifies the shared <see cref="MemoryBase"/> API works for
|
||||
/// both external and in-process readers.
|
||||
/// </summary>
|
||||
public class InProcessReaderTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImageBase_is_nonzero()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
Assert.NotEqual(IntPtr.Zero, reader.ImageBase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_int_reads_known_value_from_own_memory()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
int expected = 0x12345678;
|
||||
GCHandle pin = GCHandle.Alloc(expected, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
int result = reader.Read<int>(addr);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_int_writes_and_reads_back()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
int slot = 0;
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, unchecked((int)0xCAFEBABE)));
|
||||
Assert.Equal(unchecked((int)0xCAFEBABE), reader.Read<int>(addr));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_bytes_reads_known_bytes()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] expected = [0x0A, 0x0B, 0x0C, 0x0D];
|
||||
GCHandle pin = GCHandle.Alloc(expected, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
byte[] result = reader.ReadBytes(addr, 4);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_bytes_writes_and_reads_back()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] slot = new byte[4];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
byte[] expected = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||
|
||||
int written = reader.WriteBytes(addr, expected);
|
||||
Assert.Equal(4, written);
|
||||
|
||||
byte[] result = reader.ReadBytes(addr, 4);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_struct_via_InProcessReader()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var slot = new TestStruct { X = 10, Y = 20 };
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
var result = reader.Read<TestStruct>(addr);
|
||||
Assert.Equal(10, result.X);
|
||||
Assert.Equal(20, result.Y);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_struct_via_InProcessReader()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
var slot = new TestStruct { X = 1, Y = 2 };
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, new TestStruct { X = 99, Y = 88 }));
|
||||
var result = reader.Read<TestStruct>(addr);
|
||||
Assert.Equal(99, result.X);
|
||||
Assert.Equal(88, result.Y);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_disposes_handle()
|
||||
{
|
||||
var reader = CreateReader();
|
||||
Assert.False(reader.Handle.IsClosed);
|
||||
reader.Dispose();
|
||||
Assert.True(reader.Handle.IsClosed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag,
|
||||
/// IsIntPtr, and computed-once behavior.
|
||||
/// </summary>
|
||||
public class MarshalCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void Size_for_int_is_4()
|
||||
{
|
||||
Assert.Equal(4, MarshalCache<int>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_for_byte_is_1()
|
||||
{
|
||||
Assert.Equal(1, MarshalCache<byte>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_for_IntPtr_matches_native_pointer_size()
|
||||
{
|
||||
Assert.Equal(IntPtr.Size, MarshalCache<IntPtr>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_for_bool_is_1()
|
||||
{
|
||||
Assert.Equal(1, MarshalCache<bool>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_for_enum_matches_underlying_type()
|
||||
{
|
||||
Assert.Equal(4, MarshalCache<DayOfWeek>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_for_blittable_struct_is_accurate()
|
||||
{
|
||||
Assert.Equal(8, MarshalCache<BlittableStruct>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypeRequiresMarshal_is_false_for_blittable_types()
|
||||
{
|
||||
Assert.False(MarshalCache<int>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<long>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<BlittableStruct>.TypeRequiresMarshal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypeRequiresMarshal_is_true_for_types_with_MarshalAs_field()
|
||||
{
|
||||
Assert.True(MarshalCache<MarshalAsStruct>.TypeRequiresMarshal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIntPtr_is_true_for_IntPtr()
|
||||
{
|
||||
Assert.True(MarshalCache<IntPtr>.IsIntPtr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIntPtr_is_false_for_non_IntPtr_types()
|
||||
{
|
||||
Assert.False(MarshalCache<int>.IsIntPtr);
|
||||
Assert.False(MarshalCache<long>.IsIntPtr);
|
||||
Assert.False(MarshalCache<BlittableStruct>.IsIntPtr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_properties_are_computed_once_and_cached()
|
||||
{
|
||||
int size1 = MarshalCache<int>.Size;
|
||||
bool marshal1 = MarshalCache<int>.TypeRequiresMarshal;
|
||||
bool intPtr1 = MarshalCache<int>.IsIntPtr;
|
||||
|
||||
int size2 = MarshalCache<int>.Size;
|
||||
bool marshal2 = MarshalCache<int>.TypeRequiresMarshal;
|
||||
bool intPtr2 = MarshalCache<int>.IsIntPtr;
|
||||
|
||||
Assert.Equal(size1, size2);
|
||||
Assert.Equal(marshal1, marshal2);
|
||||
Assert.Equal(intPtr1, intPtr2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SizeU_matches_Size_as_uint()
|
||||
{
|
||||
Assert.Equal((uint)MarshalCache<int>.Size, MarshalCache<int>.SizeU);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct BlittableStruct
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MarshalAsStruct
|
||||
{
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
|
||||
public byte[] Data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MemoryBase"/> abstract contract and <see cref="ExternalReader"/>
|
||||
/// round-trip (Read<T>/Write<T>, arrays) using the current process as target.
|
||||
/// </summary>
|
||||
public class MemoryBaseTests
|
||||
{
|
||||
private static ExternalReader OpenSelf()
|
||||
{
|
||||
return new ExternalReader(
|
||||
Process.GetCurrentProcess(),
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImageBase_is_nonzero_for_self()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
Assert.NotEqual(IntPtr.Zero, reader.ImageBase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_int_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
|
||||
int slot = 0;
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, 0x1BADB002));
|
||||
Assert.Equal(0x1BADB002, reader.Read<int>(addr));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_byte_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte slot = 0;
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, (byte)0xAB));
|
||||
Assert.Equal(0xAB, reader.Read<byte>(addr));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_long_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
long slot = 0;
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, unchecked((long)0xDEADBEEF_CAFEBABE)));
|
||||
Assert.Equal(unchecked((long)0xDEADBEEF_CAFEBABE), reader.Read<long>(addr));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_blittable_struct_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
var slot = new TestStruct { X = 42, Y = 99 };
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, new TestStruct { X = 100, Y = 200 }));
|
||||
var result = reader.Read<TestStruct>(addr);
|
||||
Assert.Equal(100, result.X);
|
||||
Assert.Equal(200, result.Y);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_bytes_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] buffer = new byte[16];
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
byte[] expected = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
|
||||
|
||||
int written = reader.WriteBytes(addr, expected);
|
||||
Assert.Equal(expected.Length, written);
|
||||
|
||||
byte[] actual = reader.ReadBytes(addr, expected.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_int_array_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
int[] buffer = new int[4];
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
int[] expected = [10, 20, 30, 40];
|
||||
|
||||
Assert.True(reader.Write(addr, expected));
|
||||
int[] actual = reader.Read<int>(addr, 4);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_struct_array_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
var buffer = new TestStruct[4];
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
var expected = new[]
|
||||
{
|
||||
new TestStruct { X = 1, Y = 2 },
|
||||
new TestStruct { X = 3, Y = 4 },
|
||||
new TestStruct { X = 5, Y = 6 },
|
||||
new TestStruct { X = 7, Y = 8 },
|
||||
};
|
||||
|
||||
Assert.True(reader.Write(addr, expected));
|
||||
var actual = reader.Read<TestStruct>(addr, 4);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_returns_false_for_invalid_address()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
Assert.False(reader.Write(IntPtr.Zero, 42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_closes_handle()
|
||||
{
|
||||
var reader = OpenSelf();
|
||||
Assert.False(reader.Handle.IsClosed);
|
||||
reader.Dispose();
|
||||
Assert.True(reader.Handle.IsClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Double_dispose_does_not_throw()
|
||||
{
|
||||
var reader = OpenSelf();
|
||||
reader.Dispose();
|
||||
reader.Dispose();
|
||||
}
|
||||
|
||||
// ── Graceful failure on invalid addresses ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Read_int_on_invalid_address_returns_default()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
Assert.Equal(0, reader.Read<int>(IntPtr.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_struct_on_invalid_address_returns_default()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
var result = reader.Read<TestStruct>(IntPtr.Zero);
|
||||
Assert.Equal(0, result.X);
|
||||
Assert.Equal(0, result.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_int_array_on_invalid_address_returns_empty()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
Assert.Empty(reader.Read<int>(IntPtr.Zero, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_bytes_on_invalid_address_returns_empty()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
Assert.Empty(reader.ReadBytes(IntPtr.Zero, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple blittable struct for use in tests.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct TestStruct : IEquatable<TestStruct>
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
|
||||
public bool Equals(TestStruct other) => X == other.X && Y == other.Y;
|
||||
public override bool Equals(object? obj) => obj is TestStruct other && Equals(other);
|
||||
public override int GetHashCode() => HashCode.Combine(X, Y);
|
||||
public override string ToString() => $"({X}, {Y})";
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the edge-case defects found in the second review pass:
|
||||
/// char sizing, reference-containing structs, unchecked counts, the ReadString
|
||||
/// partial-chunk skip, and ExternalReader construction against awkward targets.
|
||||
/// Each test fails against the pre-fix code.
|
||||
/// </summary>
|
||||
public class MemoryHardeningTests
|
||||
{
|
||||
private static ExternalReader OpenSelf()
|
||||
{
|
||||
return new ExternalReader(
|
||||
Process.GetCurrentProcess(),
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
|
||||
}
|
||||
|
||||
// ── char sizing (MarshalCache / MemoryBase.Read<char>) ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void MarshalCache_char_size_is_two_bytes()
|
||||
{
|
||||
Assert.Equal(2, MarshalCache<char>.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_char_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
char slot = '\0';
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.Write(addr, 'Z'));
|
||||
Assert.Equal('Z', reader.Read<char>(addr));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_char_array_writes_and_reads_back()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
char[] slot = new char[4];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
char[] expected = ['w', 'o', 'w', '!'];
|
||||
Assert.True(reader.Write(addr, expected));
|
||||
Assert.Equal(expected, reader.Read<char>(addr, 4));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
// ── reference-containing structs route to the marshal path ──────────────
|
||||
|
||||
[Fact]
|
||||
public void MarshalCache_flags_struct_with_reference_field_as_marshal_required()
|
||||
{
|
||||
// Has a string field but no [MarshalAs]; the blittable path (MemoryMarshal.Read)
|
||||
// throws for a reference-containing T, so the cache must route it to the marshal
|
||||
// path. A struct with a managed reference cannot be pinned, so the routing flag —
|
||||
// not a live round-trip — is the regression guard here.
|
||||
Assert.True(MarshalCache<StructWithReference>.TypeRequiresMarshal);
|
||||
}
|
||||
|
||||
// ── unchecked count guards ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Read_array_with_negative_count_throws_argument_out_of_range()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
int dummy = 0;
|
||||
GCHandle pin = GCHandle.Alloc(dummy, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => reader.Read<int>(pin.AddrOfPinnedObject(), -1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_array_with_zero_count_returns_empty()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
int dummy = 0;
|
||||
GCHandle pin = GCHandle.Alloc(dummy, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
Assert.Empty(reader.Read<int>(pin.AddrOfPinnedObject(), 0));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReadString partial-chunk advance ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReadString_advances_by_actual_bytes_when_reads_are_partial()
|
||||
{
|
||||
// The reader serves at most 3 bytes per call. The string is longer than one
|
||||
// chunk with the terminator well past it. If ReadString advanced by the
|
||||
// requested count instead of the bytes actually returned, it would skip
|
||||
// data and truncate the result.
|
||||
byte[] data = Encoding.ASCII.GetBytes("ABCDEFGHIJ\0");
|
||||
var reader = new PartialReader(data, maxChunk: 3);
|
||||
|
||||
string result = reader.ReadString(IntPtr.Zero, Encoding.ASCII, maxLength: 64);
|
||||
|
||||
Assert.Equal("ABCDEFGHIJ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadString_stops_at_null_across_partial_chunks()
|
||||
{
|
||||
byte[] data = Encoding.ASCII.GetBytes("hi\0garbage");
|
||||
var reader = new PartialReader(data, maxChunk: 1);
|
||||
|
||||
string result = reader.ReadString(IntPtr.Zero, Encoding.ASCII, maxLength: 64);
|
||||
|
||||
Assert.Equal("hi", result);
|
||||
}
|
||||
|
||||
// ── ExternalReader construction ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ExternalReader_opens_self_with_default_access()
|
||||
{
|
||||
// The default access set must be small enough to open a normal process.
|
||||
using var reader = new ExternalReader(Process.GetCurrentProcess());
|
||||
Assert.False(reader.Handle.IsInvalid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="MemoryBase"/> that serves bytes from an in-memory buffer and
|
||||
/// caps every read to <c>maxChunk</c> bytes, to exercise partial-read handling.
|
||||
/// The address is treated as a zero-based index into the buffer.
|
||||
/// </summary>
|
||||
private sealed class PartialReader(byte[] data, int maxChunk) : MemoryBase
|
||||
{
|
||||
public override IntPtr ImageBase => IntPtr.Zero;
|
||||
public override SafeMemoryHandle Handle => null!;
|
||||
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
{
|
||||
int start = (int)address;
|
||||
if (start < 0 || start >= data.Length || count <= 0)
|
||||
return [];
|
||||
int n = Math.Min(Math.Min(count, maxChunk), data.Length - start);
|
||||
return data[start..(start + n)];
|
||||
}
|
||||
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A struct that carries a managed reference. <see cref="RuntimeHelpers.IsReferenceOrContainsReferences{T}"/>
|
||||
/// reports <see langword="true"/>, so it cannot travel the blittable read path.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct StructWithReference
|
||||
{
|
||||
public int Id;
|
||||
public string Name;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that exercise the P/Invoke surface against the current
|
||||
/// process. They prove the marshalling signatures are correct end-to-end.
|
||||
/// </summary>
|
||||
public class NativeSurfaceTests
|
||||
{
|
||||
private static SafeMemoryHandle OpenSelf(ProcessAccess access)
|
||||
{
|
||||
SafeMemoryHandle handle = NativeMethods.OpenProcess(access, false, Environment.ProcessId);
|
||||
Assert.False(handle.IsInvalid, $"OpenProcess failed: {Marshal.GetLastPInvokeError()}");
|
||||
return handle;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenProcess_on_self_returns_valid_handle_and_closes_on_dispose()
|
||||
{
|
||||
SafeMemoryHandle handle = OpenSelf(ProcessAccess.QueryInformation);
|
||||
Assert.False(handle.IsClosed);
|
||||
handle.Dispose();
|
||||
Assert.True(handle.IsClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadProcessMemory_reads_a_known_value_from_own_memory()
|
||||
{
|
||||
int value = 0x1BADB002;
|
||||
GCHandle pin = GCHandle.Alloc(value, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
using SafeMemoryHandle handle = OpenSelf(ProcessAccess.VmRead | ProcessAccess.QueryInformation);
|
||||
Span<byte> buffer = stackalloc byte[sizeof(int)];
|
||||
|
||||
bool ok = NativeMethods.ReadProcessMemory(
|
||||
handle, pin.AddrOfPinnedObject(), buffer, buffer.Length, out nint read);
|
||||
|
||||
Assert.True(ok, $"ReadProcessMemory failed: {Marshal.GetLastPInvokeError()}");
|
||||
Assert.Equal(sizeof(int), (int)read);
|
||||
Assert.Equal(value, BitConverter.ToInt32(buffer));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteProcessMemory_writes_a_value_into_own_memory()
|
||||
{
|
||||
int slot = 0;
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
using SafeMemoryHandle handle = OpenSelf(
|
||||
ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
|
||||
ReadOnlySpan<byte> payload = BitConverter.GetBytes(0x5EED);
|
||||
|
||||
bool ok = NativeMethods.WriteProcessMemory(
|
||||
handle, pin.AddrOfPinnedObject(), payload, payload.Length, out nint written);
|
||||
|
||||
Assert.True(ok, $"WriteProcessMemory failed: {Marshal.GetLastPInvokeError()}");
|
||||
Assert.Equal(payload.Length, (int)written);
|
||||
Assert.Equal(0x5EED, Marshal.ReadInt32(pin.AddrOfPinnedObject()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualAllocEx_commits_then_protects_then_frees()
|
||||
{
|
||||
using SafeMemoryHandle handle = OpenSelf(ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
|
||||
|
||||
IntPtr region = NativeMethods.VirtualAllocEx(
|
||||
handle, IntPtr.Zero, 0x1000,
|
||||
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
|
||||
MemoryProtectionType.ReadWrite);
|
||||
Assert.NotEqual(IntPtr.Zero, region);
|
||||
|
||||
bool protect = NativeMethods.VirtualProtectEx(
|
||||
handle, region, 0x1000, MemoryProtectionType.ExecuteReadWrite, out MemoryProtectionType old);
|
||||
Assert.True(protect, $"VirtualProtectEx failed: {Marshal.GetLastPInvokeError()}");
|
||||
Assert.Equal(MemoryProtectionType.ReadWrite, old);
|
||||
|
||||
bool free = NativeMethods.VirtualFreeEx(handle, region, 0, MemoryFreeType.Release);
|
||||
Assert.True(free, $"VirtualFreeEx failed: {Marshal.GetLastPInvokeError()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadLibrary_then_GetProcAddress_resolves_an_export()
|
||||
{
|
||||
IntPtr module = NativeMethods.LoadLibrary("kernel32.dll");
|
||||
Assert.NotEqual(IntPtr.Zero, module);
|
||||
|
||||
IntPtr proc = NativeMethods.GetProcAddress(module, "CloseHandle");
|
||||
Assert.NotEqual(IntPtr.Zero, proc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(Context32), 716)]
|
||||
[InlineData(typeof(Context64), 1232)]
|
||||
public void Thread_context_struct_has_the_exact_native_size(Type contextType, int expectedSize)
|
||||
{
|
||||
Assert.Equal(expectedSize, Marshal.SizeOf(contextType));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MemoryBase.ReadString"/> and <see cref="MemoryBase.WriteString"/>
|
||||
/// with encoding, null-terminator stop, and max-length behavior.
|
||||
/// </summary>
|
||||
public class StringReadWriteTests
|
||||
{
|
||||
private static ExternalReader OpenSelf()
|
||||
{
|
||||
return new ExternalReader(
|
||||
Process.GetCurrentProcess(),
|
||||
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_ascii_then_ReadString_round_trips()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[64];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.WriteString(addr, "hello", Encoding.ASCII));
|
||||
string result = reader.ReadString(addr, Encoding.ASCII);
|
||||
Assert.Equal("hello", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_utf8_then_ReadString_round_trips()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[64];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.WriteString(addr, "héllo wörld", Encoding.UTF8));
|
||||
string result = reader.ReadString(addr, Encoding.UTF8);
|
||||
Assert.Equal("héllo wörld", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_unicode_then_ReadString_round_trips()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[128];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
Assert.True(reader.WriteString(addr, "Hello\u00A9\u00AE\u20AC", Encoding.Unicode));
|
||||
string result = reader.ReadString(addr, Encoding.Unicode);
|
||||
Assert.Equal("Hello\u00A9\u00AE\u20AC", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadString_stops_at_null_terminator()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = Encoding.ASCII.GetBytes("hello\0world");
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 64);
|
||||
Assert.Equal("hello", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadString_respects_max_length()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = Encoding.ASCII.GetBytes("hello world this is a test");
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 5);
|
||||
Assert.Equal("hello", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_appends_null_terminator_automatically()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[32];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
|
||||
// Write without terminator
|
||||
Assert.True(reader.WriteString(addr, "test", Encoding.ASCII));
|
||||
|
||||
// The written bytes should end with \0
|
||||
byte[] read = reader.ReadBytes(addr, 8);
|
||||
Assert.Equal((byte)'t', read[0]);
|
||||
Assert.Equal((byte)'e', read[1]);
|
||||
Assert.Equal((byte)'s', read[2]);
|
||||
Assert.Equal((byte)'t', read[3]);
|
||||
Assert.Equal(0, read[4]); // null terminator
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadString_empty_buffer_returns_empty_string()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[1] { 0 };
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
string result = reader.ReadString(addr, Encoding.ASCII, maxLength: 1);
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_empty_string_writes_only_null()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
byte[] slot = new byte[8];
|
||||
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
|
||||
// Write a marker first
|
||||
reader.WriteBytes(addr, [0xAB, 0xCD, 0xEF, 0x00]);
|
||||
// Now overwrite with empty string
|
||||
Assert.True(reader.WriteString(addr, "", Encoding.ASCII));
|
||||
|
||||
byte[] read = reader.ReadBytes(addr, 4);
|
||||
Assert.Equal(0, read[0]); // null
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using WhiteMagic.Assembly;
|
||||
|
||||
namespace WhiteMagicTest;
|
||||
|
||||
public class StubAssemblerTests
|
||||
{
|
||||
private static StubAssembler Create() => new();
|
||||
|
||||
// ── Emit primitives ────────────────────────────────────────────────────
|
||||
|
||||
[Fact] public void EmitU8_appends_a_single_byte() { var s=Create(); var b=new List<byte>(); s.EmitU8(b,0xAB); Assert.Equal([0xAB],b); }
|
||||
[Fact] public void EmitU32_appends_little_endian() { var s=Create(); var b=new List<byte>(); s.EmitU32(b,0x11223344); Assert.Equal([0x44,0x33,0x22,0x11],b); }
|
||||
[Fact] public void EmitU32_appends_zero() { var s=Create(); var b=new List<byte>(); s.EmitU32(b,0); Assert.Equal([0,0,0,0],b); }
|
||||
[Fact] public void EmitU64_appends_little_endian() { var s=Create(); var b=new List<byte>(); s.EmitU64(b,0x1122334455667788); Assert.Equal([0x88,0x77,0x66,0x55,0x44,0x33,0x22,0x11],b); }
|
||||
[Fact] public void EmitU64_appends_high_bits() { var s=Create(); var b=new List<byte>(); s.EmitU64(b,0xDEADBEEF_CAFEBABE); Assert.Equal([0xBE,0xBA,0xFE,0xCA,0xEF,0xBE,0xAD,0xDE],b); }
|
||||
[Fact] public void StubAssembler_is_IAssembler() { Assert.IsAssignableFrom<IAssembler>(Create()); }
|
||||
[Fact] public void Assemble_throws() { Assert.Throws<NotSupportedException>(()=>Create().Assemble("nop",0)); }
|
||||
|
||||
// ── x86 cdecl ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Cdecl_0args()
|
||||
{
|
||||
uint r = 0x12345678u-(0x10000000u+5);
|
||||
Assert.Equal([0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cdecl_1arg()
|
||||
{
|
||||
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68,0xDD,0xCC,0xBB,0xAA,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
|
||||
0x83,0xC4,0x04,0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAABBCCDD],4,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cdecl_2args()
|
||||
{
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68,0x22,0x22,0x22,0x22,
|
||||
0x68,0x11,0x11,0x11,0x11,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
|
||||
0x83,0xC4,0x08,0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222],4,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
// ── x86 stdcall ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Stdcall_1arg()
|
||||
{
|
||||
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68,0xDD,0xCC,0xBB,0xAA,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAABBCCDD],4,CallConvention.Stdcall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stdcall_2args()
|
||||
{
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0x68,0x22,0x22,0x22,0x22,
|
||||
0x68,0x11,0x11,0x11,0x11,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222],4,CallConvention.Stdcall));
|
||||
}
|
||||
|
||||
// ── x86 thiscall ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Thiscall_ecx_then_stack()
|
||||
{
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0xB9,0x55,0x55,0xAA,0xAA,
|
||||
0x68,0x66,0x66,0xBB,0xBB,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAA5555,0xBBBB6666],4,CallConvention.Thiscall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thiscall_1arg_ecx_only()
|
||||
{
|
||||
uint ca=0x10000000u+5,r=0x12345678u-(ca+5);
|
||||
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xCAFEBABE],4,CallConvention.Thiscall);
|
||||
Assert.Equal(11,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xCAFEBABE,BitConverter.ToUInt32(s,1));
|
||||
Assert.Equal(0xE8,s[5]); Assert.Equal(r,BitConverter.ToUInt32(s,6)); Assert.Equal(0xC3,s[10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thiscall_0args_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Thiscall));
|
||||
}
|
||||
|
||||
// ── x86 fastcall ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Fastcall_ecx_edx_stack()
|
||||
{
|
||||
uint ca=0x10000000u+15,r=0x12345678u-(ca+5);
|
||||
Assert.Equal([
|
||||
0xB9,0x11,0x11,0x11,0x11,
|
||||
0xBA,0x22,0x22,0x22,0x22,
|
||||
0x68,0x33,0x33,0x33,0x33,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0x11111111,0x22222222,0x33333333],4,CallConvention.Fastcall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fastcall_2args_registers_only()
|
||||
{
|
||||
uint ca=0x10000000u+10,r=0x12345678u-(ca+5);
|
||||
byte[] s=Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[0xAAAAAAAA,0xBBBBBBBB],4,CallConvention.Fastcall);
|
||||
Assert.Equal(16,s.Length); Assert.Equal(0xB9,s[0]); Assert.Equal(0xAAAAAAAA,BitConverter.ToUInt32(s,1));
|
||||
Assert.Equal(0xBA,s[5]); Assert.Equal(0xBBBBBBBB,BitConverter.ToUInt32(s,6));
|
||||
Assert.Equal(0xE8,s[10]); Assert.Equal(r,BitConverter.ToUInt32(s,11)); Assert.Equal(0xC3,s[15]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fastcall_0args_is_valid()
|
||||
{
|
||||
uint r=0x12345678u-(0x10000000u+5);
|
||||
Assert.Equal([0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
Create().BuildCallStub((IntPtr)0x10000000,(IntPtr)0x12345678,[],4,CallConvention.Fastcall));
|
||||
}
|
||||
|
||||
// ── x64 ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void X64_0args()
|
||||
{
|
||||
var s=Create(); ulong a=0x100000000,t=0x123456788;
|
||||
uint r=(uint)(t-(a+5));
|
||||
byte[] stub=s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[],8,CallConvention.Cdecl);
|
||||
Assert.Equal(6,stub.Length); Assert.Equal(0xE8,stub[0]); Assert.Equal(r,BitConverter.ToUInt32(stub,1)); Assert.Equal(0xC3,stub[5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X64_1arg_mov_ecx()
|
||||
{
|
||||
var s=Create(); ulong a=0x100000000,t=0x123456788;
|
||||
uint r=(uint)(t-(a+5+5));
|
||||
Assert.Equal([
|
||||
0xB9,0xDD,0xCC,0xBB,0xAA,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0xAABBCCDD],8,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X64_4args_rcx_rdx_r8_r9()
|
||||
{
|
||||
var s=Create(); ulong a=0x100000000,t=0x123456788;
|
||||
uint ca=(uint)a+5+5+6+6,r=(uint)(t-(ca+5));
|
||||
Assert.Equal([
|
||||
0xB9,0x11,0x11,0x11,0x11, 0xBA,0x22,0x22,0x22,0x22,
|
||||
0x41,0xB8,0x33,0x33,0x33,0x33, 0x41,0xB9,0x44,0x44,0x44,0x44,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),0xC3],
|
||||
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[0x11111111,0x22222222,0x33333333,0x44444444],8,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X64_5args_push_cleanup()
|
||||
{
|
||||
var s=Create(); ulong a=0x100000000,t=0x123456788;
|
||||
uint ca=(uint)a+5+5+6+6+5,r=(uint)(t-(ca+5));
|
||||
Assert.Equal([
|
||||
0xB9,1,0,0,0, 0xBA,2,0,0,0,
|
||||
0x41,0xB8,3,0,0,0, 0x41,0xB9,4,0,0,0,
|
||||
0x68,5,0,0,0,
|
||||
0xE8,(byte)r,(byte)(r>>8),(byte)(r>>16),(byte)(r>>24),
|
||||
0x48,0x83,0xC4,8, 0xC3],
|
||||
s.BuildCallStub((IntPtr)(nint)a,(IntPtr)(nint)t,[1,2,3,4,5],8,CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
// ── Edge cases ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Far_target_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
Create().BuildCallStub(IntPtr.Zero, unchecked((IntPtr)(nint)0xC0000000), [], 4, CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Many_args_cleanup_uses_imm32_form()
|
||||
{
|
||||
var args = new uint[33];
|
||||
for (int i = 0; i < 33; i++) args[i] = (uint)(i * 0x10000 + i);
|
||||
byte[] stub = Create().BuildCallStub(
|
||||
(IntPtr)0x10000000, (IntPtr)0x12345678, args, 4, CallConvention.Cdecl);
|
||||
|
||||
for (int i = 0; i < stub.Length - 5; i++)
|
||||
{
|
||||
if (stub[i] == 0x81 && stub[i + 1] == 0xC4)
|
||||
{
|
||||
Assert.Equal(132, BitConverter.ToInt32(stub, i + 2));
|
||||
return;
|
||||
}
|
||||
}
|
||||
Assert.Fail("Expected 0x81 0xC4 (add esp, imm32) not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_pointerSize_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
Create().BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [], 2, CallConvention.Cdecl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_calling_convention_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
Create().BuildCallStub((IntPtr)0x10000000, (IntPtr)0x12345678, [], 4, (CallConvention)99));
|
||||
}
|
||||
|
||||
// ── No-FASM ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_fasm_reference_in_output()
|
||||
{
|
||||
var asm = typeof(StubAssembler).Assembly;
|
||||
var refs = asm.GetReferencedAssemblies();
|
||||
Assert.DoesNotContain(refs, r =>
|
||||
r.Name!.Contains("Fasm", StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Name!.Contains("ManagedFasm", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WhiteMagic\WhiteMagic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,93 @@
|
||||
# Process-Introspection Library Comparison — BlackMagic-old, MemorySharp, GreyMagic, BlackMagic
|
||||
|
||||
**Date**: 2026-07-21
|
||||
**Purpose**: Compare four C# process-introspection libraries present in this repo and derive the design of a modern successor ("WhiteMagic"). The OpenSpec change `whitemagic-foundation` formalizes the design; this document is the supporting study.
|
||||
|
||||
## The four subjects
|
||||
|
||||
| Library | Location | Era / Platform | Role in this study |
|
||||
|---|---|---|---|
|
||||
| **BlackMagic-old** | `reference/Blackmagic-old/` | .NET FW 4.0, x86 | The FASM "before" — initial commit, FASM as public API |
|
||||
| **MemorySharp** | `reference/MemorySharp/` (≡ `lib/MemorySharp/`, byte-identical) | .NET FW, x86 | Contemporaneous peer that kept FASM behind a polished API |
|
||||
| **GreyMagic** | `reference/GreyMagic/` | .NET FW, ~2016, x86 | Introduces the in-process execution model + detour/patch/marshal-cache |
|
||||
| **BlackMagic** (current) | `reference/Blackmagic/` | **.NET 8, x86 + x64** | The FASM "after" — modernized, FASM removed |
|
||||
|
||||
> `reference/MemorySharp/` and `lib/MemorySharp/` are identical copies (`diff -rq` clean).
|
||||
|
||||
## Where FASM lives (the through-line)
|
||||
|
||||
FASM (the Flat Assembler, via the `ManagedFasm` C++/CLI wrapper in `reference/fasm/`) exists in these libraries for exactly one job: **turning assembly text into machine code at runtime**, so a small call-stub / injection-stub can be synthesized when the target address, arguments, and calling convention are only known at call time.
|
||||
|
||||
- **BlackMagic-old** — FASM is a *public, load-bearing* dependency: `public ManagedFasm Asm { get; set; }` sits directly on the facade (`BMMain.cs:80`), and `SInject.cs` builds its DLL-redirect injection stub as mnemonic text at runtime.
|
||||
- **MemorySharp** — same dependency, *wrapped*: the assembler is internal (`Fasm32Assembler`, created unconditionally by `AssemblyFactory`), driven by calling-convention formatters behind `Execute<T>`.
|
||||
- **GreyMagic** — FASM only on the *external* path (`ExternalProcessReader.Asm`); the in-process path needs no assembler because it calls functions as delegates directly.
|
||||
- **BlackMagic (current)** — FASM **removed entirely**. Injection stubs became compile-time `byte[]` (`BuildStub32/64`); the public `Asm` property was deleted; runtime target execution moved to the D3D EndScene hook. See `FASM-MIGRATION.md`.
|
||||
|
||||
**Conclusion**: the assembly subsystem is not inherent to process introspection — it is a consequence of choosing `CreateRemoteThread` + "support arbitrary calling conventions" as the execution contract. Change the execution primitive (as current BM did) and the need for a runtime assembler evaporates. A managed assembler ([Iced](https://github.com/icedland/iced)) or hand-emitted stubs cover the residual need with no native dependency and full x64 support.
|
||||
|
||||
## Feature matrix
|
||||
|
||||
| Axis | BM-old | MemorySharp | GreyMagic | BM (current) |
|
||||
|---|---|---|---|---|
|
||||
| Platform | FW 4.0, x86 | FW, x86 | FW, x86 | **.NET 8, x86 + x64** |
|
||||
| Handles | raw `IntPtr` | `SafeMemoryHandle` | `SafeMemoryHandle` | `SafeMemoryHandle`, nullable |
|
||||
| Addressing | `uint` | `IntPtr` | `IntPtr` | `IntPtr` (64-bit-safe) |
|
||||
| Process model | external | external | **external + in-process** | external (+ frame-hook) |
|
||||
| Typed read/write | `ReadInt` etc. | `Read<T>` + marshal | `Read<T>` + **MarshalCache** | `Read<T> where unmanaged` |
|
||||
| Pattern scanning | ✅ | ❌ ("coming soon") | ❌ (has PE parser) | ✅ + cache |
|
||||
| FASM / assembler | **public `Asm`** | internal, behind `Execute<T>` | `Asm` (external only) | **none** |
|
||||
| Remote fn call | raw `Asm` stubs | **`Execute<T>(conv, args…)`** + async | **in-proc delegates** | D3D frame-hook stub |
|
||||
| Function hooking | — | — | **Detour mgr** (reversible, `CallOriginal`) | Frame-hook only |
|
||||
| Byte patching | — | ❌ ("coming soon") | **Patch mgr** (named, reversible) | ad hoc |
|
||||
| DLL injection | CreateThread + hijack | LoadLibrary via CreateThread | (via `Asm`) | CreateThread + hijack, x86/x64 |
|
||||
| Named allocation | — | `RemoteAllocation` | **`AllocatedMemory`** (by name) | `AllocateMemory` |
|
||||
| PE parsing | — | — | **`PeHeaderParser`** | — |
|
||||
| PEB / TEB | — | ✅ `ManagedPeb`/`ManagedTeb` | — | — |
|
||||
| Window mutation | — | ✅ (move/resize/title/flash) | — | — |
|
||||
| Input simulation | — | ✅ (keyboard/mouse, no focus) | — | — |
|
||||
| High-level ergonomics | simple facade | `sharp[addr]`, `module["fn"]`, Enum reads | `CreateFunction<T>`, vtable helpers | facade |
|
||||
| Helpers | — | ApplicationFinder, HandleManipulator, Randomizer, Serialization, Singleton | MarshalCache, Utilities | — |
|
||||
|
||||
## What each does best (the "take from each" summary)
|
||||
|
||||
- **BlackMagic-old** → the clean minimal `Open / Read / Write / FindPattern` facade; it is also the historical proof that FASM was once load-bearing and can be retired.
|
||||
- **MemorySharp** → high-level ergonomics: calling-convention `Execute<T>` + parameter marshalling + async, `RemotePointer` indexer, PEB/TEB, window + keyboard/mouse simulation, helper utilities.
|
||||
- **GreyMagic** → the engine: dual in/out-of-process `MemoryBase`, `MarshalCache<T>` fast typed IO, reversible `DetourManager` + `PatchManager`, `CreateFunction<T>`/vtable helpers, named `AllocatedMemory`, `PeHeaderParser`.
|
||||
- **BlackMagic (current)** → the modern platform: .NET 8, `SafeMemoryHandle`, nullable, `Span<byte>`, **x64**, pattern scanning + cache, rich DLL injection (CreateThread + thread-hijack, x86/x64 stubs), hand-assembled stubs (no FASM), per-frame hook (crash-safe execution), test coverage.
|
||||
|
||||
## The crash-safety principle (why `CreateRemoteThread` needs care)
|
||||
|
||||
`CreateRemoteThread` does not crash the target — **calling target internals from the wrong thread does**. The target's main thread has exclusive affinity for its scripting VM, the render device, and the object model. A thread you spawn runs concurrently with it; the moment a payload touches that state (scripting-engine entry points, object traversal) it races the main thread → memory corruption → crash. This matches the "3 crashes in one session" recorded in `FASM-MIGRATION.md`.
|
||||
|
||||
The rule the successor must encode — **split execution by payload safety**:
|
||||
|
||||
1. **`CreateRemoteThread` is safe** for *self-contained, thread-agnostic* payloads: `LoadLibrary` (DLL injection), pure WinAPI, code touching only memory you own.
|
||||
2. **State-sensitive calls must run on the target's own thread**, reached by hooking a per-frame function (D3D `EndScene`, or any frame function via a detour) and draining a work queue there each frame.
|
||||
3. **In-process** (once a managed DLL is injected), call target functions directly as delegates — no thread crossing at all.
|
||||
|
||||
## WhiteMagic — synthesis
|
||||
|
||||
A modern successor unifying the four. Full design in `openspec/changes/whitemagic-foundation/`.
|
||||
|
||||
```
|
||||
WhiteMagic (facade — BM-old ergonomics)
|
||||
├─ Core: SafeHandle, native P/Invoke, x64 [BM current]
|
||||
├─ MemoryBase (abstract Read/Write + MarshalCache) [GreyMagic]
|
||||
│ ├─ ExternalReader (RPM/WPM)
|
||||
│ └─ InProcessReader (direct deref, injected)
|
||||
├─ Discovery: PatternScanner(+cache), PeHeaderParser [BM current + GreyMagic]
|
||||
├─ Allocation: AllocatedMemory (named chunks) [GreyMagic]
|
||||
├─ Assembler: IAssembler → { HandStubs | Iced } [BM current; Iced replaces FASM]
|
||||
├─ Execution (three tiers):
|
||||
│ ├─ RemoteThreadExecutor (CreateRemoteThread — safe payloads) [MemorySharp Execute<T>, no FASM]
|
||||
│ ├─ MainThreadPump (frame-hook work queue) [BM frame-hook + GreyMagic detour] ← crash-safe
|
||||
│ └─ InProcessInvoker (CreateFunction<T> delegates) [GreyMagic]
|
||||
├─ Hooking: DetourManager + PatchManager [GreyMagic]
|
||||
├─ Injection: CreateThread + ThreadHijack (x86/x64) [BM current]
|
||||
├─ HighLevel: RemotePointer, Module["fn"], PEB/TEB,
|
||||
│ input sim, window, async, helpers [MemorySharp]
|
||||
└─ Safety: disassemble-before-splice (Iced), auto-restore
|
||||
all patches/detours on Dispose [new]
|
||||
```
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-21
|
||||
@@ -0,0 +1,74 @@
|
||||
## Context
|
||||
|
||||
BlackMagic is a pure C# process manipulation library. It replaced FASM (native x86 assembler DLL) with hand-assembled `byte[]` stubs. Two capabilities were lost:
|
||||
|
||||
- `InjectAndExecuteEx`: non-blocking remote thread that returns a handle without waiting.
|
||||
- Text-based assembly: build code payloads from `pushad`, `mov eax, 0x1234`, etc. instead of raw bytes.
|
||||
|
||||
Existing code:
|
||||
- `BMThread.cs` has blocking `Execute(addr, param)` → waits 10s, returns exit code.
|
||||
- `BMThread.cs` has `CreateRemoteThread(addr, param)` → returns `SafeMemoryHandle?`.
|
||||
- `SInject.cs` has `InjectCode(addr, bytes)` and `InjectCode(bytes)` → allocate + write.
|
||||
- `BlackMagicTest/` uses xUnit, `net8.0-windows`, pure-logic tests (no process needed).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Add `InjectAndExecuteEx()`: inject code + create remote thread, return handle, do NOT wait.
|
||||
- Add `AsmBuilder`: pure C# x86 text assembler. Convert `"pushad\nmov eax, 1\npopad"` → `byte[]`.
|
||||
- Add `SetPassLimit(int)` on `AsmBuilder` for label resolution iteration control.
|
||||
- Add convenience overloads: `InjectAndExecute(string asm)`, `InjectAndExecuteEx(string asm)`.
|
||||
- TDD: write tests first for every public API surface.
|
||||
|
||||
**Non-Goals:**
|
||||
- Full x86 instruction set (only common payload subset: mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je/jne, inc, add, sub, xor, and, or, cmp, lea, nop, hlt).
|
||||
- x64 text assembly (keep x86 only; x64 stubs remain hand-assembled `byte[]`).
|
||||
- Reimplementing FASM's directive system (`org`, `use32`, macros).
|
||||
- Native DLL dependency.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: AsmBuilder lives in `BlackMagic/Asm/AsmBuilder.cs`
|
||||
|
||||
New directory `Asm/` keeps assembler code isolated. Static class, no instance state needed — each `Assemble()` call is self-contained.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Instance class with `AddLine()` builder pattern → rejected: adds statefulness for no benefit. Each payload is built fresh.
|
||||
- Put in `Injection/` → rejected: assembler is generic, not injection-specific.
|
||||
|
||||
### D2: Two-pass assembler (labels + bytes)
|
||||
|
||||
Pass 1: scan instructions, record label positions, emit bytes (reserving 4 bytes for near jumps). Pass 2: resolve label offsets, patch jump targets.
|
||||
|
||||
This handles forward references (`jmp @skip` before `@skip:` label is defined) without multiple iterations. `SetPassLimit()` caps the loop for safety but 2 passes is sufficient for all payload patterns.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Single-pass → rejected: can't resolve forward jumps.
|
||||
- FASM-style multi-pass → overkill: payloads don't need complex expression evaluation.
|
||||
|
||||
### D3: Instruction encoding via switch + helper methods
|
||||
|
||||
Each instruction maps to hand-coded byte emission. Helper methods: `EmitU8()`, `EmitU32()`, `EmitModRM()`, `EmitSIB()`. Same pattern as `SInject.cs`'s existing `BuildStub()` methods.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Lookup table / dictionary → rejected: instruction encoding is irregular (ModRM, SIB, displacement, immediate). Switch statements are clearer.
|
||||
|
||||
### D4: InjectAndExecuteEx returns `SafeMemoryHandle`
|
||||
|
||||
Matches existing `CreateRemoteThread()` return type. Caller is responsible for disposing the handle. No auto-wait, no auto-close.
|
||||
|
||||
### D5: TDD approach
|
||||
|
||||
Write xUnit tests in `BlackMagicTest/AsmBuilderTests.cs` first:
|
||||
- Each instruction: known input text → expected `byte[]` output.
|
||||
- Label resolution: forward jump, backward jump, multiple labels.
|
||||
- Error cases: unknown instruction, missing operand, invalid register.
|
||||
- `SetPassLimit`: verify iteration cap is respected.
|
||||
|
||||
Then implement `AsmBuilder` to make tests pass.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Instruction subset**: users may need an instruction not in the initial set. Mitigation: document supported instructions, add new ones incrementally.
|
||||
- **Label complexity**: relative jumps are limited to ±127 bytes (near) or ±2GB (far). Payloads rarely exceed this, but document the limit.
|
||||
- **No runtime validation of payloads**: the assembler produces bytes; it doesn't verify the result is safe to execute. This matches FASM's behavior — the assembler doesn't validate semantics.
|
||||
@@ -0,0 +1,23 @@
|
||||
## Why
|
||||
|
||||
BlackMagic replaced FASM for code-payload generation but lost two useful capabilities:
|
||||
|
||||
1. **Non-blocking remote execution** (`InjectAndExecuteEx`): FASM's managed wrapper returned a thread handle without waiting. BlackMagic only has blocking `Execute()`. For DLL injection, a non-blocking variant avoids hanging when the target is slow to load.
|
||||
|
||||
2. **Text-based assembly**: FASM allowed building code payloads from assembly text (`AddLine("pushad")`). BlackMagic requires hand-assembled `byte[]`. For prototyping, debugging, and one-off code payloads, text assembly is faster to write and easier to review. A managed assembler eliminates the native FASM DLL dependency while keeping the ergonomic benefit.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `InjectAndExecuteEx()` to `BlackMagic` and `BMThread`: inject code then create a remote thread without waiting, returning the thread handle.
|
||||
- Add `AsmBuilder` class: pure C# x86 text assembler that converts instruction text to `byte[]` machine code. Supports common payload instructions (mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je, jne, inc, add, sub, xor, etc.).
|
||||
- Add `InjectAndExecute(string asm)` and `InjectAndExecuteEx(string asm)` overloads that accept assembly text, assemble via `AsmBuilder`, then inject+execute.
|
||||
- Add `SetPassLimit()` to `AsmBuilder` for label resolution iteration control.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `non-blocking-execute`: Non-blocking remote thread creation that returns a handle without waiting for exit.
|
||||
- `text-assembler`: Pure C# x86 text assembler converting assembly source to byte arrays without native dependencies.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None — additive features only. -->
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: InjectAndExecuteEx creates remote thread without waiting
|
||||
|
||||
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` injects code at `startAddress` into the opened process, creates a remote thread with `parameter`, and returns the thread handle immediately without waiting for the thread to exit.
|
||||
|
||||
#### Scenario: successful non-blocking execution
|
||||
- **WHEN** a process is open and `InjectAndExecuteEx(addr, param)` is called with a valid code address
|
||||
- **THEN** a remote thread is created in the target process and a valid `SafeMemoryHandle` is returned
|
||||
|
||||
#### Scenario: no process open
|
||||
- **WHEN** no process is open and `InjectAndExecuteEx(addr, param)` is called
|
||||
- **THEN** `null` is returned
|
||||
|
||||
### Requirement: InjectAndExecuteEx single-parameter overload
|
||||
|
||||
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress)` calls `InjectAndExecuteEx(startAddress, IntPtr.Zero)`.
|
||||
|
||||
#### Scenario: parameter-less non-blocking execution
|
||||
- **WHEN** `InjectAndExecuteEx(addr)` is called with a valid address
|
||||
- **THEN** the thread is created with parameter `IntPtr.Zero`
|
||||
|
||||
### Requirement: InjectAndExecuteEx from assembly text
|
||||
|
||||
`BlackMagic.InjectAndExecuteEx(string asm)` assembles the text via `AsmBuilder`, allocates remote memory, writes the bytes, calls `InjectAndExecuteEx` on the allocated address, and returns the thread handle.
|
||||
|
||||
#### Scenario: execute assembly text non-blocking
|
||||
- **WHEN** `InjectAndExecuteEx("nop")` is called with a process open
|
||||
- **THEN** the text is assembled to bytes, written to remote memory, a thread is started, and the handle is returned
|
||||
|
||||
#### Scenario: assembly failure
|
||||
- **WHEN** `InjectAndExecuteEx("invalidinstruction")` is called
|
||||
- **THEN** `ArgumentException` is thrown with the assembly error
|
||||
|
||||
### Requirement: InjectAndExecute from assembly text (blocking convenience)
|
||||
|
||||
`BlackMagic.InjectAndExecute(string asm)` assembles the text, allocates remote memory, writes the bytes, calls `Execute` (blocking, 10s timeout), and returns the exit code.
|
||||
|
||||
#### Scenario: execute assembly text blocking
|
||||
- **WHEN** `InjectAndExecute("mov eax, 42\nret")` is called with a process open
|
||||
- **THEN** the text is assembled, injected, executed, and the thread exit code is returned
|
||||
@@ -0,0 +1,84 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: AsmBuilder assembles x86 instruction text to byte array
|
||||
|
||||
`AsmBuilder.Assemble(string source)` parses x86 assembly text and returns the corresponding `byte[]` machine code.
|
||||
|
||||
#### Scenario: single instruction
|
||||
- **WHEN** `AsmBuilder.Assemble("nop")` is called
|
||||
- **THEN** the result is `[0x90]`
|
||||
|
||||
#### Scenario: multiple instructions
|
||||
- **WHEN** `AsmBuilder.Assemble("pushad\npopad")` is called
|
||||
- **THEN** the result is `[0x60, 0x61]`
|
||||
|
||||
#### Scenario: instruction with immediate operand
|
||||
- **WHEN** `AsmBuilder.Assemble("mov eax, 1")` is called
|
||||
- **THEN** the result is `[0xB8, 0x01, 0x00, 0x00, 0x00]`
|
||||
|
||||
### Requirement: AsmBuilder supports register operands
|
||||
|
||||
Supported registers: `eax`, `ecx`, `edx`, `ebx`, `esp`, `ebp`, `esi`, `edi` (and 8-bit: `al`, `cl`, `dl`, `bl`, `ah`, `ch`, `dh`, `bh`).
|
||||
|
||||
#### Scenario: register-to-register move
|
||||
- **WHEN** `AsmBuilder.Assemble("mov eax, ecx")` is called
|
||||
- **THEN** the result is `[0x89, 0xC8]` (mov eax, ecx encoding)
|
||||
|
||||
#### Scenario: register encoding
|
||||
- **WHEN** registers are used in instructions
|
||||
- **THEN** each register maps to its correct 3-bit encoding (eax=0, ecx=1, edx=2, ebx=3, esp=4, ebp=5, esi=6, edi=7)
|
||||
|
||||
### Requirement: AsmBuilder supports labels and jumps
|
||||
|
||||
Labels are defined with `@name:` and referenced with `jmp @name` or `je @name`. Forward and backward references are resolved in a second pass.
|
||||
|
||||
#### Scenario: forward jump
|
||||
- **WHEN** `AsmBuilder.Assemble("jmp @skip\nnop\n@skip:\nret")` is called
|
||||
- **THEN** the jump skips exactly over the `nop` (2 bytes) and lands on `ret`
|
||||
|
||||
#### Scenario: backward jump
|
||||
- **WHEN** `AsmBuilder.Assemble("@loop:\nnop\njmp @loop")` is called
|
||||
- **THEN** the jump targets the earlier label correctly
|
||||
|
||||
#### Scenario: multiple labels
|
||||
- **WHEN** multiple labels are used in one source
|
||||
- **THEN** each label resolves to its correct byte offset
|
||||
|
||||
### Requirement: AsmBuilder SetPassLimit controls iteration
|
||||
|
||||
`AsmBuilder.SetPassLimit(int limit)` sets the maximum number of assembly passes for label resolution. Default is 10. If the limit is exceeded before all labels resolve, `InvalidOperationException` is thrown.
|
||||
|
||||
#### Scenario: default pass limit
|
||||
- **WHEN** no `SetPassLimit` is called
|
||||
- **THEN** the assembler uses 10 passes maximum
|
||||
|
||||
#### Scenario: custom pass limit
|
||||
- **WHEN** `SetPassLimit(20)` is called
|
||||
- **THEN** the assembler uses 20 passes maximum
|
||||
|
||||
#### Scenario: pass limit exceeded
|
||||
- **WHEN** forward references cannot resolve within the pass limit
|
||||
- **THEN** `InvalidOperationException` is thrown with label resolution details
|
||||
|
||||
### Requirement: AsmBuilder reports clear errors
|
||||
|
||||
Unknown instructions, missing operands, and invalid register names produce `ArgumentException` with the line number and offending text.
|
||||
|
||||
#### Scenario: unknown instruction
|
||||
- **WHEN** `AsmBuilder.Assemble("xyzw")` is called
|
||||
- **THEN** `ArgumentException` is thrown mentioning line 1 and "xyzw"
|
||||
|
||||
#### Scenario: missing operand
|
||||
- **WHEN** `AsmBuilder.Assemble("mov")` is called (no operands)
|
||||
- **THEN** `ArgumentException` is thrown mentioning missing operand
|
||||
|
||||
### Requirement: AsmBuilder supported instruction set
|
||||
|
||||
The following x86 instructions are supported:
|
||||
- **Data movement**: `mov`, `push`, `pop`, `pushad`, `popad`, `lea`
|
||||
- **Arithmetic**: `add`, `sub`, `inc`, `dec`, `xor`, `and`, `or`, `cmp`, `test`
|
||||
- **Control flow**: `jmp`, `je`, `jne`, `call`, `ret`, `nop`, `hlt`
|
||||
|
||||
#### Scenario: all instructions produce valid bytes
|
||||
- **WHEN** each supported instruction is assembled individually
|
||||
- **THEN** it produces the correct x86 machine code encoding
|
||||
@@ -0,0 +1,44 @@
|
||||
## 1. AsmBuilder — Core (TDD: tests first)
|
||||
|
||||
- [ ] 1.1 Create `BlackMagicTest/AsmBuilderTests.cs` with tests for single-instruction assembly: `nop` → `[0x90]`, `pushad` → `[0x60]`, `popad` → `[0x61]`, `ret` → `[0xC3]`, `hlt` → `[0xF4]`
|
||||
- [ ] 1.2 Create `BlackMagic/Asm/AsmBuilder.cs` with `Assemble(string source)` entry point and instruction table. Implement `nop`, `pushad`, `popad`, `ret`, `hlt` to make step 1.1 tests pass
|
||||
- [ ] 1.3 Add tests for `mov reg, imm32` (eax, ecx, edx, ebx, esp, ebp, esi, edi) — each register maps to `0xB8 + reg` encoding with 4-byte little-endian immediate
|
||||
- [ ] 1.4 Implement `mov reg, imm32` in AsmBuilder to make step 1.3 tests pass
|
||||
- [ ] 1.5 Add tests for `mov reg, reg` (register-to-register via ModRM byte 0xC0 + (src<<3 | dst))
|
||||
- [ ] 1.6 Implement `mov reg, reg` to make step 1.5 tests pass
|
||||
- [ ] 1.7 Add tests for `push reg` (`0x50 + reg`), `pop reg` (`0x58 + reg`)
|
||||
- [ ] 1.8 Implement `push reg`, `pop reg` to make step 1.7 tests pass
|
||||
- [ ] 1.9 Add tests for `add reg, imm8` (`0x83 /0` with sign-extended byte), `sub reg, imm8` (`0x83 /5`), `xor reg, reg` (`0x31 /r`), `and reg, imm8`, `or reg, imm8`, `cmp reg, imm8` (`0x83 /7`)
|
||||
- [ ] 1.10 Implement `add`, `sub`, `xor`, `and`, `or`, `cmp` to make step 1.9 tests pass
|
||||
- [ ] 1.11 Add tests for `inc reg` (`0x40 + reg`), `dec reg` (`0x48 + reg`), `test reg, reg` (`0x85 /r`)
|
||||
- [ ] 1.12 Implement `inc`, `dec`, `test` to make step 1.11 tests pass
|
||||
- [ ] 1.13 Add tests for `jmp @label`, `je @label`, `jne @label` with forward and backward references
|
||||
- [ ] 1.14 Implement two-pass label resolution in AsmBuilder (pass 1: record label offsets, pass 2: patch jump targets). Make step 1.13 tests pass
|
||||
- [ ] 1.15 Add tests for `call @label` (near call, E8 rel32) and `nop` multi-instruction sequences
|
||||
- [ ] 1.16 Implement `call` to make step 1.15 tests pass
|
||||
- [ ] 1.17 Add tests for error cases: unknown instruction → `ArgumentException` with line number, missing operand → `ArgumentException`, invalid register → `ArgumentException`
|
||||
- [ ] 1.18 Implement error reporting to make step 1.17 tests pass
|
||||
|
||||
## 2. AsmBuilder — Pass Limit
|
||||
|
||||
- [ ] 2.1 Add tests for `SetPassLimit()`: default is 10, custom value is respected, exceeded limit throws `InvalidOperationException`
|
||||
- [ ] 2.2 Implement `SetPassLimit(int limit)` and pass-limit enforcement in AsmBuilder to make step 2.1 tests pass
|
||||
|
||||
## 3. InjectAndExecuteEx — Non-blocking Execution
|
||||
|
||||
- [ ] 3.1 Add tests for `BlackMagic.InjectAndExecuteEx(IntPtr, IntPtr)`: returns `null` when no process open, returns valid handle when process open (mock or integration)
|
||||
- [ ] 3.2 Implement `InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` in `BMThread.cs` to make step 3.1 tests pass
|
||||
- [ ] 3.3 Add test for single-parameter overload: `InjectAndExecuteEx(IntPtr)` passes `IntPtr.Zero`
|
||||
- [ ] 3.4 Implement `InjectAndExecuteEx(IntPtr startAddress)` overload in `BMThread.cs`
|
||||
|
||||
## 4. Convenience Overloads (text → inject → execute)
|
||||
|
||||
- [ ] 4.1 Add tests for `InjectAndExecute(string asm)`: assembles text, allocates remote memory, writes bytes, executes, returns exit code. Test assembly failure → `ArgumentException`
|
||||
- [ ] 4.2 Implement `InjectAndExecute(string asm)` in `BMInject.cs` to make step 4.1 tests pass
|
||||
- [ ] 4.3 Add tests for `InjectAndExecuteEx(string asm)`: assembles text, allocates remote memory, writes bytes, returns thread handle. Test assembly failure → `ArgumentException`
|
||||
- [ ] 4.4 Implement `InjectAndExecuteEx(string asm)` in `BMInject.cs` to make step 4.3 tests pass
|
||||
|
||||
## 5. Build Verification
|
||||
|
||||
- [ ] 5.1 Run full test suite: `"C:\Program Files\dotnet\dotnet.exe" test BlackMagicTest/BlackMagicTest.csproj` — all tests pass
|
||||
- [ ] 5.2 Run full solution build: `"C:\Program Files\dotnet\dotnet.exe" build BlackMagic.slnx` — zero errors
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-21
|
||||
@@ -0,0 +1,126 @@
|
||||
## Context
|
||||
|
||||
This repo contains four C# process-manipulation libraries studied in `docs/memory-library-comparison.md`:
|
||||
|
||||
- **BlackMagic-old** (`reference/Blackmagic-old/`) — .NET FW 4.0, x86, FASM as a public `Asm` property.
|
||||
- **MemorySharp** (`reference/MemorySharp/`) — .NET FW, x86, FASM behind `Execute<T>`; rich high-level API (PEB/TEB, window, input, calling conventions) but unmaintained since 2016.
|
||||
- **GreyMagic** (`reference/GreyMagic/`) — .NET FW, ~2016, x86; dual in/out-of-process `MemoryBase`, `MarshalCache`, `DetourManager`, `PatchManager`, `CreateFunction<T>`, `PeHeaderParser`.
|
||||
- **BlackMagic** (`reference/Blackmagic/`, current) — .NET 8, x86 + x64, FASM removed, pattern scanning + cache, DLL injection (CreateThread + hijack), hand-assembled x86 stubs (`SInject.cs` `EmitU8`/`EmitU32` byte emitter), `CreateRemoteThread`-based `Execute` (`BMThread.cs`). No frame hook exists.
|
||||
|
||||
The consuming use case is an automation client targeting a legacy x86 desktop application. The dominant failure mode observed (`FASM-MIGRATION.md`) is target crashes when state-sensitive functions are invoked on a thread created by `CreateRemoteThread`, because the target's main thread has exclusive affinity for its scripting VM, render device, and object model.
|
||||
|
||||
WhiteMagic is a **new, additive** .NET 8 library that unifies the four. It reuses their ideas, not their assemblies. No project already depends on WhiteMagic, so there is no backward-compatibility constraint.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Single modern (.NET 8, nullable, `Span<byte>`, `SafeHandle`) library that is bitness-agnostic (x86 + x64).
|
||||
- A **three-tier execution model** whose default path for state-sensitive calls is crash-safe (runs on the target's own thread), while `CreateRemoteThread` remains available for thread-agnostic payloads.
|
||||
- Dual memory access: out-of-process (RPM/WPM) and in-process (RPM-on-self-handle, see D1 revision) behind one abstract `MemoryBase`, with `MarshalCache<T>` for allocation-free typed IO.
|
||||
- Reversible function hooking (`DetourManager`) and byte patching (`PatchManager`) with auto-restore on dispose.
|
||||
- Replace FASM with an `IAssembler` seam: hand-emitted convention stubs by default, optional Iced backend for arbitrary assembly. Zero native dependency in the default configuration.
|
||||
- Port DLL injection (CreateThread + thread-hijack, x86/x64) and pattern scanning + cache from current BlackMagic.
|
||||
- Provide MemorySharp-grade ergonomics: `RemotePointer` indexer, `Module["fn"].Execute(...)`, PEB/TEB, window mutation, input simulation, async wrappers, helpers.
|
||||
- Test-first for all pure logic (assembler encodings, marshal cache, pattern matching, stub building, pump queue semantics).
|
||||
|
||||
**Non-Goals:**
|
||||
- Modifying or replacing BlackMagic/MemorySharp/GreyMagic — WhiteMagic is additive.
|
||||
- Shipping a full x86/x64 assembler ourselves — arbitrary assembly is delegated to Iced; only the fixed convention-stub shapes are hand-emitted.
|
||||
- Managed-DLL injection bootstrapper (the CLR host that loads `InProcessReader` into the target). WhiteMagic exposes the in-process API surface; wiring an actual managed loader is a follow-up change.
|
||||
- Application-specific offsets, scripting-engine hooks, or automation logic — those live in the consumer, not the library.
|
||||
- Interference with other software's operation.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Layered architecture with an abstract `MemoryBase` (from GreyMagic)
|
||||
|
||||
`MemoryBase` defines abstract `ReadBytes`/`WriteBytes`/`Read<T>`/`Write<T>`, relative/absolute addressing, and hosts the `PatchManager`. Two concrete readers:
|
||||
- `ExternalReader : MemoryBase` — `ReadProcessMemory`/`WriteProcessMemory` over a `SafeMemoryHandle`. Owns allocation, injection, and the remote-thread + main-thread executors.
|
||||
- `InProcessReader : MemoryBase` — reads the current process via `ReadProcessMemory`/`WriteProcessMemory` on a self-handle; owns the `DetourManager` and `InProcessInvoker`. **(Revised from `unsafe` direct deref during Phase 2: .NET cannot catch `AccessViolationException`, so a bad deref kills the host with no soft-failure path; RPM-on-self fails soft. The in-process speed win moves to the delegate-call/detour paths, not the reader. See `specs/memory-access`.)**
|
||||
|
||||
**Why**: GreyMagic proved this abstraction lets the same higher-level code (pattern scan, patch, high-level API) run in either mode. External is the primary path for an automation host; in-process becomes valuable once injected — not for faster reads (both readers use RPM/WPM, see the D1 revision) but for the delegate-call and detour paths it unlocks (`InProcessInvoker`, `DetourManager`).
|
||||
|
||||
**Alternatives considered**: single external-only class (current BlackMagic) — rejected: forecloses the in-process delegate path, which is the cleanest crash-free execution. MemorySharp's factory-per-concern model (`Assembly`, `Threads`, `Windows` factories) — adopted selectively for the high-level surface, but the read/write core stays on `MemoryBase` for GreyMagic-style polymorphism.
|
||||
|
||||
### D2: Three-tier execution, crash-safe by default (the headline)
|
||||
|
||||
Execution is split by **payload safety**, not by convenience:
|
||||
|
||||
| Tier | Type | Use when | Mechanism |
|
||||
|---|---|---|---|
|
||||
| Remote thread | `RemoteThreadExecutor` | payload is thread-agnostic (LoadLibrary, pure WinAPI, self-contained code payload) | `CreateRemoteThread` + convention stub, wait, exit code |
|
||||
| **Main-thread pump** | `MainThreadPump` | **payload touches target state** (default) | queue delegate → drained on target thread via per-frame hook |
|
||||
| In-process | `InProcessInvoker` | injected in-process | `Marshal.GetDelegateForFunctionPointer`, direct call |
|
||||
|
||||
`MainThreadPump` installs a detour on a caller-supplied per-frame function address (an `EndScene` resolver ships as a convenience helper) via `DetourManager`. Each frame the hook drains a thread-safe queue and runs pending work items synchronously in the target's context, returning results/exceptions to the requesting thread through a completion handle. **This is net-new — no frame hook exists in current BlackMagic to port.** It is built on GreyMagic-style detours (D5) as its one underlying primitive; the pump is the first consumer of `DetourManager`.
|
||||
|
||||
**Why**: `CreateRemoteThread` does not itself crash the target — calling single-thread-affinity target internals from a foreign thread does. Making the pump the default for target-state calls encodes that rule so callers cannot trip the crash by accident, while power users retain raw `CreateRemoteThread` for the payloads it is safe for.
|
||||
|
||||
**Alternatives considered**: (a) always `CreateRemoteThread` (MemorySharp/old-BM) — rejected: the documented crash source. (b) always in-process (GreyMagic) — rejected: requires a managed loader in the target and is not always available; external must work standalone. (c) thread-hijack for every call — rejected: high risk, one-shot, poor for repeated calls; kept only for injection.
|
||||
|
||||
### D3: `IAssembler` seam replacing FASM
|
||||
|
||||
```
|
||||
IAssembler { byte[] Assemble(string asm, ulong origin = 0); }
|
||||
```
|
||||
Two backends:
|
||||
- `StubAssembler` (default) — hand-emits the fixed calling-convention trampolines (cdecl/stdcall/thiscall/fastcall: push args, call, cleanup, ret) and the injection stubs, using an `EmitU8`/`EmitU32`/`EmitU64` byte emitter (the pattern already in `SInject.cs`). No parsing, deterministic, x64-capable, zero dependency.
|
||||
- `IcedAssembler` (optional) — wraps Iced for arbitrary user-supplied mnemonics when a caller genuinely needs runtime text assembly.
|
||||
|
||||
**Why**: The comparison established that the only thing FASM did was runtime text→bytecode, needed solely for stubs and (rarely) arbitrary asm. Stubs are a small fixed set best hand-emitted; arbitrary asm is better served by a modern, managed, x64, MIT-licensed assembler (Iced) than by a native C++/CLI FASM DLL. Retiring FASM removes the x86-only mixed-mode constraint.
|
||||
|
||||
**Alternatives considered**: keep FASM/Fasm.NET — rejected: native dependency, x86-only, unmaintained. Hand-emit everything including arbitrary asm — rejected: reimplementing an assembler is out of scope; Iced already exists.
|
||||
|
||||
### D4: `MarshalCache<T>` for typed IO (from GreyMagic)
|
||||
|
||||
A `static class MarshalCache<T>` computes and caches `Marshal.SizeOf`, `TypeCode`, `TypeRequiresMarshal`, and `IsIntPtr` once per type. `Read<T>`/`Write<T>` branch on the cached flags: blittable types round-trip through `Span`/`MemoryMarshal`; marshal-required types use `Marshal.PtrToStructure`.
|
||||
|
||||
**Why**: GreyMagic's signature perf win — avoids per-call reflection. Current BlackMagic's `where unmanaged` constraint is faster still for blittable types but cannot express marshalled structs; the cache gives both.
|
||||
|
||||
### D5: Reversible hooking and patching with auto-restore (from GreyMagic)
|
||||
|
||||
`DetourManager`/`Detour` (inline `E9` jmp over a prologue, `CallOriginal`, `Apply`/`Remove`) and `PatchManager`/`Patch` (named byte patch, `Apply`/`Remove`/`IsApplied`). Both register into a manager that restores all live modifications on `MemoryBase.Dispose`. Detours are in-process only (they require executing the hook delegate in the target); patches work in both modes.
|
||||
|
||||
**Safety addition**: before splicing a detour, `StubAssembler`/`IcedAssembler` disassembles the target prologue to confirm the overwrite lands on instruction boundaries, avoiding the mid-instruction-splice crash class. When only `StubAssembler` is present, a minimal length-disassembler covers the common prologue shapes; full validation requires the Iced backend.
|
||||
|
||||
**Why**: MemorySharp promised Hook/Patch "coming soon" and never shipped them; GreyMagic shipped both and they are the cleanest available. Auto-restore prevents leaving the target corrupted after a crash of the host.
|
||||
|
||||
### D6: High-level surface as opt-in factories (from MemorySharp)
|
||||
|
||||
`RemotePointer` (indexer `sharp[addr]`), `RemoteModule`/`RemoteFunction` (`sharp["user32"]["MessageBoxA"].Execute(...)`), `ManagedPeb`/`ManagedTeb`, `WindowFactory` (move/resize/title/flash/activate), `Keyboard`/`Mouse` (PostMessage + SendInput), and async wrappers over the executors. These sit above `MemoryBase` and the execution tiers; none is required for core memory work.
|
||||
|
||||
**Why**: This is the ergonomic layer that made MemorySharp pleasant. It is pure P/Invoke over the core — no assembler, no FASM.
|
||||
|
||||
### D7: New projects, additive, test-first
|
||||
|
||||
`WhiteMagic/` (library, `net8.0-windows`, `AllowUnsafeBlocks`) and `WhiteMagicTest/` (xUnit). Task 1.3 creates `WhiteMagic.slnx` (SDK 10's default solution format). The reference libraries live under `reference/` (git-ignored) and are not part of this build. Pure-logic components (assembler encodings, marshal cache, pattern matcher, stub builders, pump queue) get tests before implementation, mirroring the existing `inject-and-assemble` change's TDD discipline. Live-process behavior (RPM/WPM, injection, detours) is validated in integration tests gated on an available target.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Scope is large** → Deliver in vertical slices (see Migration Plan). The minimum crash-safe slice is `MemoryBase` + `ExternalReader` + `DetourManager` + `MainThreadPump`; everything else layers on without reworking it.
|
||||
- **In-process tier needs a managed loader not in scope** → Ship `InProcessReader`'s API and delegate-call path now; mark the actual CLR-host injection as a follow-up. External + pump deliver crash-safety without it.
|
||||
- **Detour prologue splicing can crash if misaligned** → Validate instruction boundaries before writing (D5); require Iced backend for full validation; keep `Remove`/auto-restore so a bad detour is recoverable.
|
||||
- **Iced adds a NuGet dependency** → Isolated behind `IAssembler`; default `StubAssembler` keeps the library dependency-free. Only callers needing arbitrary asm opt in.
|
||||
- **`MainThreadPump` adds per-frame overhead and a hook on a hot function** → Keep the drained-per-frame work bounded; the hook is a thin queue check when idle. Document that a wedged work item stalls the frame.
|
||||
- **x64 detours need larger/absolute jumps (14-byte `push/ret` or RIP-relative)** → `StubAssembler` emits the correct form per bitness; covered by encoding tests.
|
||||
- **Blittable vs marshalled ambiguity** in `MarshalCache` → Explicit flags and tests per type category; document that `[StructLayout]` is required for non-blittable remote structs.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
WhiteMagic is additive; there is nothing to migrate off. Delivery is phased so each slice is independently useful and testable:
|
||||
|
||||
1. **Core** — `MemoryBase`, `ExternalReader`, `SafeMemoryHandle`, `MarshalCache`, typed/string/bytes IO. (Replaces nothing; standalone.)
|
||||
2. **Crash-safe execution slice** — `StubAssembler`, `DetourManager`, `MainThreadPump`, `RemoteThreadExecutor`. Proves state-sensitive calls without crashes end-to-end. This is the headline deliverable.
|
||||
3. **Injection & discovery** — DLL injection (CreateThread + hijack, x86/x64), pattern scanning + cache, `PeHeaderParser`, named `AllocatedMemory`.
|
||||
4. **In-process tier** — `InProcessReader`, `InProcessInvoker`, `CreateFunction<T>`, vtable helpers (API surface; managed loader deferred).
|
||||
5. **High-level ergonomics** — `RemotePointer`, `RemoteModule`/`RemoteFunction`, PEB/TEB, window, input, async, `PatchManager` polish, helpers.
|
||||
6. **Optional Iced backend** — `IcedAssembler`, arbitrary-asm inject, full prologue validation.
|
||||
|
||||
**Rollback**: WhiteMagic is a separate assembly; removing its project reference reverts consumers with no effect on existing libraries.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Frame-hook target**: ~~default to D3D9 `EndScene`, or accept a caller-supplied per-frame function address?~~ **Resolved**: `MainThreadPump` takes a caller-supplied frame-function address (application-agnostic, library stays offset-free per Non-Goals); an `EndScene` resolver ships as a convenience helper only. Slice 2 depends on this — settled before slice 2 starts.
|
||||
- **Managed in-process loader**: which host mechanism (custom CLR host vs. a native shim that calls `CorBindToRuntimeEx`/`ICLRRuntimeHost`)? Deferred to a follow-up change but affects the `InProcessReader` seam shape.
|
||||
- **Iced as default vs optional**: keep hand-stubs default (zero dep) — confirmed — but should the library ship a `WhiteMagic.Iced` companion package rather than an optional reference? Package boundary TBD.
|
||||
- **Async model**: `Task`-based wrappers (MemorySharp) vs. exposing the pump's completion handles directly. Likely both: pump returns a handle, async wrappers adapt it to `Task<T>`.
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
Four process-manipulation libraries in this repo each solve part of the problem but none is complete: **BlackMagic-old** and **MemorySharp** depend on the native FASM assembler; **MemorySharp** has rich high-level ergonomics but is 32-bit-only and unmaintained; **GreyMagic** has the best engine (in-process reads, detours, patches, marshal cache) but is 32-bit and external-FASM-bound; **current BlackMagic** is the only modern, x64, FASM-free base but lacks remote function-calling, hooking, and high-level ergonomics. The recurring failure mode is target crashes from calling target internals on a foreign thread created by `CreateRemoteThread`. WhiteMagic unifies the best of all four into one modern (.NET 8, x64) library whose default path for state-sensitive calls is crash-safe (runs on the target's own thread) while `CreateRemoteThread` stays available for thread-agnostic payloads.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Introduce a new library, **WhiteMagic**, as a fresh .NET 8 project (`WhiteMagic/`) with an xUnit test project (`WhiteMagicTest/`). Additive — existing BlackMagic is untouched.
|
||||
- **Dual memory-access model**: an abstract `MemoryBase` with an `ExternalReader` (ReadProcessMemory/WriteProcessMemory) and an `InProcessReader` (direct pointer deref for injected scenarios), fronted by a `MarshalCache<T>` for allocation-free typed reads/writes.
|
||||
- **Three-tier remote execution** — the headline capability:
|
||||
- `RemoteThreadExecutor`: `CreateRemoteThread`-based `Execute<T>(addr, convention, args…)`, documented as safe for **thread-agnostic payloads only**.
|
||||
- `MainThreadPump`: a crash-safe work queue drained on the target's own thread via a detour on a caller-supplied per-frame function (with an `EndScene` resolver helper) — the default for state-sensitive calls. Net-new; no frame hook exists in current BlackMagic to port.
|
||||
- `InProcessInvoker`: direct native-delegate calls (`CreateFunction<T>`) when injected in-process.
|
||||
- **Function hooking**: reversible `DetourManager` (inline jmp, `CallOriginal`) and `PatchManager` (named byte patches) with auto-restore on dispose.
|
||||
- **Managed assembler seam**: an `IAssembler` abstraction with two backends — hand-emitted calling-convention stubs (default) and an optional [Iced](https://github.com/icedland/iced) backend for arbitrary x86/x64 assembly. **No FASM, no native DLL.**
|
||||
- **DLL injection**: `CreateRemoteThread` + thread-hijack strategies, x86 and x64 stubs (ported from current BlackMagic).
|
||||
- **Discovery & allocation**: pattern scanning with cache, PE-header parsing, and named-chunk `AllocatedMemory`.
|
||||
- **High-level ergonomics**: `RemotePointer` indexer, `Module["fn"].Execute(...)`, `ManagedPeb`/`ManagedTeb`, window mutation, keyboard/mouse simulation, async execution wrappers, and helper utilities.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `memory-access`: Dual external/in-process readers over an abstract `MemoryBase`, with `MarshalCache`-backed typed, array, and string read/write and relative/absolute addressing.
|
||||
- `memory-discovery`: Pattern/signature scanning (with cache), PE-header parsing, and named-chunk remote allocation.
|
||||
- `managed-assembler`: `IAssembler` seam producing machine code with no native dependency — hand-emitted convention stubs plus an optional Iced backend for arbitrary assembly.
|
||||
- `remote-execution`: Three-tier execution (remote-thread, crash-safe main-thread pump, in-process delegate) with calling-convention-aware `Execute<T>` and parameter marshalling.
|
||||
- `function-hooking`: Reversible inline detours (`CallOriginal`) and named byte patches with lifecycle management and auto-restore.
|
||||
- `dll-injection`: DLL injection via `CreateRemoteThread` and thread-hijack redirection for x86 and x64 targets.
|
||||
- `high-level-api`: Ergonomic surface — `RemotePointer` indexer, module/function access, PEB/TEB, window and input simulation, async wrappers, and helpers.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None — WhiteMagic is a new library; no existing WhiteMagic specs exist in openspec/specs/. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **New code**: `WhiteMagic/` library, `WhiteMagicTest/` xUnit project, both added to the solution.
|
||||
- **New dependency (optional)**: `Iced` NuGet package, isolated behind `IAssembler`; the default hand-stub backend has zero third-party dependencies.
|
||||
- **No native dependency**: FASM (`reference/fasm/`, `ManagedFasm`) is not referenced. It remains historical reference only, consistent with `FASM-MIGRATION.md`.
|
||||
- **No changes** to BlackMagic, MemorySharp, GreyMagic, or their tests — WhiteMagic reuses their ideas, not their assemblies.
|
||||
- **Platform**: builds x86 and x64; target bitness stays x86 to match the reference application, but the library is bitness-agnostic.
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: DLL injection via remote thread
|
||||
|
||||
WhiteMagic SHALL inject a DLL into an open target process by creating a remote thread on `LoadLibrary`, returning the base address of the injected module on success and reporting failure without throwing for expected failure conditions.
|
||||
|
||||
#### Scenario: successful injection
|
||||
- **WHEN** a valid DLL path is injected into an open process of matching bitness
|
||||
- **THEN** the returned base address MUST be non-zero and the module MUST be loaded in the target
|
||||
|
||||
#### Scenario: bitness mismatch rejected
|
||||
- **WHEN** the target process bitness differs from the caller
|
||||
- **THEN** injection MUST fail with a clear error rather than corrupt the target
|
||||
|
||||
#### Scenario: missing file
|
||||
- **WHEN** the DLL path does not exist
|
||||
- **THEN** injection MUST report an argument error
|
||||
|
||||
### Requirement: DLL injection via thread hijack
|
||||
|
||||
WhiteMagic SHALL inject a DLL by hijacking an existing thread — saving its context, redirecting execution through a `LoadLibrary` stub, and restoring the original context — returning the injected module base address.
|
||||
|
||||
#### Scenario: hijack loads the module
|
||||
- **WHEN** a valid DLL is injected by hijacking a running thread
|
||||
- **THEN** the module MUST be loaded and the hijacked thread's original context MUST be restored
|
||||
|
||||
#### Scenario: exit code reports load result
|
||||
- **WHEN** the redirect stub completes
|
||||
- **THEN** the stub MUST record `LoadLibrary`'s result so the caller can detect load success or failure
|
||||
|
||||
### Requirement: x86 and x64 stubs
|
||||
|
||||
Injection stubs SHALL be emitted correctly for both x86 and x64 targets, including proper x64 addressing.
|
||||
|
||||
#### Scenario: x86 stub
|
||||
- **WHEN** injecting into a 32-bit target
|
||||
- **THEN** a 32-bit redirect stub MUST be emitted
|
||||
|
||||
#### Scenario: x64 stub
|
||||
- **WHEN** injecting into a 64-bit target
|
||||
- **THEN** a 64-bit redirect stub with correct absolute/RIP-relative addressing MUST be emitted
|
||||
|
||||
### Requirement: Raw code injection
|
||||
|
||||
WhiteMagic SHALL inject raw machine-code bytes into an open process, either at a caller-supplied address or into freshly allocated remote memory whose address is returned.
|
||||
|
||||
#### Scenario: inject at address
|
||||
- **WHEN** raw bytes are injected at a given address
|
||||
- **THEN** memory at that address MUST equal the injected bytes
|
||||
|
||||
#### Scenario: inject into fresh allocation
|
||||
- **WHEN** raw bytes are injected without an address
|
||||
- **THEN** remote memory MUST be allocated, the bytes written, and the allocation address returned
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Reversible inline detours
|
||||
|
||||
WhiteMagic SHALL provide a `DetourManager` that creates named inline detours redirecting a target function to a managed hook, supporting `Apply`, `Remove`, and calling the original function. Detours operate in-process.
|
||||
|
||||
#### Scenario: apply redirects the target
|
||||
- **WHEN** a detour from a target function to a hook delegate is applied
|
||||
- **THEN** calling the target MUST invoke the hook delegate
|
||||
|
||||
#### Scenario: call original
|
||||
- **WHEN** the hook invokes `CallOriginal(args)`
|
||||
- **THEN** the original target behavior MUST execute with those arguments and its result returned
|
||||
|
||||
#### Scenario: remove restores original bytes
|
||||
- **WHEN** an applied detour is removed
|
||||
- **THEN** the target's original prologue bytes MUST be restored and calling the target MUST no longer invoke the hook
|
||||
|
||||
#### Scenario: named lookup
|
||||
- **WHEN** a detour is created with a name
|
||||
- **THEN** it MUST be retrievable from the manager by that name
|
||||
|
||||
### Requirement: Instruction-boundary validation before splicing
|
||||
|
||||
Before overwriting a target prologue, the detour SHALL verify the overwrite covers whole instructions so that no instruction is split.
|
||||
|
||||
#### Scenario: aligned splice permitted
|
||||
- **WHEN** the bytes required for the jump cover a whole number of prologue instructions
|
||||
- **THEN** the detour MUST apply
|
||||
|
||||
#### Scenario: misaligned splice rejected
|
||||
- **WHEN** the required overwrite would end in the middle of an instruction and boundary information is available
|
||||
- **THEN** the detour MUST refuse to apply rather than corrupt the target
|
||||
|
||||
### Requirement: Named reversible byte patches
|
||||
|
||||
WhiteMagic SHALL provide a `PatchManager` that creates named byte patches with `Apply`, `Remove`, and `IsApplied`, usable in both external and in-process modes.
|
||||
|
||||
#### Scenario: apply writes patch bytes
|
||||
- **WHEN** a patch is applied at an address
|
||||
- **THEN** memory at that address MUST equal the patch bytes
|
||||
|
||||
#### Scenario: remove restores original
|
||||
- **WHEN** an applied patch is removed
|
||||
- **THEN** memory at that address MUST equal the original bytes captured at creation
|
||||
|
||||
#### Scenario: is-applied reflects state
|
||||
- **WHEN** `IsApplied` is queried
|
||||
- **THEN** it MUST return true only when the current bytes equal the patch bytes
|
||||
|
||||
### Requirement: Auto-restore on dispose
|
||||
|
||||
All live detours and patches SHALL be reverted when their owning `MemoryBase` is disposed.
|
||||
|
||||
#### Scenario: dispose reverts modifications
|
||||
- **WHEN** a `MemoryBase` with active detours and patches is disposed
|
||||
- **THEN** every modified region MUST be restored to its pre-modification bytes
|
||||
@@ -0,0 +1,69 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Remote pointer indexer
|
||||
|
||||
WhiteMagic SHALL expose a `RemotePointer` obtained by indexing the memory facade with an address, offering read/write/execute operations relative to that base address.
|
||||
|
||||
#### Scenario: read via indexer
|
||||
- **WHEN** `sharp[addr].Read<int>(offset)` is called
|
||||
- **THEN** it MUST read an int at `addr + offset`
|
||||
|
||||
#### Scenario: write via indexer
|
||||
- **WHEN** `sharp[addr].WriteString("text")` is called
|
||||
- **THEN** the string MUST be written starting at `addr`
|
||||
|
||||
### Requirement: Module and function access
|
||||
|
||||
WhiteMagic SHALL expose modules and their exported functions by name, allowing a resolved function to be executed with a calling convention and arguments.
|
||||
|
||||
#### Scenario: resolve function by name
|
||||
- **WHEN** `sharp["user32"]["MessageBoxA"]` is resolved
|
||||
- **THEN** it MUST return a function bound to the export address of `MessageBoxA` in `user32`
|
||||
|
||||
#### Scenario: execute resolved function
|
||||
- **WHEN** a resolved function is executed with a calling convention and arguments
|
||||
- **THEN** it MUST invoke the target through the chosen execution strategy with those arguments
|
||||
|
||||
### Requirement: PEB and TEB access
|
||||
|
||||
WhiteMagic SHALL expose managed reads of the target's Process Environment Block and a thread's Thread Environment Block.
|
||||
|
||||
#### Scenario: read PEB field
|
||||
- **WHEN** a PEB field (e.g. being-debugged flag) is read
|
||||
- **THEN** it MUST reflect the target's actual PEB value
|
||||
|
||||
#### Scenario: read TEB field
|
||||
- **WHEN** a TEB field is read for a given thread
|
||||
- **THEN** it MUST reflect that thread's actual TEB value
|
||||
|
||||
### Requirement: Window mutation
|
||||
|
||||
WhiteMagic SHALL enumerate and mutate target windows — position, size, title, activation, and flashing.
|
||||
|
||||
#### Scenario: move and resize
|
||||
- **WHEN** a window's X, Y, width, and height are set
|
||||
- **THEN** the window MUST move and resize to those values
|
||||
|
||||
#### Scenario: query by class name
|
||||
- **WHEN** windows are queried by class name
|
||||
- **THEN** matching windows MUST be returned
|
||||
|
||||
### Requirement: Keyboard and mouse simulation
|
||||
|
||||
WhiteMagic SHALL simulate keyboard and mouse input to a target window, including input delivered without activating the window where the mechanism allows.
|
||||
|
||||
#### Scenario: write text to a window
|
||||
- **WHEN** text is written to a target window's keyboard interface
|
||||
- **THEN** the window MUST receive the corresponding key input
|
||||
|
||||
#### Scenario: mouse click
|
||||
- **WHEN** a click at a coordinate is issued to a window's mouse interface
|
||||
- **THEN** the window MUST receive the corresponding mouse input
|
||||
|
||||
### Requirement: Asynchronous execution wrappers
|
||||
|
||||
WhiteMagic SHALL provide `Task`-based asynchronous wrappers over its execution strategies.
|
||||
|
||||
#### Scenario: async execute returns a task
|
||||
- **WHEN** an async execute is invoked
|
||||
- **THEN** it MUST return a `Task<T>` that completes with the execution result
|
||||
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IAssembler abstraction with no native dependency
|
||||
|
||||
WhiteMagic SHALL define an `IAssembler` seam that produces machine code, with a default backend that has no native or third-party dependency. FASM MUST NOT be referenced by the default configuration.
|
||||
|
||||
#### Scenario: default backend is dependency-free
|
||||
- **WHEN** WhiteMagic is built in its default configuration
|
||||
- **THEN** no reference to FASM or `ManagedFasm` MUST be present in the output
|
||||
|
||||
#### Scenario: backend is replaceable
|
||||
- **WHEN** an alternate `IAssembler` implementation is supplied
|
||||
- **THEN** execution and injection MUST use it without other code changes
|
||||
|
||||
### Requirement: Hand-emitted calling-convention stubs
|
||||
|
||||
The default `StubAssembler` SHALL emit call trampolines for the cdecl, stdcall, thiscall, and fastcall conventions — pushing/placing arguments, calling the target, cleaning the stack per convention, and returning — for both x86 and x64 targets.
|
||||
|
||||
#### Scenario: cdecl stub encoding
|
||||
- **WHEN** a cdecl call stub for a function with N 4-byte arguments is emitted (x86)
|
||||
- **THEN** the bytes MUST push the arguments in reverse order, `call` the target, `add esp, N*4`, and `ret`
|
||||
|
||||
#### Scenario: stdcall omits caller cleanup
|
||||
- **WHEN** a stdcall stub is emitted
|
||||
- **THEN** it MUST NOT emit a caller-side stack cleanup (the callee cleans)
|
||||
|
||||
#### Scenario: x64 uses register argument order
|
||||
- **WHEN** an x64 call stub is emitted
|
||||
- **THEN** the first integer arguments MUST be placed in the platform argument registers before the call
|
||||
|
||||
### Requirement: Byte emitter primitives
|
||||
|
||||
`StubAssembler` SHALL provide little-endian emit primitives (`EmitU8`, `EmitU32`, `EmitU64`) used to hand-assemble stubs deterministically.
|
||||
|
||||
#### Scenario: little-endian 32-bit emit
|
||||
- **WHEN** `EmitU32(0x11223344)` is called
|
||||
- **THEN** the appended bytes MUST be `[0x44, 0x33, 0x22, 0x11]`
|
||||
|
||||
### Requirement: Optional Iced backend for arbitrary assembly
|
||||
|
||||
WhiteMagic SHALL provide an optional `IcedAssembler` backend that assembles arbitrary x86/x64 mnemonic text to machine code for callers who require runtime text assembly.
|
||||
|
||||
#### Scenario: arbitrary mnemonics assembled
|
||||
- **WHEN** the Iced backend assembles `"push 0\nadd esp, 4\nret"` at a given origin
|
||||
- **THEN** it MUST return the corresponding machine code bytes
|
||||
|
||||
#### Scenario: origin-relative encoding
|
||||
- **WHEN** assembly containing a relative jump is assembled at a specified origin address
|
||||
- **THEN** the encoded relative offsets MUST be correct for that origin
|
||||
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Abstract memory base with two readers
|
||||
|
||||
WhiteMagic SHALL expose an abstract `MemoryBase` type defining `ReadBytes`, `WriteBytes`, generic `Read<T>`/`Write<T>`, array read/write, and string read/write, with two concrete implementations: `ExternalReader` (out-of-process via ReadProcessMemory/WriteProcessMemory) and `InProcessReader` (in-process, reading the current process through ReadProcessMemory/WriteProcessMemory on a self-handle).
|
||||
|
||||
> **Deviation from design D1.** D1 originally specified `InProcessReader` as `unsafe` direct pointer dereference (the "fast/crash-free" path). Implementation revised it to `ReadProcessMemory`/`WriteProcessMemory` on a handle to the current process, because .NET (Core) cannot catch `AccessViolationException` (`HandleProcessCorruptedStateExceptions` is removed), so a raw deref of a bad address terminates the host process with no soft-failure path. RPM on a self-handle fails soft (returns empty) like `ExternalReader`. The in-process performance win therefore moves to the delegate-call and detour paths (`InProcessInvoker`, `DetourManager`), not the reader.
|
||||
|
||||
#### Scenario: in-process read fails soft on an invalid address
|
||||
- **WHEN** an `InProcessReader` reads an unmapped or protected address
|
||||
- **THEN** it MUST return empty/`default` rather than crash the host process
|
||||
|
||||
#### Scenario: external read round-trip
|
||||
- **WHEN** an `ExternalReader` opens a target process and writes a value with `Write<int>(addr, 0x1234)` then reads it back with `Read<int>(addr)`
|
||||
- **THEN** the returned value MUST equal `0x1234`
|
||||
|
||||
#### Scenario: in-process read of own memory
|
||||
- **WHEN** an `InProcessReader` reads a known address in its own process
|
||||
- **THEN** the value MUST match a direct managed read of the same address
|
||||
|
||||
#### Scenario: shared API surface
|
||||
- **WHEN** code is written against the `MemoryBase` abstract type
|
||||
- **THEN** it MUST operate unchanged against both `ExternalReader` and `InProcessReader`
|
||||
|
||||
### Requirement: Typed read/write via marshal cache
|
||||
|
||||
`MemoryBase` SHALL support generic `Read<T>`/`Write<T>` for blittable and marshalled struct types, using a per-type `MarshalCache<T>` that caches size, type code, and marshalling requirements to avoid per-call reflection.
|
||||
|
||||
#### Scenario: blittable struct round-trip
|
||||
- **WHEN** a blittable `[StructLayout(LayoutKind.Sequential)]` struct is written and read back
|
||||
- **THEN** all fields MUST be preserved exactly
|
||||
|
||||
#### Scenario: marshal cache computed once
|
||||
- **WHEN** `Read<T>` is invoked repeatedly for the same type `T`
|
||||
- **THEN** `Marshal.SizeOf` and type inspection for `T` MUST be computed at most once and reused
|
||||
|
||||
#### Scenario: array read
|
||||
- **WHEN** `Read<T>(addr, count)` is called
|
||||
- **THEN** it MUST return an array of exactly `count` elements read contiguously from `addr`
|
||||
|
||||
### Requirement: String read and write with encoding
|
||||
|
||||
`MemoryBase` SHALL read and write strings with a caller-specified `Encoding` and a maximum length, terminating reads at a null terminator or the maximum length.
|
||||
|
||||
#### Scenario: ASCII write then read
|
||||
- **WHEN** `WriteString(addr, "hello", Encoding.ASCII)` is called then `ReadString(addr, Encoding.ASCII)`
|
||||
- **THEN** the result MUST equal `"hello"`
|
||||
|
||||
#### Scenario: read stops at null terminator
|
||||
- **WHEN** a null-terminated string shorter than `maxLength` is read
|
||||
- **THEN** the returned string MUST exclude the terminator and everything after it
|
||||
|
||||
### Requirement: Relative and absolute addressing
|
||||
|
||||
`MemoryBase` SHALL convert between addresses relative to the module image base and absolute addresses via `GetAbsolute` and `GetRelative`, and accept an `isRelative` flag on read/write operations.
|
||||
|
||||
#### Scenario: relative resolves against image base
|
||||
- **WHEN** `GetAbsolute(relative)` is called with the process image base known
|
||||
- **THEN** the result MUST equal `imageBase + relative`
|
||||
|
||||
#### Scenario: read with isRelative
|
||||
- **WHEN** `Read<int>(offset, isRelative: true)` is called
|
||||
- **THEN** the read MUST occur at `GetAbsolute(offset)`
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pattern scanning with mask
|
||||
|
||||
WhiteMagic SHALL scan process memory for a byte signature with a wildcard mask, returning the address of the first match or `IntPtr.Zero` when no match is found. Scans SHALL be available over an explicit range, a single module, and all modules.
|
||||
|
||||
#### Scenario: pattern found
|
||||
- **WHEN** a known byte sequence is scanned for with a matching mask over a range containing it
|
||||
- **THEN** the returned address MUST point at the first occurrence
|
||||
|
||||
#### Scenario: wildcard mask
|
||||
- **WHEN** the mask marks positions as wildcards (e.g. `"xx?x"`)
|
||||
- **THEN** those byte positions MUST be ignored during matching
|
||||
|
||||
#### Scenario: pattern not found
|
||||
- **WHEN** a pattern absent from the range is scanned for
|
||||
- **THEN** the result MUST be `IntPtr.Zero`
|
||||
|
||||
### Requirement: Pattern scan cache
|
||||
|
||||
The scanner SHALL cache resolved pattern results keyed by pattern and mask, returning the cached address on repeat lookups, and SHALL expose an operation to clear the cache.
|
||||
|
||||
#### Scenario: repeat lookup served from cache
|
||||
- **WHEN** the same pattern and mask are scanned twice without clearing the cache
|
||||
- **THEN** the second lookup MUST return the same address without rescanning memory
|
||||
|
||||
#### Scenario: cache cleared
|
||||
- **WHEN** the cache is cleared
|
||||
- **THEN** the next lookup MUST rescan memory
|
||||
|
||||
### Requirement: PE header parsing
|
||||
|
||||
WhiteMagic SHALL parse the PE headers of a module to expose its sections and entry point without executing the module.
|
||||
|
||||
#### Scenario: sections enumerated
|
||||
- **WHEN** a valid PE module is parsed
|
||||
- **THEN** its section names, virtual addresses, and sizes MUST be enumerable
|
||||
|
||||
#### Scenario: entry point located
|
||||
- **WHEN** a valid PE module is parsed
|
||||
- **THEN** the parsed entry-point RVA MUST match the module's header
|
||||
|
||||
### Requirement: Named remote allocation
|
||||
|
||||
WhiteMagic SHALL allocate a chunk of remote memory subdivided into named regions, allowing typed read/write and address lookup by name, and freeing the whole chunk on dispose.
|
||||
|
||||
#### Scenario: write and read by name
|
||||
- **WHEN** a named region is allocated and `Write<int>("count", 5)` then `Read<int>("count")` is called
|
||||
- **THEN** the result MUST equal `5`
|
||||
|
||||
#### Scenario: address by name
|
||||
- **WHEN** a region named `"buffer"` is allocated
|
||||
- **THEN** requesting its address MUST return `chunkBase + regionOffset`
|
||||
|
||||
#### Scenario: freed on dispose
|
||||
- **WHEN** the allocation is disposed
|
||||
- **THEN** the underlying remote memory MUST be released
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Three-tier execution model
|
||||
|
||||
WhiteMagic SHALL provide three execution strategies selected by payload safety: `RemoteThreadExecutor` (via `CreateRemoteThread`), `MainThreadPump` (work marshalled onto the target's own thread), and `InProcessInvoker` (direct native-delegate calls when injected in-process).
|
||||
|
||||
#### Scenario: strategies are distinct and selectable
|
||||
- **WHEN** a caller chooses an execution strategy
|
||||
- **THEN** each of remote-thread, main-thread-pump, and in-process MUST be individually invokable
|
||||
|
||||
#### Scenario: main-thread pump is the documented default for state-sensitive calls
|
||||
- **WHEN** documentation or API guidance describes calling functions that touch single-thread-affinity process state
|
||||
- **THEN** it MUST direct callers to the main-thread pump, not `CreateRemoteThread`
|
||||
|
||||
### Requirement: Remote-thread execution for thread-agnostic payloads
|
||||
|
||||
`RemoteThreadExecutor` SHALL create a remote thread at a target address using a calling-convention-aware stub, wait for completion, and return the typed exit value. Its documentation MUST state that it is safe only for thread-agnostic payloads.
|
||||
|
||||
#### Scenario: execute with parameters and convention
|
||||
- **WHEN** `Execute<int>(addr, CallConvention.Cdecl, arg1, arg2)` is called on a safe self-contained function
|
||||
- **THEN** the target MUST be called with the arguments laid out per cdecl and the typed return value returned
|
||||
|
||||
#### Scenario: parameters marshalled and freed
|
||||
- **WHEN** a `string` or struct parameter is passed to `Execute`
|
||||
- **THEN** it MUST be allocated in the remote process, passed by pointer, and freed after the call completes
|
||||
|
||||
#### Scenario: no process open
|
||||
- **WHEN** `Execute` is called with no process open
|
||||
- **THEN** it MUST fail deterministically rather than crash
|
||||
|
||||
### Requirement: Crash-safe main-thread pump
|
||||
|
||||
`MainThreadPump` SHALL install a hook on a per-frame function in the target and, each time that function runs, drain a thread-safe queue of work items, executing each on the target's own thread and returning its result or exception to the requesting caller.
|
||||
|
||||
#### Scenario: work runs on the hooked thread
|
||||
- **WHEN** a work item is queued and the hooked per-frame function next executes
|
||||
- **THEN** the work item MUST run in the context of the thread that calls the per-frame function
|
||||
|
||||
#### Scenario: result returned to caller
|
||||
- **WHEN** a caller queues a function returning a value and awaits its completion
|
||||
- **THEN** the caller MUST receive the returned value
|
||||
|
||||
#### Scenario: exception propagated, pump survives
|
||||
- **WHEN** a queued work item throws
|
||||
- **THEN** the exception MUST be surfaced to the requesting caller AND subsequent queued items MUST still be processed
|
||||
|
||||
#### Scenario: uninstall restores the frame function
|
||||
- **WHEN** the pump is disposed
|
||||
- **THEN** the hooked per-frame function MUST be restored to its original bytes
|
||||
|
||||
### Requirement: In-process delegate invocation
|
||||
|
||||
`InProcessInvoker` SHALL convert a function address to a typed managed delegate and call it directly, without creating a thread or crossing a thread boundary.
|
||||
|
||||
#### Scenario: call as delegate
|
||||
- **WHEN** `CreateFunction<TDelegate>(addr)` is called in-process and the returned delegate is invoked
|
||||
- **THEN** the native function at `addr` MUST be called directly on the current thread with the delegate's marshalled arguments
|
||||
@@ -0,0 +1,87 @@
|
||||
## 1. Project Setup
|
||||
|
||||
- [x] 1.1 Create `WhiteMagic/WhiteMagic.csproj` targeting `net8.0-windows`, `AllowUnsafeBlocks=true`, nullable enabled, `TreatWarningsAsErrors`, `Platforms=x86;x64;AnyCPU`
|
||||
- [x] 1.2 Create `WhiteMagicTest/WhiteMagicTest.csproj` (xUnit, `net8.0-windows`) referencing `WhiteMagic`
|
||||
- [x] 1.3 Create `WhiteMagic.slnx` (SDK 10 default solution format) and add both projects. (Built on SDK 10; `net8.0-windows` targeting pack auto-restored.)
|
||||
- [x] 1.4 Add `WhiteMagic/Native/` P/Invoke surface (`LibraryImport`): OpenProcess, Read/WriteProcessMemory, VirtualAllocEx/FreeEx/ProtectEx, CreateRemoteThread, Wow64Get/SetThreadContext, Get/SetThreadContext, LoadLibrary, GetProcAddress; add `SafeMemoryHandle`
|
||||
- [x] 1.5 Verify empty projects build: `dotnet build WhiteMagic.slnx` — zero errors, zero warnings
|
||||
|
||||
## 2. Core Memory Access (spec: memory-access)
|
||||
|
||||
- [x] 2.1 Add tests for `MarshalCache<T>`: blittable size, marshal-required flag, IsIntPtr, computed-once behavior
|
||||
- [x] 2.2 Implement `WhiteMagic/MarshalCache.cs` to pass 2.1
|
||||
- [x] 2.3 Add tests for `MemoryBase` abstract contract + `ExternalReader` round-trip (`Read<T>`/`Write<T>`, arrays) using the current process as target
|
||||
- [x] 2.4 Implement `WhiteMagic/MemoryBase.cs` (abstract) and `WhiteMagic/ExternalReader.cs` to pass 2.3
|
||||
- [x] 2.5 Add tests for string read/write with encoding, null-terminator stop, and max length
|
||||
- [x] 2.6 Implement `ReadString`/`WriteString` on `MemoryBase` to pass 2.5
|
||||
- [x] 2.7 Add tests for relative/absolute addressing (`GetAbsolute`/`GetRelative`, `isRelative` flag)
|
||||
- [x] 2.8 Implement addressing helpers to pass 2.7
|
||||
- [x] 2.9 Add tests + implementation for `InProcessReader` (RPM/WPM on a self-handle — see D1 deviation note; direct deref rejected because .NET cannot catch `AccessViolationException`); verify shared `MemoryBase` API works for both readers
|
||||
- [ ] 2.10 Follow-up (found in review): `ReadString` scans for the null terminator byte-by-byte, so for UTF-16/UTF-32 it can match a **misaligned** multi-byte null across a char boundary (e.g. `"A"`+U+4200 = `41 00 00 42` matches `{00,00}` at offset 1) and can miss a terminator split across the 64-byte chunk boundary. Harmless for ASCII/UTF-8 (single-byte encodings). Fix: align the scan to the encoding's code-unit width and carry the last `(nullLen-1)` bytes across chunks. Add a UTF-16 test.
|
||||
|
||||
## 3. Managed Assembler (spec: managed-assembler)
|
||||
|
||||
- [ ] 3.1 Add tests for `EmitU8`/`EmitU32`/`EmitU64` little-endian primitives
|
||||
- [ ] 3.2 Implement `WhiteMagic/Assembly/StubAssembler.cs` emitters + `IAssembler` interface to pass 3.1
|
||||
- [ ] 3.3 Add tests for x86 cdecl stub encoding (reverse push, call, `add esp, N*4`, ret) with known byte expectations
|
||||
- [ ] 3.4 Implement x86 cdecl stub to pass 3.3
|
||||
- [ ] 3.5 Add tests for stdcall (no caller cleanup), thiscall (ecx = this), fastcall (ecx/edx) x86 stubs
|
||||
- [ ] 3.6 Implement x86 stdcall/thiscall/fastcall stubs to pass 3.5
|
||||
- [ ] 3.7 Add tests for x64 stub argument-register placement and call
|
||||
- [ ] 3.8 Implement x64 stub to pass 3.7
|
||||
- [ ] 3.9 Confirm no FASM/`ManagedFasm` reference exists in `WhiteMagic` output (assert via a test that scans loaded references)
|
||||
|
||||
## 4. Crash-Safe Execution Slice (spec: remote-execution, function-hooking)
|
||||
|
||||
- [ ] 4.1 Add tests for `PatchManager`/`Patch`: apply writes bytes, remove restores original, `IsApplied` reflects state
|
||||
- [ ] 4.2 Implement `WhiteMagic/Hooking/PatchManager.cs` + `Patch.cs` to pass 4.1
|
||||
- [ ] 4.3 Add tests for `DetourManager`/`Detour` in-process: apply redirects, `CallOriginal`, remove restores, named lookup
|
||||
- [ ] 4.4 Implement `WhiteMagic/Hooking/DetourManager.cs` + `Detour.cs` (inline jmp, x86/x64 form) to pass 4.3
|
||||
- [ ] 4.5 Add tests for instruction-boundary validation (aligned splice permitted, misaligned rejected when boundary info available)
|
||||
- [ ] 4.6 Implement minimal prologue length-decoder in `Detour.Apply` to pass 4.5. Default `StubAssembler` covers ONLY the common x86/x64 prologue shapes — enumerate the covered opcodes in code + XML doc (e.g. `push reg` 0x50-0x57, `mov edi,edi` 8B FF, `push ebp`/`mov ebp,esp` 55 8B EC, `sub esp,imm` 83 EC / 81 EC, REX-prefixed forms). On any opcode outside the set, refuse the splice (do not guess). Full arbitrary-prologue validation is gated on the optional Iced backend (task 8.3) — document that slices 2-5 ship partial boundary safety.
|
||||
- [ ] 4.7 Add tests for auto-restore: disposing a `MemoryBase` reverts all active patches and detours
|
||||
- [ ] 4.8 Wire manager registration + `MemoryBase.Dispose` restore to pass 4.7
|
||||
- [ ] 4.9 Add tests for `MainThreadPump` queue semantics: item runs on hooked thread, result returned, throwing item surfaces exception and pump survives, dispose uninstalls hook (use a self-hosted frame-loop harness in-process)
|
||||
- [ ] 4.10 Implement `WhiteMagic/Execution/MainThreadPump.cs` (frame-function detour + thread-safe work queue + completion handles) to pass 4.9
|
||||
- [ ] 4.11 Add tests for `RemoteThreadExecutor.Execute<T>` (convention stub + wait + typed exit; no-process failure is deterministic)
|
||||
- [ ] 4.12 Implement `WhiteMagic/Execution/RemoteThreadExecutor.cs` and parameter marshalling (string/struct → remote alloc → free) to pass 4.11
|
||||
|
||||
## 5. Injection & Discovery (spec: dll-injection, memory-discovery)
|
||||
|
||||
- [ ] 5.1 Add tests for pattern scanning: found (range/module/all-modules), wildcard mask, not-found returns Zero
|
||||
- [ ] 5.2 Implement `WhiteMagic/Discovery/PatternScanner.cs` to pass 5.1
|
||||
- [ ] 5.3 Add tests + implement scan result cache (repeat served from cache, clear rescans)
|
||||
- [ ] 5.4 Add tests + implement `WhiteMagic/Discovery/PeHeaderParser.cs` (sections, entry point)
|
||||
- [ ] 5.5 Add tests + implement `WhiteMagic/Memory/AllocatedMemory.cs` (named regions, typed read/write by name, address by name, free on dispose)
|
||||
- [ ] 5.6 Add tests + implement raw code injection (`InjectCode` at address and into fresh allocation)
|
||||
- [ ] 5.7 Add tests + implement DLL injection via remote thread (LoadLibrary), including bitness-mismatch and missing-file failures
|
||||
- [ ] 5.8 Add tests + implement DLL injection via thread-hijack (save/redirect/restore context) with x86 and x64 stubs
|
||||
|
||||
## 6. In-Process Tier (spec: remote-execution)
|
||||
|
||||
- [ ] 6.1 Add tests for `InProcessInvoker.CreateFunction<TDelegate>` calling a known in-process function directly
|
||||
- [ ] 6.2 Implement `WhiteMagic/Execution/InProcessInvoker.cs` (`Marshal.GetDelegateForFunctionPointer`) + vtable-entry helper to pass 6.1
|
||||
- [ ] 6.3 Document that the CLR-host managed loader (injecting `InProcessReader` into a foreign process) is a separate follow-up change
|
||||
|
||||
## 7. High-Level Ergonomics (spec: high-level-api)
|
||||
|
||||
- [ ] 7.1 Add tests + implement `RemotePointer` indexer (`sharp[addr].Read/Write/Execute` relative to base)
|
||||
- [ ] 7.2 Add tests + implement `RemoteModule`/`RemoteFunction` (`sharp["mod"]["fn"]`) resolving export addresses and executing via a chosen strategy
|
||||
- [ ] 7.3 Add tests + implement `ManagedPeb`/`ManagedTeb` field reads
|
||||
- [ ] 7.4 Add tests + implement `WindowFactory`/`RemoteWindow` (enumerate, move/resize/title/activate/flash, query by class)
|
||||
- [ ] 7.5 Add tests + implement keyboard/mouse simulation (PostMessage + SendInput) to a target window
|
||||
- [ ] 7.6 Add tests + implement `Task`-based async execution wrappers over the executors and pump
|
||||
- [ ] 7.7 Add minimal facade (`WhiteMagic` entry type) exposing `Open`, readers, executors, managers, and the indexer
|
||||
|
||||
## 8. Optional Iced Backend (spec: managed-assembler)
|
||||
|
||||
- [ ] 8.1 Add `Iced` package reference behind an `IcedAssembler : IAssembler` in a way that keeps the default `StubAssembler` dependency-free
|
||||
- [ ] 8.2 Add tests + implement `IcedAssembler.Assemble(text, origin)` for arbitrary mnemonics and origin-relative encoding
|
||||
- [ ] 8.3 Add tests + wire full prologue instruction-boundary validation (D5) using the Iced disassembler when present
|
||||
|
||||
## 9. Verification
|
||||
|
||||
- [ ] 9.1 Run full test suite: `dotnet test WhiteMagicTest/WhiteMagicTest.csproj` — all pass
|
||||
- [ ] 9.2 Run full build (`dotnet build WhiteMagic.slnx`) — zero errors, zero new warnings in `WhiteMagic`
|
||||
- [ ] 9.3 Confirm existing BlackMagic/its tests are unchanged and still green
|
||||
- [ ] 9.4 Update `docs/memory-library-comparison.md` "WhiteMagic — synthesis" section with any deviations discovered during implementation
|
||||
@@ -0,0 +1 @@
|
||||
schema: spec-driven
|
||||
Reference in New Issue
Block a user