Skip to main content

Tutorial: GameRewind v3 plugin

Learn discovery, file policies and commands through the buildable GameRewind example, with operations performed through Host services.

GameRewind is a buildable API 3.6 example. It discovers supplied roots containing save.dat, proposes drafts, excludes cache files, and asks the Host to back up a config or run a side-effect-free ECHO.

Complete source​

GameRewind/Plugin.cs
using System.Text.Json;
using FolderRewind.Plugin.Abstractions;

namespace GameRewind;

public sealed class Plugin : IFolderRewindPlugin, IDiscoveryCapability,
IDiscoveryDefinitionCatalog, IFilePolicyCapability, IPluginCommandCapability,
IKnotLinkIntegrationCapability
{
private static readonly PluginId Id = new("com.example.gamerewind");
public ConfigKindRef Kind { get; } = new(new OwnerId(Id.Value), "game-saves");
public DiscoveryProviderId ProviderId { get; } = new("com.example.gamerewind.discovery");
private bool _discover;

public ValueTask<PluginActivationResult> ActivateAsync(
IPluginActivationContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_discover = context.Settings.Values.TryGetValue("DiscoverSaves", out var value)
&& value.GetBoolean();
context.RegisterCapability<IDiscoveryCapability>(this);
// Register this multi-interface instance once. Runtime recognizes all its contracts.
return ValueTask.FromResult(PluginActivationResult.Empty);
}

public ValueTask DeactivateAsync(CancellationToken cancellationToken)
{
_discover = false;
return ValueTask.CompletedTask;
}

public IReadOnlyList<DiscoveryDefinitionDescriptor> Definitions { get; } =
[new("example-game", "Example game", [], new Dictionary<string, string>())];
public string? ResolveDefinitionId(DiscoveryCandidate candidate)
=> candidate.ConfigDrafts.Any(draft => draft.Kind == Kind) ? "example-game" : null;

public ValueTask<DiscoveryResult> DiscoverAsync(
DiscoveryRequest request, PluginInvocationContext context)
{
var candidates = new List<DiscoveryCandidate>();
var diagnostics = new List<PluginDiagnostic>();
if (!_discover) return ValueTask.FromResult(new DiscoveryResult(candidates, diagnostics));
foreach (var suppliedRoot in request.UserRoots)
{
context.OperationCancellation.ThrowIfCancellationRequested();
context.PluginLifetime.ThrowIfCancellationRequested();
try
{
var root = Path.GetFullPath(suppliedRoot);
if (!File.Exists(Path.Combine(root, "save.dat"))) continue;
var folder = new FolderDraft(root, Path.GetFileName(root),
new Dictionary<StateOwnerId, ProviderStateDraft>());
var draft = new ConfigDraft(Kind, "Example game", [folder],
new Dictionary<StateOwnerId, ProviderStateDraft>());
candidates.Add(new DiscoveryCandidate(root, folder.DisplayName, [draft]));
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
{
diagnostics.Add(new("example.discovery_root_failed", DiagnosticSeverity.Warning,
"Discovery", Id.Value, new Dictionary<string, string> { ["message"] = error.Message }));
}
}
return ValueTask.FromResult(new DiscoveryResult(candidates, diagnostics));
}

public ValueTask<FilePolicyResult> ResolveAsync(
FilePolicyRequest request, PluginInvocationContext context)
{
context.OperationCancellation.ThrowIfCancellationRequested();
return ValueTask.FromResult(new FilePolicyResult(["*.tmp", "cache/**"], [], []));
}

public IReadOnlyList<PluginCommandDescriptor> Commands { get; } =
[new(new PluginCommandId(Id, "backup"), "Back up a reviewed example config",
JsonSerializer.SerializeToElement(new {
type = "object", properties = new { configId = new { type = "string" } },
required = new[] { "configId" }, additionalProperties = false }))];

public async ValueTask<PluginCommandResult> ExecuteAsync(
PluginCommandRequest request, PluginInvocationContext context)
{
if (request.Id != Commands[0].Id || !request.Arguments.TryGetValue("configId", out var id)
|| id.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(id.GetString()))
return Result(OperationOutcome.Blocked);
context.HostServices.Logger.Log(DiagnosticSeverity.Information, "Example backup requested.");
var outcome = await context.HostServices.Backups.RequestAsync(id.GetString()!, null,
new BackupRequestOptions { Comment = "GameRewind example" }, context.OperationCancellation);
return Result(outcome);
}

IReadOnlyList<KnotLinkCommandDescriptor> IKnotLinkIntegrationCapability.Commands { get; } =
[new("EXAMPLE_ECHO", "Echo text without changing data") {
Arguments = [new("text", "Text to return")], Returns = ["data"] }];

public ValueTask<PluginCommandResult> ExecuteAsync(string command,
IReadOnlyDictionary<string, string> arguments, PluginInvocationContext context)
{
context.OperationCancellation.ThrowIfCancellationRequested();
return ValueTask.FromResult(command == "EXAMPLE_ECHO"
? new PluginCommandResult(OperationOutcome.Success,
new Dictionary<string, JsonElement> {
["data"] = JsonSerializer.SerializeToElement(arguments.GetValueOrDefault("text", "")) }, [])
: Result(OperationOutcome.Blocked));
}

private static PluginCommandResult Result(OperationOutcome outcome)
=> new(outcome, new Dictionary<string, JsonElement>(), []);
}
GameRewind/manifest.json
{
"manifestVersion": 3,
"pluginId": "com.example.gamerewind",
"version": "1.0.0",
"name": "GameRewind",
"description": "API 3.6 documentation example; use test data.",
"author": "FolderRewind documentation",
"pluginApi": {
"major": 3,
"minor": 6
},
"entryAssembly": "GameRewind.dll",
"entryType": "GameRewind.Plugin",
"settingsSchema": "settings.schema.json",
"architectures": [
"any"
],
"configKinds": [
{
"ownerId": "com.example.gamerewind",
"kindId": "game-saves",
"displayName": "Game saves example",
"description": "User-reviewed local save directories.",
"icon": "folder",
"backupFallback": "block",
"restoreCoordination": "none"
}
],
"requestedHostServices": [
"backupRequest",
"logging"
],
"capabilities": [
"discovery",
"filePolicy",
"pluginCommand",
"knotLinkIntegration"
],
"artifactFormats": [],
"artifactTransformers": [],
"restoreStrategies": [],
"hasBackupCompletionObserver": false
}

Discovery and ownership​

Inspect explicit roots and return ConfigDraft/FolderDraft, never write config.json. The Host assigns managed identities and commits according to the user's creation policy. Unreadable or unmarked roots are not forcibly included. The example does not recursively scan disks.

DiscoveryDefinitionCatalog supplies stable DefinitionId, aliases and external IDs for game discovery and targeted presets. It is not another capability. Production candidate identities must support reconciliation; display titles are insufficient.

File policy and commands​

FilePolicy returns required exclusions within the Host source/filter boundary. The backup command requires configId and uses Backups instead of running an archive engine itself. ECHO declares its own metadata rather than taking over Host BACKUP.

Hot backup and restore boundaries​

The sample does not coordinate a running game or preserve player data. IBackupConsistencyCapability must provide an appropriate lease and stable source; copying a directory under active writes does not prove consistency. IRestoreCoordinatorCapability coordinates the external environment while the Host performs once-only mutation.

IRestoreStagingPreparationCapability proposes relative files for ordinary Restore; never write live paths after restore. Checkout/Merge do not invoke player preservation. IConfigReconciliationCapability proposes revision-bound changes; recompute stale proposals.

Build and test​

node scripts/pack-plugin.mjs GameRewind

Create a test directory with save.dat, install and review declarations, enable, discover and review drafts, then back up and restore test content. Check diagnostics and resource release on failure/cancellation. Stop writers before testing real game data.

See capability contracts and the API reference.

Known locations and selected roots​

The GameRewind example checks only supplied UserRoots containing save.dat; it defines no known-location catalog. A production provider may merge read-only machine hints when IncludeKnownLocations=true; false keeps selected-root scope. Do not scan entire drives, write launcher settings or persist candidates directly as configurations.