Skip to content
forked from thethiny/MVSIASI

About

OVS client-side DLL

Resources

Code of conduct

Contributing

Stars

5 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

OpenVersus MVS mod

TL;DR;/I just wanna play MVS via OpenVersus:

Releases of the official OpenVersus installer MVS Buddy are available in the OpenVersus MVS Buddy repository. If you just want to install OpenVersus, please download and run MVS Buddy, as it is by far the easiest and most user-friendly method to install and manage OpenVersus installations. This repository (ovs-client) is aimed toward developers and its releases are primarily consumed by MVS Buddy and the client's built-in updater.

To get MVS Buddy for Windows, download the zip file here, and then run then exe inside of it.

To get MVS Buddy for Steam Deck and ordinary Linux installs. download the tarball here, and then run the the MvsBuddy file inside of it. It should extract from the tarball with the exec bit already set.


This is the source repository of the OpenVersus client-side DLL, a fork based on the publicly-available original work of thethiny and multiversuskoth.

The source code for the OpenVersus client-side DLL is available here, as well as prebuilt binary releases of the client. Binary releases are also maintained in this repo for backward-compatibility.

The current release is built with Visual Studio 2022 and 2026, using toolchain version 145. Opening the solution and building is all that's necessary at this point to locally build a version of the client. A more comprehensive README will be committed in the near future.


