Add detailed analysis and documentation for RSNet.dll download functionality

- Introduced a comprehensive markdown document outlining the API surface of RSNet.dll related to downloading video files.
- Documented initialization, connection, record querying, and download processes.
- Provided insights into the exported functions, their parameters, and expected behaviors.
- Included practical implications and recommendations for implementing a downloader script using C# interop.
- Highlighted the necessary struct layouts and callback mechanisms for effective integration with the DLL.
This commit is contained in:
MaddoScientisto 2026-04-17 21:19:46 +02:00
commit de077ca5d5
34 changed files with 2084 additions and 152 deletions

74
SurveillanceClient/.gitignore vendored Normal file
View file

@ -0,0 +1,74 @@
# Build results
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# User-specific files
*.user
*.suo
*.userosscache
*.sln.docstates
*.rsuser
# Visual Studio
.vs/
*.VisualState.xml
*.pidb
*.svclog
# JetBrains Rider
.idea/
*.DotSettings.user
# VS Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# NuGet
*.nupkg
*.snupkg
.nuget/
packages/
!packages/build/
project.lock.json
project.fragment.lock.json
artifacts/
# MSBuild Binary and Structured Log
*.binlog
# ReSharper
_ReSharper*/
*.[Rr]e[Ss]harper
# Test results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
coverage*.json
coverage*.xml
coverage*.info
# Local secrets / configuration overrides (NEVER COMMIT CREDENTIALS)
appsettings.Development.json
appsettings.Local.json
appsettings.*.Local.json
secrets.json
secrets.*.json
*.secrets
.env
.env.*
# OS
Thumbs.db
ehthumbs.db
Desktop.ini
.DS_Store
# Downloaded video output (local runtime artifact)
downloads/
output/

View file

@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Required by source-generated P/Invoke (LibraryImport). -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- RSNet.dll is 32-bit (x86). Consumers of the native interop must run as x86. -->
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,62 @@
# SurveillanceClient
.NET 10 host for driving the vendor `RSNet.dll` (32-bit) to download
recorded video from a surveillance NVR/DVR.
## Layout
- `src/SurveillanceClient.Core` — class library that wraps the `RSNet.dll`
native exports behind a service interface (`IRsNetClient`).
- `src/SurveillanceClient.Cli` — console host used for iterative,
manual testing of the Core library against a real device.
## Platform
`RSNet.dll` is a 32-bit Visual Studio DLL using `__stdcall`. Every
project in this solution targets `x86` via `Directory.Build.props`.
## Running
The vendor DLL and its dependencies live in
`i:\Apps\Surveillance_client`. The CLI sets that directory as the
native DLL search path at startup so the DLL and its `.ini` config
are found.
## Credentials
Credentials are **never committed**. Configure them via .NET user
secrets (recommended) or a `appsettings.Local.json` file (ignored by
`.gitignore`).
### User secrets (recommended)
```powershell
cd src/SurveillanceClient.Cli
dotnet user-secrets set "Device:Host" "192.168.100.92"
dotnet user-secrets set "Device:Port" "9000"
dotnet user-secrets set "Device:Username" "admin"
dotnet user-secrets set "Device:Password" "your-password-here"
```
### appsettings.Local.json (alternative)
Create `src/SurveillanceClient.Cli/appsettings.Local.json`:
```json
{
"Device": {
"Host": "192.168.100.92",
"Port": 9000,
"Username": "admin",
"Password": "your-password-here"
}
}
```
## Build / Run
```powershell
cd SurveillanceClient
dotnet build
dotnet run --project src/SurveillanceClient.Cli
```

View file

@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/SurveillanceClient.Core/SurveillanceClient.Core.csproj" />
<Project Path="src/SurveillanceClient.Cli/SurveillanceClient.Cli.csproj" />
</Folder>
<Folder Name="/solution-items/">
<File Path=".gitignore" />
<File Path="Directory.Build.props" />
<File Path="README.md" />
</Folder>
</Solution>

View file

@ -0,0 +1,37 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SurveillanceClient.Cli;
using SurveillanceClient.Core;
var builder = Host.CreateApplicationBuilder(args);
// Local, never-committed overrides for credentials etc.
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false);
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>(optional: true);
}
builder.Services.AddSurveillanceClientCore();
builder.Services.AddHostedService<TestHarness>();
using var host = builder.Build();
try
{
await host.RunAsync();
}
catch (Exception ex)
{
host.Services.GetRequiredService<ILogger<Program>>()
.LogCritical(ex, "Unhandled exception");
return 1;
}
return 0;
/// <summary>Marker type used to anchor user-secrets and logger categories.</summary>
public partial class Program;

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>SurveillanceClient.Cli</RootNamespace>
<AssemblyName>SurveillanceClient.Cli</AssemblyName>
<IsPackable>false</IsPackable>
<UserSecretsId>surveillance-client-cli-4f0a1c0d</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SurveillanceClient.Core\SurveillanceClient.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,87 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SurveillanceClient.Core.Configuration;
using SurveillanceClient.Core.Services;
namespace SurveillanceClient.Cli;
/// <summary>
/// Iterative manual-test harness. Runs once at startup against the
/// configured device, logs status callbacks, then requests shutdown.
/// Extended step-by-step as the native API is proven out.
/// </summary>
internal sealed class TestHarness : BackgroundService
{
private readonly ILogger<TestHarness> _logger;
private readonly IRsNetClient _client;
private readonly DeviceOptions _device;
private readonly IHostApplicationLifetime _lifetime;
public TestHarness(
ILogger<TestHarness> logger,
IRsNetClient client,
IOptions<DeviceOptions> device,
IHostApplicationLifetime lifetime)
{
_logger = logger;
_client = client;
_device = device.Value;
_lifetime = lifetime;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await RunAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// graceful shutdown
}
catch (Exception ex)
{
_logger.LogError(ex, "Test harness failed");
}
finally
{
_lifetime.StopApplication();
}
}
private async Task RunAsync(CancellationToken ct)
{
_logger.LogInformation("=== Stage 1: Initialize RSNet.dll ===");
await _client.InitializeAsync(ct).ConfigureAwait(false);
_logger.LogInformation("RSNet initialized successfully.");
if (string.IsNullOrWhiteSpace(_device.Host) ||
string.IsNullOrWhiteSpace(_device.Username))
{
_logger.LogWarning(
"Device credentials not configured. Set them via user-secrets or appsettings.Local.json " +
"to run the connection stage. Skipping connect.");
return;
}
_logger.LogInformation(
"=== Stage 2: Connect to {Host}:{Port} as {User} ===",
_device.Host, _device.Port, _device.Username);
using var session = _client.Connect(_device);
session.StatusChanged += (_, e) =>
_logger.LogInformation("Session status callback: code=0x{Code:X} ({CodeDec})", e.Code, e.Code);
_logger.LogInformation("Session handle: 0x{Handle:X}. Waiting up to 15s for login callbacks...",
session.Handle.ToInt64());
try
{
await Task.Delay(TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { }
_logger.LogInformation("=== Stage 2 complete. Stop here until login status codes are mapped. ===");
}
}

View file

@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning",
"SurveillanceClient": "Debug"
}
},
"RsNet": {
"NativeDirectory": "i:\\Apps\\Surveillance_client"
},
"Device": {
"Host": "",
"Port": 9000,
"Username": "",
"Password": ""
}
}

View file

@ -0,0 +1,15 @@
namespace SurveillanceClient.Core.Configuration;
/// <summary>
/// Credentials and endpoint for a target surveillance device.
/// Supplied via user secrets or local config — never committed.
/// </summary>
public sealed class DeviceOptions
{
public const string SectionName = "Device";
public string Host { get; set; } = string.Empty;
public ushort Port { get; set; } = 9000;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View file

@ -0,0 +1,22 @@
namespace SurveillanceClient.Core.Configuration;
/// <summary>
/// Runtime options for locating and loading <c>RSNet.dll</c>.
/// </summary>
public sealed class RsNetOptions
{
public const string SectionName = "RsNet";
/// <summary>
/// Absolute path to the directory that contains <c>RSNet.dll</c>
/// and its companion files (e.g. <c>RSNet.ini</c>, P2P helpers).
/// Typically the vendor install directory.
/// </summary>
public string NativeDirectory { get; set; } = @"i:\Apps\Surveillance_client";
/// <summary>
/// Optional encryption value passed to <c>RSNetSetEncription</c>.
/// <c>null</c> skips the call.
/// </summary>
public uint? Encryption { get; set; }
}

View file

@ -0,0 +1,69 @@
using System.Runtime.InteropServices;
namespace SurveillanceClient.Core.Native;
/// <summary>
/// Raw P/Invoke bindings for <c>RSNet.dll</c>.
///
/// ABI notes (see docs/surveillance-client-rsnet-download-analysis.md):
/// <list type="bullet">
/// <item>Architecture: x86/32-bit. The host process MUST run as x86.</item>
/// <item>Calling convention: <c>StdCall</c> (callee cleans stack).</item>
/// <item>Strings: ANSI (<c>char*</c>), not UTF-16.</item>
/// </list>
///
/// Request structs are intentionally passed as opaque <see cref="IntPtr"/>
/// during the reverse-engineering phase. Strongly-typed wrappers may be
/// introduced as the native layouts are confirmed.
/// </summary>
internal static partial class RsNetNative
{
private const string Dll = "RSNet.dll";
[LibraryImport(Dll, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial void RSNetInit();
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial void RSNetRelease();
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial void RSNetSetEncription(uint value);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial IntPtr RSNetConnectionStart(IntPtr request);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial IntPtr RSNetConnectionStartEx(IntPtr request);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial int RSNetQueryRecord(IntPtr session, IntPtr queryRequest);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial int RSNetQueryRecordEx(IntPtr session, IntPtr queryRequest);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial IntPtr RSNetStartDownloadRecord(IntPtr session, IntPtr downloadRequest);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial IntPtr RSNetStartDownloadRecordEx(IntPtr session, IntPtr downloadRequest);
[LibraryImport(Dll)]
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvStdcall)])]
internal static partial void RSNetReqRecordData(IntPtr recordPlayHandle);
/// <summary>
/// Session/status callback dispatched by the DLL's login/worker thread.
/// Exact signature is provisional — see the analysis doc.
/// </summary>
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
internal delegate void RsNetStatusCallback(uint code, IntPtr userContext);
}

View file

@ -0,0 +1,34 @@
using System.Runtime.InteropServices;
namespace SurveillanceClient.Core.Native;
/// <summary>
/// Provisional layout for <c>RSNetConnectionStart</c>'s request struct.
///
/// Based on decompilation of the copy-in path inside RSNet.dll:
/// <list type="bullet">
/// <item>+0x00 pointer to ANSI address string</item>
/// <item>+0x04 16-bit port</item>
/// <item>+0x08 pointer to ANSI username string</item>
/// <item>+0x0C pointer to ANSI password string</item>
/// <item>+0x10..+0x28 misc 32-bit fields (mode/callback-related)</item>
/// </list>
///
/// This is NOT fully validated. Treat as a best-effort starting point.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct RsNetConnectRequest
{
public IntPtr AddressAnsi;
public ushort Port;
public ushort Padding0;
public IntPtr UsernameAnsi;
public IntPtr PasswordAnsi;
public uint Field10;
public uint Field14;
public uint Field18;
public uint Field1C;
public uint Field20;
public uint Field24;
public uint Field28;
}

View file

@ -0,0 +1,26 @@
using System.Runtime.InteropServices;
namespace SurveillanceClient.Core.Native;
/// <summary>
/// Win32 entry points used to control the native DLL search path before
/// loading <c>RSNet.dll</c>. The vendor DLL expects its install directory
/// on the search path so it can find <c>RSNet.ini</c> and helper binaries.
/// </summary>
internal static partial class Win32
{
[LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetDllDirectoryW(string? lpPathName);
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial IntPtr AddDllDirectory(
[MarshalAs(UnmanagedType.LPWStr)] string NewDirectory);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetDefaultDllDirectories(uint DirectoryFlags);
internal const uint LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000;
internal const uint LOAD_LIBRARY_SEARCH_USER_DIRS = 0x00000400;
}

View file

@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SurveillanceClient.Core.Configuration;
using SurveillanceClient.Core.Services;
namespace SurveillanceClient.Core;
/// <summary>
/// DI registration helpers for the SurveillanceClient Core services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="IRsNetClient"/> and binds its options from
/// configuration. The client is a singleton because <c>RSNet.dll</c>
/// holds process-wide global state.
/// </summary>
public static IServiceCollection AddSurveillanceClientCore(this IServiceCollection services)
{
services.AddOptions<RsNetOptions>().BindConfiguration(RsNetOptions.SectionName);
services.AddOptions<DeviceOptions>().BindConfiguration(DeviceOptions.SectionName);
services.TryAddSingleton<IRsNetClient, RsNetClient>();
return services;
}
}

View file

@ -0,0 +1,46 @@
using SurveillanceClient.Core.Configuration;
namespace SurveillanceClient.Core.Services;
/// <summary>
/// High-level, managed facade over <c>RSNet.dll</c>. Intended lifetime:
/// singleton per process. <see cref="InitializeAsync"/> must be called
/// before any session is opened.
/// </summary>
public interface IRsNetClient : IAsyncDisposable
{
/// <summary>Whether <c>RSNetInit</c> has been called successfully.</summary>
bool IsInitialized { get; }
/// <summary>
/// Loads <c>RSNet.dll</c> from the configured directory and calls
/// <c>RSNetInit</c>. Safe to call more than once; subsequent calls are no-ops.
/// </summary>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Opens a direct-IP session by calling <c>RSNetConnectionStart</c>.
/// The returned session has not yet necessarily completed login — the
/// DLL performs login asynchronously on a worker thread.
/// </summary>
IRsNetSession Connect(DeviceOptions device);
}
/// <summary>
/// Handle-bearing wrapper around a single <c>RSNet.dll</c> session.
/// Disposing closes the native session and releases any pinned buffers.
/// </summary>
public interface IRsNetSession : IDisposable
{
/// <summary>Native session handle returned by <c>RSNetConnectionStart</c>.</summary>
IntPtr Handle { get; }
/// <summary>Raised for each native status/event callback.</summary>
event EventHandler<RsNetStatusEventArgs>? StatusChanged;
}
/// <summary>Status event payload forwarded from the native callback.</summary>
public sealed class RsNetStatusEventArgs(uint code) : EventArgs
{
public uint Code { get; } = code;
}

View file

@ -0,0 +1,122 @@
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SurveillanceClient.Core.Configuration;
using SurveillanceClient.Core.Native;
namespace SurveillanceClient.Core.Services;
/// <inheritdoc cref="IRsNetClient"/>
internal sealed class RsNetClient : IRsNetClient
{
private readonly ILogger<RsNetClient> _logger;
private readonly RsNetOptions _options;
private readonly SemaphoreSlim _initLock = new(1, 1);
private bool _initialized;
private bool _disposed;
public RsNetClient(ILogger<RsNetClient> logger, IOptions<RsNetOptions> options)
{
_logger = logger;
_options = options.Value;
}
public bool IsInitialized => _initialized;
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_initialized) return;
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized) return;
if (!OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("RSNet.dll is Windows-only.");
if (RuntimeInformation.ProcessArchitecture != Architecture.X86)
throw new PlatformNotSupportedException(
$"RSNet.dll is 32-bit (x86). Current process is {RuntimeInformation.ProcessArchitecture}. " +
"Build and run the host as x86.");
var dir = _options.NativeDirectory;
if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
throw new DirectoryNotFoundException(
$"RsNetOptions.NativeDirectory not found: '{dir}'. " +
"Configure it to the vendor install directory containing RSNet.dll.");
var dllPath = Path.Combine(dir, "RSNet.dll");
if (!File.Exists(dllPath))
throw new FileNotFoundException("RSNet.dll not found in NativeDirectory.", dllPath);
_logger.LogInformation("Configuring native DLL search path: {Directory}", dir);
// Use the modern search list so both RSNet.dll and its implicit
// dependencies are resolved from the vendor directory.
Win32.SetDefaultDllDirectories(
Win32.LOAD_LIBRARY_SEARCH_DEFAULT_DIRS | Win32.LOAD_LIBRARY_SEARCH_USER_DIRS);
Win32.AddDllDirectory(dir);
// Legacy fallback for any implicit LoadLibrary calls by RSNet.dll itself.
Win32.SetDllDirectoryW(dir);
// Many vendor DLLs read <Name>.ini from the *current* working directory
// rather than the DLL's own directory. Switch CWD so RSNet.ini resolves.
var previousCwd = Environment.CurrentDirectory;
Environment.CurrentDirectory = dir;
_logger.LogDebug("CWD changed {Previous} -> {New}", previousCwd, dir);
_logger.LogInformation("Calling RSNetInit()");
RsNetNative.RSNetInit();
if (_options.Encryption is { } enc)
{
_logger.LogInformation("Calling RSNetSetEncription({Value})", enc);
RsNetNative.RSNetSetEncription(enc);
}
_initialized = true;
}
finally
{
_initLock.Release();
}
}
public IRsNetSession Connect(DeviceOptions device)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_initialized)
throw new InvalidOperationException(
$"{nameof(RsNetClient)} is not initialized. Call InitializeAsync first.");
ArgumentException.ThrowIfNullOrWhiteSpace(device.Host);
ArgumentException.ThrowIfNullOrWhiteSpace(device.Username);
return RsNetSession.Open(_logger, device);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_initialized)
{
try
{
_logger.LogInformation("Calling RSNetRelease()");
RsNetNative.RSNetRelease();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RSNetRelease threw during shutdown");
}
}
_initLock.Dispose();
await Task.CompletedTask;
}
}

View file

@ -0,0 +1,122 @@
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using SurveillanceClient.Core.Configuration;
using SurveillanceClient.Core.Native;
namespace SurveillanceClient.Core.Services;
/// <summary>
/// Owns a single <c>RSNetConnectionStart</c> session handle plus the
/// unmanaged buffers (ANSI strings, request struct) that the DLL reads
/// from during the asynchronous login worker's lifetime.
/// </summary>
internal sealed class RsNetSession : IRsNetSession
{
private readonly ILogger _logger;
private readonly List<IntPtr> _unmanagedAllocs = [];
private readonly RsNetNative.RsNetStatusCallback? _callback;
private IntPtr _handle;
private bool _disposed;
public IntPtr Handle => _handle;
public event EventHandler<RsNetStatusEventArgs>? StatusChanged;
private RsNetSession(ILogger logger, RsNetNative.RsNetStatusCallback callback)
{
_logger = logger;
_callback = callback;
}
internal static RsNetSession Open(ILogger logger, DeviceOptions device)
{
RsNetSession? session = null;
RsNetNative.RsNetStatusCallback callback = null!;
try
{
// The callback MUST be held by a managed field so the GC does not
// collect the thunk while the native worker still holds the pointer.
session = new RsNetSession(logger, (uint code, IntPtr ctx) =>
{
logger.LogDebug("RSNet status callback: code={Code} ctx={Ctx}", code, ctx);
session?.RaiseStatus(code);
});
callback = session._callback!;
var hostPtr = MarshalAnsi(session, device.Host);
var userPtr = MarshalAnsi(session, device.Username);
var passPtr = MarshalAnsi(session, device.Password);
var request = new RsNetConnectRequest
{
AddressAnsi = hostPtr,
Port = device.Port,
Padding0 = 0,
UsernameAnsi = userPtr,
PasswordAnsi = passPtr,
// Fields 0x10..0x28 are mode/callback-related and not yet
// fully validated. Leaving them zero matches the minimal
// direct-IP login path observed in Surveillance_client.exe.
};
var requestPtr = Marshal.AllocHGlobal(Marshal.SizeOf<RsNetConnectRequest>());
session._unmanagedAllocs.Add(requestPtr);
Marshal.StructureToPtr(request, requestPtr, fDeleteOld: false);
logger.LogInformation(
"RSNetConnectionStart host={Host} port={Port} user={User}",
device.Host, device.Port, device.Username);
var handle = RsNetNative.RSNetConnectionStart(requestPtr);
if (handle == IntPtr.Zero)
throw new InvalidOperationException("RSNetConnectionStart returned NULL.");
session._handle = handle;
return session;
}
catch
{
session?.Dispose();
GC.KeepAlive(callback);
throw;
}
}
private void RaiseStatus(uint code)
{
try
{
StatusChanged?.Invoke(this, new RsNetStatusEventArgs(code));
}
catch (Exception ex)
{
_logger.LogError(ex, "Status handler threw for code {Code}", code);
}
}
private static IntPtr MarshalAnsi(RsNetSession session, string? value)
{
var ptr = Marshal.StringToHGlobalAnsi(value ?? string.Empty);
session._unmanagedAllocs.Add(ptr);
return ptr;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// The exported stop-session API has not been positively identified in
// the current RSNet.dll symbol recovery (see analysis doc). For now
// we simply release native buffers and let RSNetRelease() at shutdown
// tear down session state. Once the stop export is confirmed, call it
// here with _handle before freeing buffers.
foreach (var p in _unmanagedAllocs)
{
if (p != IntPtr.Zero) Marshal.FreeHGlobal(p);
}
_unmanagedAllocs.Clear();
// Keep the callback alive until after native cleanup would have run.
GC.KeepAlive(_callback);
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SurveillanceClient.Core</RootNamespace>
<AssemblyName>SurveillanceClient.Core</AssemblyName>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
</Project>