The plugin project file
A plugin is an ordinary .NET class library with a few required properties and some build targets
that package and deploy it. The annotated StarterPlugin.csproj below is the canonical reference —
copy it and rename. The key parts:
EnableDynamicLoading— required, so ofs-ng can load the assembly into its own collectible context.AssemblyName/ folder /.dll— must all match; ofs-ng loads<folder>/<folder>.dll.Version— your plugin's own version, shown next to its name in the plugin list.OfsDir— where your ofs-ng install lives; the build readsOfs.Api.dllfrom<OfsDir>/managed. Resolution order:-p:OfsDir=…→OFS_DIRenv var → the in-repo default../../bin.RuntimeIdentifier— defaults to the building SDK's RID so packages with native libraries ship their native assets; pin it (-p:RuntimeIdentifier=win-x64) to target another platform.- Localization
EmbeddedResource— optional strongly-typedStraccessor generated fromLocalization/Str.resx; delete theLocalization/folder and the block if you don't localize. PackPluginZip/DeployToPrefPlugins— after each build, packdist/<name>.zipand copy the plugin straight into your per-user plugins folder for fast iteration (-p:DeployToPref=falseto opt out).Ofs.Apiis excluded from both — ofs-ng owns the one canonical copy it loads everyone against.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Required: lets ofs-ng load this assembly into its own collectible context. -->
<EnableDynamicLoading>true</EnableDynamicLoading>
<!-- ofs-ng loads <folder>/<folder>.dll, so the folder name must equal the assembly name. -->
<AssemblyName>StarterPlugin</AssemblyName>
<RootNamespace>StarterPlugin</RootNamespace>
<!-- Shown next to the name in the plugin list (OfsPlugin.Version reads this). -->
<Version>1.0.0</Version>
<!-- Localization/Str.resx is the neutral (English) catalog; declaring the neutral language emits
[assembly: NeutralResourcesLanguage] so the resource manager skips a satellite probe for English. -->
<NeutralLanguage>en</NeutralLanguage>
</PropertyGroup>
<!-- ======================================================================
Where is ofs-ng installed?
OfsDir must point at the folder that contains the ofs-ng executable, plus
managed/ (the build reads Ofs.Api.dll from there).
Standalone copy? Just uncomment the line below and set your path. Because it
lives in the project file, BOTH the build and IntelliSense pick it up — no
environment variable, no command-line flag. Forward slashes work on every
OS, e.g. C:/Tools/ofs-ng or /home/you/ofs-ng.
Resolution order if this line stays commented out:
1. -p:OfsDir=... on the command line (build only)
2. the OFS_DIR environment variable (build only)
3. ../../../bin (default: this template living inside the ofs-ng repo)
====================================================================== -->
<PropertyGroup>
<!-- <OfsDir>C:/Tools/ofs-ng</OfsDir> -->
<OfsDir Condition="'$(OfsDir)' == '' and '$(OFS_DIR)' != ''">$(OFS_DIR)</OfsDir>
<OfsDir Condition="'$(OfsDir)' == ''">$(MSBuildProjectDirectory)/../../../bin</OfsDir>
<OfsManagedDir>$(OfsDir)/managed</OfsManagedDir>
</PropertyGroup>
<!-- ======================================================================
Native NuGet assets (e.g. Emgu.CV's cvextern.dll, shipped under
runtimes/<rid>/native/ inside the package) are only restored and copied
to the build output for a RID-specific build. A framework-dependent
class-library build with no RuntimeIdentifier emits the *managed*
assemblies (so Emgu.CV.dll appears) but drops every native runtime asset
— so the plugin loads, then dies at the first P/Invoke with a
DllNotFoundException, and the natives are absent from both dist/<name>.zip
and <pref>/plugins. (EnableDynamicLoading already turns on
CopyLocalLockFileAssemblies, which covers managed transitive deps but NOT
the runtimes/ native tree.)
Defaulting to the building SDK's RID makes the natives land next to the
plugin in $(TargetDir), from where PackPluginZip / DeployToPrefPlugins
glob them in. This pins the plugin to one OS/arch — which a native
dependency does anyway. Override with -p:RuntimeIdentifier=linux-x64 etc.
SelfContained stays false: we want the package's native assets, not a
private copy of the .NET runtime. -->
<PropertyGroup>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">$(NETCoreSdkRuntimeIdentifier)</RuntimeIdentifier>
<SelfContained>false</SelfContained>
</PropertyGroup>
<!-- Optional localization: build-time strongly-typed accessor for Localization/Str.resx, so you call
Str.ClickMe (compile-checked) instead of a "ClickMe" string key. MSBuild generates the class into
obj/ on every build — no Visual Studio, no committed Designer.cs. OfsPlugin keeps the generated
accessor's static Culture synced to ofs-ng's language picker, so the getters follow the in-app
language with zero wiring. Don't want localization? Delete the Localization/ folder and this
ItemGroup and pass literal strings to ui.* directly. See README "Localization". -->
<ItemGroup>
<EmbeddedResource Update="Localization/Str.resx">
<!-- Link makes MSBuild derive the manifest name from "Str.resx" (root), NOT "Localization/Str.resx",
so the embedded resource is "StarterPlugin.Str(.resources)" — matching the generated accessor's
ResourceManager base name (StronglyTypedNamespace + class). Without it the subdir leaks into the
manifest and every Str.* lookup throws MissingManifestResource at runtime. -->
<Link>Str.resx</Link>
<Generator>MSBuild:Compile</Generator>
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
<StronglyTypedNamespace>StarterPlugin</StronglyTypedNamespace>
<StronglyTypedClassName>Str</StronglyTypedClassName>
<!-- Fixed, config/RID-independent path (Generated/, gitignored), NOT $(IntermediateOutputPath):
with a RuntimeIdentifier set (above) the latter gains a RID segment
(obj/Debug/net10.0/win-x64/). The full build generates+compiles from there fine, but Roslyn
(C# Dev Kit *and* Visual Studio) loads source from the evaluation-time **/*.cs Compile glob —
which excludes obj/ — so Str builds yet red-underlines in the editor. A stable in-tree path is
globbed at evaluation time, so IntelliSense resolves Str. -->
<StronglyTypedFileName>$(MSBuildProjectDirectory)/Generated/Str.Designer.cs</StronglyTypedFileName>
</EmbeddedResource>
<!-- Add a language by dropping Localization/Str.<culture>.resx alongside the neutral catalog; this
strips the subdir from each satellite's manifest name so it lands under the same base. -->
<EmbeddedResource Update="Localization/Str.*.resx">
<Link>%(Filename)%(Extension)</Link>
</EmbeddedResource>
</ItemGroup>
<!-- GenerateResource won't create the output dir; make it before the strongly-typed class is written. -->
<Target Name="EnsureGeneratedDir" BeforeTargets="CoreResGen">
<MakeDir Directories="$(MSBuildProjectDirectory)/Generated" />
</Target>
<!-- Once the file exists on disk (any rebuild) the default **/*.cs glob picks it up, and CoreResGen
also adds the StronglyTypedFileName to @(Compile) — two entries → CS2002 ("specified multiple
times"), an error under /WX. Drop the glob copy for a real build so only CoreResGen's copy
remains. Keep it for the design-time build ($(DesignTimeBuild)='true'), which doesn't run
CoreResGen — Roslyn resolves Str solely from the evaluation-time glob (see the path note above). -->
<ItemGroup Condition="'$(DesignTimeBuild)' != 'true'">
<Compile Remove="$(MSBuildProjectDirectory)/Generated/Str.Designer.cs" />
</ItemGroup>
<!-- The single assembly a plugin references. Private=false: never copy it next
to the plugin — ofs-ng owns the one canonical Ofs.Api it loads everyone against. -->
<ItemGroup>
<Reference Include="Ofs.Api">
<HintPath>$(OfsManagedDir)/Ofs.Api.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Target Name="CheckOfsApi" BeforeTargets="ResolveAssemblyReferences">
<Error Condition="!Exists('$(OfsManagedDir)/Ofs.Api.dll')"
Text="Ofs.Api.dll not found at '$(OfsManagedDir)'. Point OfsDir at your ofs-ng install (the folder containing the ofs-ng executable) — set OFS_DIR (e.g. in .vscode/tasks.json) or build with -p:OfsDir=/path/to/ofs-ng." />
</Target>
<!-- After a successful build, pack the plugin into an installable zip:
<project>/dist/StarterPlugin.zip, laid out as
StarterPlugin/
StarterPlugin.dll
StarterPlugin.runtimeconfig.json
StarterPlugin.deps.json
StarterPlugin.pdb (symbols; fine to delete before sharing a Release build)
— the exact <name>/<name>.dll shape ofs-ng's "Install plugin from zip" flow expects. Install
it from inside ofs-ng (Plugins → Install from zip); it lands in <pref>/plugins/<name>/ after the
trust prompt. (User plugins are NOT loaded from the folder next to the executable — that root is
reserved for shipped first-party plugins — so the zip + install flow is the only way to load one.)
Ofs.Api is excluded: ofs-ng owns the one canonical copy it loads everyone against. -->
<Target Name="PackPluginZip" AfterTargets="Build" Condition="'$(PackPlugin)' != 'false'">
<PropertyGroup>
<PluginDistDir>$(MSBuildProjectDirectory)/dist</PluginDistDir>
<!-- Stage under a folder named after the assembly so the zip's top-level entry is <name>/. -->
<PluginStageRoot>$(IntermediateOutputPath)pluginzip</PluginStageRoot>
<PluginStageDir>$(PluginStageRoot)/$(AssemblyName)</PluginStageDir>
<PluginZipFile>$(PluginDistDir)/$(AssemblyName).zip</PluginZipFile>
</PropertyGroup>
<ItemGroup>
<PluginZipFiles Include="$(TargetDir)**/*.*" Exclude="$(TargetDir)Ofs.Api.dll" />
</ItemGroup>
<!-- Rebuild the stage from scratch so a since-renamed/removed file never lingers in the zip. -->
<RemoveDir Directories="$(PluginStageRoot)" />
<Copy SourceFiles="@(PluginZipFiles)"
DestinationFiles="@(PluginZipFiles->'$(PluginStageDir)/%(RecursiveDir)%(Filename)%(Extension)')" />
<MakeDir Directories="$(PluginDistDir)" /> <!-- ZipDirectory does not create the destination dir -->
<ZipDirectory SourceDirectory="$(PluginStageRoot)" DestinationFile="$(PluginZipFile)" Overwrite="true" />
<Message Importance="high"
Text="Packed $(AssemblyName) -> $(PluginZipFile) (install via ofs-ng: Plugins → Install from zip)" />
</Target>
<!-- Dev convenience: also drop the plugin straight into ofs-ng's per-user plugins dir, so a rebuild
is picked up without the install-from-zip step. That dir is SDL_GetPrefPath("ofs","ofs-ng")/plugins
(the only writable root ofs-ng scans); we mirror its per-OS location below. ofs-ng still shows the
one-time trust prompt the first time it loads these bytes; turn on Plugins → "Hot reload (developer)"
to have an already-loaded plugin auto-reload on each rebuild. Opt out with -p:DeployToPref=false. -->
<Target Name="DeployToPrefPlugins" AfterTargets="Build" Condition="'$(DeployToPref)' != 'false'">
<PropertyGroup>
<OfsPrefDir Condition="'$(OS)' == 'Windows_NT'">$(APPDATA)\ofs\ofs-ng</OfsPrefDir>
<OfsPrefDir Condition="'$(OS)' != 'Windows_NT' and '$(XDG_DATA_HOME)' != ''">$(XDG_DATA_HOME)/ofs/ofs-ng</OfsPrefDir>
<OfsPrefDir Condition="'$(OS)' != 'Windows_NT' and '$(XDG_DATA_HOME)' == '' and $([MSBuild]::IsOSPlatform('OSX'))">$(HOME)/Library/Application Support/ofs/ofs-ng</OfsPrefDir>
<OfsPrefDir Condition="'$(OS)' != 'Windows_NT' and '$(XDG_DATA_HOME)' == '' and !$([MSBuild]::IsOSPlatform('OSX'))">$(HOME)/.local/share/ofs/ofs-ng</OfsPrefDir>
<PrefDeployDir>$(OfsPrefDir)/plugins/$(AssemblyName)</PrefDeployDir>
</PropertyGroup>
<ItemGroup>
<PrefDeployFiles Include="$(TargetDir)**/*.*" Exclude="$(TargetDir)Ofs.Api.dll" />
</ItemGroup>
<MakeDir Directories="$(PrefDeployDir)" />
<Copy SourceFiles="@(PrefDeployFiles)"
DestinationFiles="@(PrefDeployFiles->'$(PrefDeployDir)/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Message Importance="high" Text="Deployed $(AssemblyName) -> $(PrefDeployDir)" />
</Target>
</Project>