Current Features:

  • Anti-Pak Signature Patch. Allows you to run .pak files with invalid .sig signatures (custom local skins replacing an existing skin or emote, etc).
  • Anti-uToc Signature Patch. Allows you to use uToc files (and .uCas) with invalid Signature Headers (see above).
  • Custom Server addresses: Allows you to change the server endpoint (whether it points to official OpenVersus servers or not. OpenVersus server code is also open source and available here)
  • Bypasses the game's built-in network sunset date/killswitch checks (necessary for online play)
  • Supports using custom in-game notification dialogs
  • Restores the original post-match behavior: after the final stock, everyone still in the match will again see the winner(s) postgame character inputs, instead of the winner standing dead still until the postmatch screen appears.
  • Auto-update: at launch, checks for a newer version and installs it, then closes the game so the next launch runs the update (AutoUpdate, on by default)
  • P2P Rollback node: runs the OpenVersus rollback netcode server beside the game and closes it with the game; this is for matches that run directly between the players' machines via P2P instead of on a central server (RollbackNode, on by default; see Rollback node)
  • Game files: at launch, downloads the paks (OpenVersus's own game files) the current release needs, each checked by thier known-good and trusted SHA-256 hash; they are required, so this happens even with AutoUpdate off (see Paks)
  • Works with the Steam and the Epic Games Store versions of the game, and should also work with other versions sourced from neither (untested)
  • Neuters the game's built-in Analytics telemetry (and holy shit there was a lot of it)

Testing features (opt-in)

These only apply in the following modes for now: Free For All, the experimental Testing Grounds queue, and Custom Lobbies that turn them on. The regular 1v1 and 2v2 queues play as they always have.

  • Free For All and 2v2 Individual Stocks rules: each fighter has their own lives and stays out once all their stocks have been taken; Free For All ends when only one fighter remains.
  • Team Attack/Friendly Fire: (most) attacks will damage your partner. Abilities/attacks that are clearly 2v2/team-focused do not.
  • Beta Speed: matches run at the same speed as Beta MVS.

How to use:

Comprehensive install instructions are available in the readme.txt file included in the release zip file. An installer will be provided with a later build, but for right now, it's just a simple drag & drop.

Settings

Settings live in OpenVersus.toml beside the plugin, which the mod creates with default values when it's missing. sample.toml in this repo is a copy of that default file, for reference. Text values go in double quotes (ServerUrl = "https://prod.openversus.org/"); true, false and numbers don't. Lines starting with # are comments, and a # after a value starts a comment too. If the file has a mistake, the mod runs on default settings and leaves the file alone, and a message in the game (and logs/OpenVersus.log) says which line and column to fix. If you still have an OpenVersus.ini from an older version, the first launch converts it to OpenVersus.toml, keeping your settings and comments, and deletes the ini. If an OpenVersus.toml is already there (say, after extracting a new release over an old install), the ini's settings are merged into it wherever it still has the default value, and the ini is deleted. Versions before the 2026.04.08.14 prerelease won't start without a settings file; since then, a missing one is created with default values.


Info for nerds

The OpenVersus mod client is a C#/.NET 10 NativeAOT MultiVersus mod. Its native build environment is a Linux machine using xwin and lld to produce a native Windows PE binary, but it should have no issue building on a Windows host. It is a reimplementation of the former C++ client: an .asi loaded by Ultimate ASI Loader. Its files are TOML: a player's OpenVersus.ini becomes OpenVersus.toml on the first launch, OVSState.ini becomes OVSState.toml, and PatternsCache.cache is replaced by PatternsCache.toml (see Settings).

Status. Verified in the game on Windows, Linux (Proton) and Steam Deck, including matches with netstats on and the post-match freeze patch. Also verified in the game: the C++ client updating itself to this one (from a legacy plain-http ServerUrl), which then moved into plugins/OpenVersus/ and converted the ini; and the updater refusing an offer whose built-in version was not newer. Never tested on macOS.

Rollback node

By default, matches run directly between the players' own machines (P2P rollback), and only fail back to a central rollback server in the event of connection failures. The game is told to connect to 127.0.0.1 and a port, where a version of the OpenVersus rollback netcode server (OVS.Rollback.Node, from the ovs-rollback-server repository) must be listening. The mod runs that node:

  • At startup, when RollbackNode is on and the game's server connection points at an OpenVersus server, it starts node/win-x64/OVS.Rollback.Node.exe from the mod's folder (plugins/OpenVersus/). Under Proton or Wine it starts node/linux-x64/OVS.Rollback.Node instead, through Wine's start /unix, so the node runs natively on Linux and the Steam Deck; the Windows build runs under Wine only when the Linux one is missing. On a Mac (CrossOver) it starts node/OVS.Rollback.Node.sh the same way, a launcher that runs node/osx-arm64/OVS.Rollback.Node on Apple silicon or node/osx-x64/OVS.Rollback.Node on Intel (the Mac's architecture is not visible from inside Wine), and the Linux node anywhere else.
  • The node is told to take any free UDP port and writes it to node/port-<game pid>.txt; the mod reports that port to the server with the player's identity (nodePort in /api/identify), and the server names it in the match-found notification. A node that cannot be started leaves the port at 0 and the server uses its default.
  • Everything the node needs travels on its command line (--port-file, --parent-token, --parent-timeout, --server, --rendezvous), because Wine does not pass the Windows environment to a Linux program.
  • [Server.Game] P2PRegistry and P2PRegistryPort name the rendezvous service the nodes find each other through (defaults p2p.openversus.org and 41235). The host is a DNS name the server side points wherever the service runs, so moving it needs no client release; the port has its own row for the same reason, and a value that is not a port falls back to 41235 with a warning. An empty host sends every P2P match through a relay. The node resolves the name once at startup, so a change takes effect at the next launch of the game.
  • The mod sends the node a keepalive every second (its P2P protocol's Parent message, with a token only this launch knows). On Windows the node is also in a kill-on-close job, so a game crash ends it at once; through start /unix it is not the mod's child, and the keepalive stopping is what ends it, within ten seconds. A normal exit stops it directly.

The node ships with every release and the updater keeps it current, like the paks: the release carries one archive per platform (OVS_Node_<version>_<platform>.zip or .tar.gz), the mod tells the server which node version it runs beside the game (read from the node's executable, where the build stamps it; a node without one, or none at all, counts as out of date), and when the server says the release's is different, Net/NodeUpdate.cs downloads the archive for the platform the game runs on (a Mac is asked which it is through a small script, since that is not visible from inside Wine), checks its size and SHA-256, unpacks it and swaps it into node/<platform>/, keeping the previous build as node/<platform>.old for one launch. Under Proton, Wine or CrossOver the unpacked node and the launcher get their execute bits through Wine's start /unix and chmod, since files written through Wine have none. This happens at launch before the node is started, so no relaunch is needed, and whatever AutoUpdate says, since peers' nodes must match; a failure leaves the previous node in place and the game plays on (matches then go through a relay).

Layout

  • OpenVersus/: the .asi itself: the InitializeASI export the loader calls, and OpenVersusInfo (Plugin.cs).
  • OpenVersus.Core/: everything else, so the tests and the Wine harness can share it.
    • Client.cs: startup: other copies retired, the move into plugins/OpenVersus/, then the C++ client's order: settings, console, hooks, background work.
    • DuplicatePlugins.cs, Layout.cs, LoaderConfig.cs: other copies of the plugin, where the mod lives, and what Ultimate ASI Loader will load (see Where it lives).
    • Config/: the TOML files (TomlConfig: Settings, State, PatternCache), the commented default settings file (DefaultConfig), what every config file shares (ConfigFile), the conversion from the old settings ini (SettingsMigration), and the reader for the old ini files (IniFile).
    • Game/: the game's structures and offsets (Mvs, UE), the function registry (GameFunctions), the object array and finder (Objects), reflection, dialogs and toasts (GameUi), the game-thread queue (GameThread) and the engine-ready gate (Engine).
    • Hooks/: one class per patch or hook; Hooking/HookGuard keeps exceptions out of the game.
    • Memory/: byte patterns, call-site redirects, trampolines, code writes, guarded reads.
    • Net/: HTTP, the notification poller, auto-update, JSON.
    • P2P/: the rollback node the mod runs beside the game (RollbackNode): how it is started on Windows and under Proton, the file it reports its port in, and the keepalive that lets it outlive a crashed game by seconds rather than forever.
    • Identity/: the Steam/Epic identity, hardware fingerprint and per-device identifiers sent to the server.
    • NetStats/: the per-match rollback statistics log.
    • Native/: P/Invoke declarations, with Windows SDK names.
  • OpenVersus.HookTest/: a plugin for the Wine harness that proves the hooking layer on a small host, with no game involved.
  • OpenVersus.Tests/: xunit tests that run on the Linux host, including the object finder against a synthetic game (FakeGame.cs).
  • wine-host/: the harness. run.sh cross-compiles host.c, publishes the test plugin and runs both under Wine in a private prefix.

Build

Requires the .NET 10 SDK. Two scripts wrap the commands below and check the prerequisites:

build.sh                # Linux (macOS untested): build, test, publish OpenVersus_<version>.asi; --help for options
build.ps1               # Windows (PowerShell; build.cmd runs it from cmd or a double-click)
build.sh build.ps1
(no command) (no command) build, run the tests, publish
test test build and run the tests only; needs no Windows toolchain
publish publish publish the plugin only
harness run the Wine harness
clean clean remove every bin/ and obj/ directory
--install DIR -Install DIR copy the plugin into DIR/OpenVersus (DIR is the game's plugins folder), renaming any OpenVersus*.asi in either to .bak
--rwx -Rwx read-write-execute trampolines (see below)
--skip-tests -SkipTests skip the tests in the default command
--accept-license accept the Visual Studio Build Tools license for the Windows SDK sysroot

On Windows the publish needs the "Desktop development with C++" workload of Visual Studio or its Build Tools. On Linux it needs lld-link (package lld) and xwin on PATH. The first publish downloads the Windows SDK sysroot (about 2.4 GB) into ~/.cache/xwin, which needs its license accepted: use --accept-license, ACCEPT_VS_BUILD_TOOLS_LICENSE=true, or answer yes when the script asks. To reuse a sysroot you already have (xwin's own default is .xwin-cache), set XWinCache to that directory, with a trailing slash.

By hand:

dotnet build OpenVersus.slnx          # everything, for the host (tests, no AOT)
dotnet test  OpenVersus.slnx
dotnet publish OpenVersus/OpenVersus.csproj -c Release -r win-x64 -p:AcceptVSBuildToolsLicense=true
wine-host/run.sh                      # the hooking layer, end to end under Wine

The published plugin is OpenVersus/bin/Release/net10.0/win-x64/publish/OpenVersus_<version>.asi, named from the VERSION file. Ultimate ASI Loader loads every .asi in the game's folder and, by default, every .asi under scripts/ and plugins/, subfolders included, so two versions side by side would both load; the plugin retires extra copies itself (see Other copies of the plugin). The plugin imports only system DLLs (KERNEL32, ADVAPI32, bcrypt, ole32) and the UCRT api-sets.

Trampoline pages are read-execute except while a stub is being written. -p:RwxTrampolines=true on the publish (the scripts' --rwx) keeps them read-write-execute for their whole life, as the C++ client did.

Release builds also write full XML documentation (OpenVersus.Core.xml, OpenVersus.xml). A public member without a doc comment is a compile-time warning (CS1591), but not a build error.

CI and releases

.github/workflows/build.yml runs on every branch push and pull request, and can be started by hand. It runs ./build.sh test and dotnet format --verify-no-changes, then publishes the plugin on a GitHub-hosted Ubuntu runner with the same toolchain as above (lld from apt, xwin pinned by version and checksum, the SDK sysroot cached between runs). Its artifact is the install exactly as it ships:

xinput1_3.dll                      Ultimate ASI Loader, pinned by digest
LICENSE_Ultimate_ASI_Loader.txt
readme.txt                         from release-readme.txt
plugins/OpenVersus/OpenVersus_<version>.asi
plugins/OpenVersus/sample_config.toml   sample.toml under a note that it is a sample
plugins/OpenVersus/LICENSE.txt
plugins/OpenVersus/VERSION.txt

The zip ships no OpenVersus.toml: the plugin writes it on first launch (the same text as sample.toml), so extracting a release over an install never replaces a player's settings.

The loader's xinput1_3 build is only published under upstream's moving x64-latest release, so when upstream replaces it the build fails with a message naming LOADER_SHA256: check the new loader, then update the digest (and LOADER_LICENSE_URL's tag) in build.yml.

.github/workflows/release.yml publishes a release when a tag is pushed. It builds nothing: it waits for the successful build run of the tagged commit and publishes that run's artifact, so what ships is what was built and tested. To release:

  1. Bump VERSION and push.
  2. Tag that commit with the bare version and push the tag: git tag 2026.09.25.01 && git push origin 2026.09.25.01. There's no need to wait for the build; the release waits for it.

The tag must equal VERSION (a v prefix does not match). A suffix, as in 2026.09.25.01-test1, publishes a prerelease; remove a test release with gh release delete <tag> --cleanup-tag. The release holds OpenVersus_v<tag>.zip (the install, the rollback node for every platform inside), OpenVersus_<version>.asi (the plugin alone, which is what the server's download_url points the auto-updater at), OVS_Node_<version>_<platform>.zip / .tar.gz (the node alone, per platform, which the updater installs; built in build.yml from the server repo at the commit NODE_REF names and stamped with VERSION, so pin NODE_REF to a commit before tagging a release), OpenVersus_<version>.zip (the package build.sh package makes: plugins/OpenVersus/ with the plugin, its settings file and every node; what MVS Buddy installs), the OVS_* paks of the previous release (GitHub's "latest", the newest that is not a prerelease), carried over so the new release is complete from the moment it is published (new content replaces them by hand afterwards, as before), a .sha256 beside each built file, and SHA256SUMS. The release is created as a draft and published once every asset is on it. To attach node archives to a release by hand, the server repo's OVSRollbackNode/tools/archive-nodes.sh <version> <dir> packs them from a NODE_VERSION=<version> ./build.sh node build; the version in their names must be the release's.

The server offers a release's .asi asset and falls back to its zip when there is none; the updater handles both, installing only the one .asi in a zip (or the one named for the offered version) and nothing else from it. Before anything is installed, the download is checked against the release's <download_url>.sha256: a mismatch, a missing .sha256, or one that cannot be fetched or read, means no install this launch. A missing one counts as a mismatch because every release the updater could install (only newer ones are) comes from this pipeline, which always publishes them. Last, the plugin itself decides: the version built into it (its version resource, read without loading it) must be newer than the running client and must be the version the server offered, so a mislabeled offer can never make a client reinstall itself.

Paks

Some features need OpenVersus's own game files (paks: .pak, .utoc, .ucas and .sig). The server's /ovs/client-version answer lists the release's files, its paks among them, and at launch, before the engine opens its paks, Net/StartupUpdate.cs compares them with %LOCALAPPDATA%\MultiVersus\Saved\Paks: one of the folders the engine mounts paks from, always writable, and never touched by Steam's updates or file verification (under Proton, the prefix's AppData). Net/PakUpdate.cs downloads whatever is missing or different into a staging folder, checks every size and SHA-256, and installs them all or nothing, keeping the previous copy of each replaced file; then the game closes, so the next launch mounts the new files. Paks download whatever AutoUpdate says: the game crashes without the paks the server's content needs, so when they cannot be brought up to date the game is closed rather than started. They download only from the openversus account's GitHub releases ([Settings.Debug] ReleaseOwner, for testing a fork).

OVS paks installed by hand in the game's Content\Paks outrank Saved\Paks, so each is moved over when it matches the release and moved out of the way when it does not. The working files live in Saved\OpenVersus\: update-staging, pak-backup, old-content-paks (hand-installed paks moved out of the way) and PakHashes.txt (local hashes by size and modified time, so unchanged files are not hashed again). A release without paks leaves all of this with nothing to do.

How it maps to the former C++ client

C++ Here
DllMain → OnInitializeHook Plugin.InitializeASI → Client.Initialize (NativeAOT cannot run managed code in DllMain; the loader calls the export right after LoadLibrary)
PatternFinder, CachedPatternsMgr PatternResolver, PatternCache; the pattern parser has the C++ semantics (? is one byte) and a cached address is checked before use
MakeProxyFromOpCode, InjectHook, Trampoline CallSite.Redirect, CallSite.Inject, Trampoline
OVS::Hooks::* Hooks/*. The sig-check, sunset-date and post-match-freeze changes are byte patches (the sunset check runs thousands of times a minute); the endpoint and game-instance hooks are call redirects into [UnmanagedCallersOnly] methods guarded by HookGuard; the startup dialog and toast are a game-thread job
MVSGame::* function globals GameFunctions: every resolved function by name, with where it came from and its signature; reflected functions through Reflection.Find
NotificationPoller heap scans ObjectFinder over the engine's object array, falling back to the heap scan
__try/__except reads CodeWriter.TryRead (a guarded read; NativeAOT cannot catch access violations)
WinInet / WinHTTP / raw socket IHttpTransport, implemented on WinHTTP

Settings

The settings are OpenVersus.toml, read and written by Config/TomlConfig.cs through Tomlyn's syntax tree (its serializer is not used: it drops comments, and its reflection path is not NativeAOT-safe). Table and key names match regardless of case, as they did in the ini. Writing only ever adds a missing key, directly under the last key of its table in that table's Key = Value or Key=Value style, or a missing table at the end; comments, blank lines, spacing, unknown keys, line endings and a byte-order mark stay as the player left them, and a complete file is not rewritten at all. A boolean row takes a TOML boolean, or on/off, 1/0 or "true" in any case. A value that does not parse is logged as a warning, read as its default and left on disk. A file that is not valid TOML is logged with the line and column where it broke, every setting takes its default, and the file is left alone. When the settings cannot be used (a TOML mistake, an unreadable file, an ini that would not convert), a toast after "OpenVersus Loaded" says so. A missing OpenVersus.toml (or the defaults after an ini that would not convert) is Config/DefaultConfig.cs: every row in Settings.Table order grouped by table, with CRLF line endings, [Settings.Debug] just above the servers, and a comment above every setting and table; a test fails if a row has no comment. Converted and merged files get no comments. sample.toml is a checked-in copy of that file: the plugin never writes it, and a test holds the two to the same bytes, so after changing a row, regenerate it by copying the OpenVersus.toml the plugin writes into an empty folder.

A legacy OpenVersus.ini with no OpenVersus.toml beside it is converted once (Config/SettingsMigration.cs), line for line: ; comments become #, names take the spelling of the row they match, values a boolean row reads as a boolean become TOML booleans, and everything else becomes a string, deprecated and invalid values included. The one exception is both ServerUrl keys, which become https://prod.openversus.org/ whatever they held, since old files can still point at the plain-http endpoint that only stays up for them; the log records the old value. A repeated section or key, or a line that is neither, was never read by the ini reader, and becomes a comment. The result is checked against the ini reader, key by key, before anything is written. If it checks out, the TOML is written and the ini deleted; if it does not, the log keeps the ini's text, the TOML starts from defaults, and the ini is still deleted. The ini is deleted only once the TOML is on disk. When both files exist (an old ini left beside a TOML, say from a C++ install restored over a converted one), the ini is merged into the TOML (SettingsMigration.Merge): a row's value is taken only where the TOML is missing it or still has the default, so nothing changed in the TOML is overwritten; any other key only where the TOML lacks it; ServerUrl never. Each value taken is logged, with a warning that sums them up, and the ini is deleted once the TOML is saved. A TOML that does not parse takes nothing, and the ini stays until it does.

The client's own files are TOML too. OVSState.toml holds [FirstRun] PaidModWarned; an OVSState.ini is carried over once (the agreement, so the free-mod dialog is not shown again) and deleted once that is on disk. PatternsCache.toml names the exe (Exe, the top 32 bits of the .text hash) and client version (Client) it is for at its top, with pattern text to RVA under [Patterns]; a file for another exe or client, or one that does not parse, starts over. The old PatternsCache.cache is deleted: it was only a cache. Config/IniFile.cs remains only to read the old ini files for these conversions; what the TOML side shares with it (boolean spellings, quote stripping, the atomic write) is in Config/ConfigFile.cs, so dropping ini support one day is IniFile.cs, SettingsMigration.cs and their tests.

What each key does is documented on its row in Config/Settings.cs (Settings.Rows). Compared with the C++ client:

  • Added: [Patches] PostMatchFreeze, on by default, with its pattern under [Patterns.MVS]; and, off by default, [Patches] SunsetCallers, [Settings.Debug] CountSunsetCalls, [Settings.Debug] MatchRulesLog (writes logs/MatchRules.log for testing the FFA, Individual Stocks and Friendly Fire rules) and [Features] NetStats. Two changes have no setting and no pattern in the file, so a player cannot switch them off: requests to the OpenVersus server carry this install's identity (Hooks/RequestHeadersHook.cs), which ties a player to their own account; and the game's WB Analytics telemetry to event.wbinsights.com is stopped at its send function (Hooks/TelemetryPatch.cs).
  • [Settings] LogLevel takes a level name as well as a number (see Logs).
  • No longer read: LogSize, ModLoader, AntiCheatEngine, CurlSetOpt and CurlPerform under [Settings] (Settings.RetiredKeys). They stay in files that have them, each logged at startup with why it is unused; nothing adds them to files that don't. Any other key the client does not know is logged as a warning.
  • [Settings.Keybinds] ToggleMenu is kept so existing files mean what they did, but nothing reads it; the keyboard hook only ever logged F1.

Sunset check switches

The game's sunset-date check is called thousands of times a minute. [Patches] SunsetDate (default on) makes the function itself return false with two byte patches. Two more switches, both off by default:

  • [Patches] SunsetCallers = true finds every direct call and tail jump to the function through .pdata and a .text scan, and turns each into "return false" in place, so the function is never entered. The log reports the count found (146 in the final build).
  • [Settings.Debug] CountSunsetCalls = true routes the function's comparison path through a counter, and the heartbeat line each minute reports how many calls the last minute saw. With both switches on, that number must be zero.

Logs

logs/OpenVersus.log next to the plugin is the running log, truncated at each launch; the previous run's is kept as logs/OpenVersus_<launch time>.log. Archives from the last week stay as plain text; older ones are compressed to .zst (zstd level 11, through ZstdSharp) on a background thread at launch, keeping their timestamps. With [Settings.Debug] ShowConsole on, every line also goes to the debug console.

If the logs directory cannot be created or written, the log goes to the plugin's own directory instead; if that fails too, the client still runs, Log.FileError records why, and every line still reaches the console. Log.Notice says which happened and is the first warning logged.

Levels

Log is a Microsoft.Extensions.Logging.ILogger with its own writer (file plus console). The minimum level comes from [Settings] LogLevel: a name in quotes ("trace", "debug", "info", "warn", "error", "critical", "none", or the usual aliases such as "verbose", "all", "err", "quiet") or a number, bare or quoted (1 debug to 6 none; a number past 6 means 6). The value is tried as a number first, then as a name. An unquoted name (LogLevel = debug) is not valid TOML, so the whole file falls back to defaults. 0, which converted files carry, means "decide from [Settings.Debug] DebugLogging": Debug when it is on, Information when it is off. A name that is not a level is logged and falls back the same way. Tags in the file are TRC DBG NFO WRN ERR CRT. Per-attempt and game-thread queue lines are Trace; pattern and function resolution is Debug; hooks, banners and netstats are Information.

Netstats

With [Features] NetStats = true the per-second NETSTATS lines go to logs/NetStats.log, one file per match: it opens when a session starts playing and closes at the summary, which archives it as NetStats_<match start time>_<match id>[_with_<teammates>]_vs_<opponents>.log with the same retention as the main log. The names come from the player data each fighter pawn carries and the id from the gameplay-config subsystem (both confirmed on the live game), read at the first playing sample and, while incomplete, again once a second for ten seconds. A log that never got a description keeps the plain stamp, and at Debug level the main log lists what each reading saw (every pointer followed, checked for being an object). A closed match log stays on disk as NetStats.log and is not archived a second time by the next open.

The main log gets only the session start and end, the match log open and close, and the NETSTATS-SUMMARY line; anything that used to grep the main log for NETSTATS reads the match logs now. Between matches the session is looked up through the object array every five seconds, and every sample asks the array whether the session object is still listed, so a freed session is never sampled.

Conventions

  • The version is the repository root's VERSION and nothing else: the build generates OvsVersion.Current from it, stamps the assembly and version resource with it and names the plugin for it, and release.yml refuses a tag that differs from it.
  • Formatting is .editorconfig, applied with dotnet format: four spaces, Allman braces, one statement per line. Braces on every control-flow body (IDE0011) is an error in the build; a one-line auto-property or single-expression => is fine.
  • Every public member has an XML doc comment. Offsets and addresses say where they came from (a dump, a live read, a disassembly), since a dump's layout is a claim about this build, not a fact.
  • Everything takes ILogger; the short verbs (Info, Warn, Success, ...) are extension methods in LogExtensions.cs that pass the text through verbatim, never as a template. Only Client and the plugin entry point hold the concrete Log, since they own its lifetime.
  • A patch that cannot be made throws PatchException out of Memory/ (CodeWriter, CallSite, Trampoline); Client.ApplyHooks catches per hook, logs it as that hook's failure and goes on. CodeWriter.TryRead never throws, since it runs on hot paths.
  • Settings are rows (Settings.Rows.*), one static SettingDef per ini key, read through typed properties; PatternResolver.Find("SigCheck") reads the pattern text from the row of that key.
  • JSON goes through the source-generated Net/OvsJson.cs context (NativeAOT has no reflection for System.Text.Json); add a [JsonSerializable] there for any new shape.
  • Background loops stop through a CancellationToken, waited on rather than slept through.
  • Nothing calls into the engine before Engine.IsUp (the game instance has existed for three seconds): every entry point in UE and GameUi refuses with an exception until then, and background work waits with Engine.WaitUntilUp. Calling the engine during UE initialization crashes the game with no log line to show for it.
  • Reads of the game's memory go through IMemory (ProcessMemory in the plugin, a byte-backed fake in the tests) and engine name lookups through IGameNames, so the object array, the finder, and the reflection decoder can be tested on Linux against a synthetic game (OpenVersus.Tests/FakeGame.cs). Those tests prove the logic; the offsets they are built from are the code's own, and only the running game checks those.
  • What stays C-shaped is what must: [UnmanagedCallersOnly] hooks and the static state they need, function-pointer casts, sequential-layout structs mirroring the game, nint arithmetic, and Win32 names in Native/.

What the binary says about itself

The version resource (Windows shows it under Properties > Details) carries the product name, description, Copyright (c) 2026 Christopher Conley, Tuggernuts, the file version from VERSION, and a product version of <VERSION>+<commit>.<build date, UTC>, all set in Directory.Build.props. The same text is in OvsVersion (Current, Informational, Copyright), and the export OpenVersusInfo returns it as a NUL-terminated UTF-8 JSON object (name, version, productVersion, copyright) for tools that load the plugin on purpose. Loading it runs nothing: the plugin does no work until the ASI loader calls InitializeASI.

Where it lives

The mod lives in its own folder, plugins/OpenVersus/, which the ASI loader loads like the rest of plugins/. Everything the plugin writes (settings, state, pattern cache, logs) goes beside it. Earlier versions lived loose in plugins/, so a plugin running from a folder named OpenVersus takes over what they left one folder up (Layout.cs): an OpenVersus.toml or OVSState.toml there is moved in when the mod's folder has none; an OpenVersus.ini or OVSState.ini there is converted, or merged into an existing TOML, by the same rules as one in the mod's folder; old pattern caches are deleted. A logs folder is never moved: the C++ client wrote none, and plugins/logs may belong to another mod.

Every launch, right after the duplicate check, a plugin running from anywhere other than plugins/OpenVersus/ beside the game executable moves itself there, with its settings, state and cache files (Layout.MoveInto), and carries on from the new folder; this launch's log stays where it was opened, and no logs are moved. It does so only when Ultimate ASI Loader would load that folder (LoaderConfig.cs): the loader reads [GlobalSets] from <its name>.ini, global.ini, scripts\global.ini, plugins\global.ini and update\global.ini beside it, each overriding the last, and loads folders under plugins/ when LoadPlugins and LoadRecursively are on, their default with no settings at all. When either is off, the plugin stays where it is and says why in the log. Updates install into the same folder the move would use, so they never land where the loader would not look. If the plugin cannot be moved, nothing is, and it runs where it is.

Other copies of the plugin

Ultimate ASI Loader loads every .asi it finds, so a new release extracted over an old one (the C++ client's OpenVersus.asi, or an older OpenVersus_<version>.asi) would run two clients patching the same code. The first thing Client.Initialize does is search the plugin's folder and the game executable's folder, subfolders included, for any other OpenVersus*.asi (DuplicatePlugins.cs). The newest copy by the version built into it stays, whatever the file is called: the fixed file version in its version resource, which the build stamps from VERSION, read from the file's bytes without loading it (loading a copy would run its code, and the C++ client patches the game from DllMain; a copy that crashed while loading would crash the game before anything was renamed, every launch). A file without a version resource, like the C++ client, counts as oldest, and a tie goes to the running copy. Every other copy, the running one included if it lost, is renamed to .bak. Then the game closes with a message saying what was renamed, without patching anything, since another copy may already have patched it this launch.

Testing against the game

build.sh --install <game>/plugins (or -Install on Windows) publishes and installs the plugin, keeping the previous one as a .bak. With DebugLogging = true or LogLevel = "debug", logs/OpenVersus.log lists every pattern, the address of every function it resolved, and the hooks that took; when something fails to resolve, try comparing those addresses with the C++ build's console output.

Set AutoUpdate = false while testing. The update check works against https (the C++ one never could) and installs any newer release the server offers, which would replace the build under test. To test the updater itself without GitHub, point [Server.Game] ServerUrl at a local server that answers GET /ovs/client-version with latest_version and download_url and serves the .asi and its .sha256; the offered file must really be newer (its version resource decides), or it is refused.

The Wine harness (build.sh harness or wine-host/run.sh) checks the hooking layer without the game: the host calls three assembly sites before and after loading the test plugin, which redirects a call and a jmp into managed code, patches a byte, makes one hook throw to prove the guard returns the fallback, and swaps a function pointer in read-only data, as in a vtable (and refuses a second swap whose expected value is stale). It needs wine and mingw-w64 besides the publish toolchain, and uses its own prefix under local/wine-host.


The former C++ version of the OpenVersus client-side DLL was a fork based on the publicly-available original work of thethiny and multiversuskoth. The current C# version contains no common code whatsoever from the original repository and is an entirely original work. OpenVersus is not affiliated with, nor endorsed by, WB, PFG, its developers, or any other related entity. OpenVersus is not affiliated with, nor endorsed by, any other modding project or effort, the developers of those projects, or any other related entity. This mod is provided on an as-is, sans-warranty basis as detailed in the LICENSE file. Use at your own risk and discretion.

About

OVS client-side DLL

Resources

Code of conduct

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages