Erste Version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:26:23 +01:00
co-authored by Claude Opus 5.5
commit 35f2d1330d
23 changed files with 1981 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
dist/
publish/
.vs/
*.user
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<RootNamespace>BSO</RootNamespace>
<AssemblyName>BSO</AssemblyName>
<Version>0.2.0</Version>
<PlatformTarget>x64</PlatformTarget>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>disable</Nullable>
<!-- Wird vom Loader (hostfxr) als Komponente geladen: erzeugt BSO.runtimeconfig.json -->
<EnableDynamicLoading>true</EnableDynamicLoading>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- Build-Ausgabe von build.ps1 -->
<Compile Remove="dist/**" />
<None Remove="dist/**" />
</ItemGroup>
</Project>
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace BSO
{
/// <summary>Was der Loader (dinput8.dll) mitgibt - Gegenstueck zu HostApi in BSO.Loader\dinput8.cpp.</summary>
[StructLayout(LayoutKind.Sequential)]
public unsafe struct HostApi
{
public delegate* unmanaged<byte*, void> Log;
public char* GameDir;
public char* BsoDir;
}
/// <summary>
/// Einstieg: der Loader startet .NET beim ersten DirectInput8Create und ruft Start auf, danach OnFrame einmal pro
/// Bild im Hauptthread des Spiels. Protokoll: &lt;Spiel&gt;\BSO\BSO_Loader.txt.
/// </summary>
public static unsafe class Bootstrap
{
public const string ModVersion = "0.2.0";
static HostApi* host;
static int frameErrors;
/// <summary>Ordner von BSO.dll (&lt;Spiel&gt;\BSO).</summary>
public static string Folder { get; private set; }
/// <summary>Ordner der Spiel-EXE (Bin_High_Win64).</summary>
public static string GameFolder { get; private set; }
[UnmanagedCallersOnly]
public static void Start(HostApi* api)
{
try
{
host = api;
Folder = new string(api->BsoDir);
GameFolder = new string(api->GameDir);
Log("BSO " + ModVersion + " gestartet (.NET " + Environment.Version + ")");
Log("Kommandozeile: " + Environment.CommandLine);
Config.Load();
}
catch (Exception e)
{
Log("Start fehlgeschlagen: " + e);
}
}
[UnmanagedCallersOnly]
public static void OnFrame()
{
try
{
Mem.NewFrame();
if (!Vision.Init()) return;
if (Vision.TimerAdvanced())
{
Input.Update();
BorderlessWindow.Update();
LocalPlayer.Update();
Multiplayer.Update();
PositionHud.Update();
}
PositionHud.Draw();
}
catch (Exception e)
{
// Nicht jedes Bild ins Protokoll schreiben
if (frameErrors++ < 5) Log("Fehler im Bild-Takt: " + e);
}
}
public static void Log(string line)
{
if (host == null) return;
byte[] bytes = Encoding.UTF8.GetBytes(line + "\0");
fixed (byte* p = bytes) host->Log(p);
}
}
}
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Xml.Linq;
namespace BSO
{
/// <summary>
/// Vollbild im Fenster: macht das Spielfenster randlos und so gross wie den Bildschirm. Das exklusive DirectX-9-Vollbild
/// des Spiels laeuft unter neuen Windows-Versionen schlecht (Alt-Tab, Abstuerze) - so geht Alt-Tab sofort. Nur wenn
/// das Spiel im Fenstermodus laeuft (Settings.xml: Fullscreen="FALSE"). F11 schaltet zwischen Vollbild und Fenster.
/// Am schaerfsten ist es, wenn im Spiel die Aufloesung des Bildschirms eingestellt ist (das Bild wird sonst skaliert).
/// </summary>
public static class BorderlessWindow
{
[StructLayout(LayoutKind.Sequential)] struct Rect { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] struct MonitorInfo { public int Size; public Rect Monitor, Work; public uint Flags; }
[DllImport("user32.dll")] static extern nint GetWindowLongPtrW(nint window, int index);
[DllImport("user32.dll")] static extern nint SetWindowLongPtrW(nint window, int index, nint value);
[DllImport("user32.dll")] static extern bool GetWindowRect(nint window, out Rect rect);
[DllImport("user32.dll")] static extern bool GetClientRect(nint window, out Rect rect);
[DllImport("user32.dll")] static extern bool SetWindowPos(nint window, nint after, int x, int y, int w, int h, uint flags);
[DllImport("user32.dll")] static extern nint MonitorFromWindow(nint window, uint flags);
[DllImport("user32.dll")] static extern bool GetMonitorInfoW(nint monitor, ref MonitorInfo info);
const int GwlStyle = -16, GwlExStyle = -20;
const long Frame = 0x00C00000 | 0x00040000 | 0x00020000 | 0x00010000; // Titelleiste, Rahmen, Min/Max
const long ExFrame = 0x1 | 0x100 | 0x200 | 0x20000; // Rahmen-Kanten
const uint MonitorNearest = 2;
const uint SwpFrameChanged = 0x20, SwpNoZOrder = 0x4, SwpNoOwnerZOrder = 0x200, SwpShowWindow = 0x40;
static bool checkedSettings, allowed, enabled = true, isBorderless;
static nint window;
static long savedStyle, savedExStyle;
static Rect savedRect;
static readonly Stopwatch checkClock = new();
/// <summary>Einmal pro Bild im Hauptthread (dem Thread des Fensters).</summary>
public static void Update()
{
if (!checkedSettings)
{
checkedSettings = true;
allowed = !GameFullscreen();
if (!allowed) Bootstrap.Log("Spiel laeuft im eigenen Vollbild - BSO-Vollbild bleibt aus");
}
if (!allowed) return;
if (Input.Pressed(Input.F11))
{
enabled = !enabled;
Bootstrap.Log(enabled ? "Vollbild an" : "Vollbild aus (Fenster)");
if (!enabled && isBorderless) Restore();
}
// Jede Sekunde pruefen - das Spiel stellt sein Fenster z.B. nach Aenderungen in den Optionen neu ein
if (checkClock.IsRunning && checkClock.ElapsedMilliseconds < 1000 && !Input.Pressed(Input.F11)) return;
checkClock.Restart();
if (window == 0) window = Process.GetCurrentProcess().MainWindowHandle;
if (window == 0 || !enabled) return;
Rect monitor = MonitorRect();
GetWindowRect(window, out Rect now);
bool hasFrame = (GetWindowLongPtrW(window, GwlStyle) & Frame) != 0;
if (isBorderless && !hasFrame && Same(now, monitor)) return;
MakeBorderless(monitor);
}
static void MakeBorderless(Rect monitor)
{
long style = GetWindowLongPtrW(window, GwlStyle), exStyle = GetWindowLongPtrW(window, GwlExStyle);
if ((style & Frame) != 0) // nur das echte Fenster merken, nicht unser randloses
{
savedStyle = style;
savedExStyle = exStyle;
GetWindowRect(window, out savedRect);
}
SetWindowLongPtrW(window, GwlStyle, (nint)(style & ~Frame));
SetWindowLongPtrW(window, GwlExStyle, (nint)(exStyle & ~ExFrame));
SetWindowPos(window, 0, monitor.Left, monitor.Top, monitor.Right - monitor.Left, monitor.Bottom - monitor.Top,
SwpFrameChanged | SwpNoZOrder | SwpNoOwnerZOrder | SwpShowWindow);
isBorderless = true;
GetClientRect(window, out Rect client);
Bootstrap.Log($"Vollbild im Fenster: {client.Right}x{client.Bottom} (Bildschirm {monitor.Right - monitor.Left}x{monitor.Bottom - monitor.Top})");
}
static void Restore()
{
isBorderless = false;
if (window == 0 || savedStyle == 0) return;
SetWindowLongPtrW(window, GwlStyle, (nint)savedStyle);
SetWindowLongPtrW(window, GwlExStyle, (nint)savedExStyle);
SetWindowPos(window, 0, savedRect.Left, savedRect.Top, savedRect.Right - savedRect.Left, savedRect.Bottom - savedRect.Top,
SwpFrameChanged | SwpNoZOrder | SwpNoOwnerZOrder | SwpShowWindow);
}
static Rect MonitorRect()
{
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
GetMonitorInfoW(MonitorFromWindow(window, MonitorNearest), ref info);
return info.Monitor;
}
static bool Same(Rect a, Rect b) => a.Left == b.Left && a.Top == b.Top && a.Right == b.Right && a.Bottom == b.Bottom;
/// <summary>Fullscreen-Einstellung des Spiels (%LOCALAPPDATA%\European Bus Simulator 2012\Settings.xml).</summary>
static bool GameFullscreen()
{
try
{
string file = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"European Bus Simulator 2012", "Settings.xml");
string value = (string)XDocument.Load(file).Root?.Element("Graphics")?.Attribute("Fullscreen");
return string.Equals(value, "TRUE", StringComparison.OrdinalIgnoreCase);
}
catch (Exception e)
{
Bootstrap.Log("Settings.xml nicht lesbar: " + e.Message);
return false;
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.IO;
using System.Text.Json;
namespace BSO
{
/// <summary>Einstellungen in &lt;Spiel&gt;\BSO\BSO.json - wird beim ersten Start mit Standardwerten angelegt.</summary>
public class Config
{
/// <summary>Name, den die anderen sehen.</summary>
public string Name { get; set; } = Environment.UserName;
/// <summary>Server als host oder host:port.</summary>
public string Server { get; set; } = "127.0.0.1:7777";
/// <summary>Beim Spielstart automatisch verbinden (sonst F8).</summary>
public bool AutoConnect { get; set; }
public static Config Current { get; private set; } = new();
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
static string ConfigFile => Path.Combine(Bootstrap.Folder, "BSO.json");
public static void Load()
{
try
{
if (File.Exists(ConfigFile)) Current = JsonSerializer.Deserialize<Config>(File.ReadAllText(ConfigFile), Options) ?? new Config();
// Immer zurueckschreiben: neue Einstellungen erscheinen so auch in alten Dateien
File.WriteAllText(ConfigFile, JsonSerializer.Serialize(Current, Options));
Bootstrap.Log($"Einstellungen: Name \"{Current.Name}\", Server {Current.Server}, AutoConnect {Current.AutoConnect}");
}
catch (Exception e)
{
Bootstrap.Log("BSO.json nicht lesbar, nehme Standardwerte: " + e.Message);
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace BSO
{
public struct EntityInfo
{
public int Slot;
public nint Ptr;
public string Class;
public Vec3 Pos;
}
/// <summary>Alle Entities der Szene (VisBaseEntity_cl) mit Klassenname und Position.</summary>
public static unsafe class Entities
{
public static List<EntityInfo> Scan()
{
var list = new List<EntityInfo>();
if (!Vision.TryGetEntities(out nint* data, out int count)) return list;
for (int i = 0; i < count; i++)
{
nint e = data[i];
if (e == 0) continue;
string cls = Rtti.ClassName(e);
if (cls == null) continue; // kein gueltiges Objekt - lieber nicht anfassen
list.Add(new EntityInfo { Slot = i, Ptr = e, Class = cls, Pos = Vision.GetPosition(e) });
}
return list;
}
/// <summary>Steht in Platz slot noch dasselbe Objekt? Dann lebt es noch und darf gelesen werden.</summary>
public static bool StillThere(int slot, nint ptr)
{
return Vision.TryGetEntities(out nint* data, out int count) && slot < count && data[slot] == ptr;
}
/// <summary>Diagnose (F10): alle Entities nach BSO\entities.txt, naechste zur Kamera zuerst.</summary>
public static string Dump(Vec3 camera)
{
var list = Scan();
var sb = new StringBuilder();
sb.AppendLine($"BSO {Bootstrap.ModVersion} - {list.Count} Entities, Kamera bei {camera}");
sb.AppendLine();
sb.AppendLine("Klassen:");
foreach (var g in list.GroupBy(e => e.Class).OrderByDescending(g => g.Count()))
sb.AppendLine($" {g.Count(),5} {g.Key}");
sb.AppendLine();
sb.AppendLine("Platz Abstand Klasse Position");
foreach (var e in list.OrderBy(e => (e.Pos - camera).Length))
sb.AppendLine($"{e.Slot,5} {(e.Pos - camera).Length,8:0} {e.Class,-34} {e.Pos}");
string file = Path.Combine(Bootstrap.Folder, "entities.txt");
File.WriteAllText(file, sb.ToString());
Bootstrap.Log($"{list.Count} Entities nach {file} geschrieben");
return file;
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System;
using System.Runtime.InteropServices;
namespace BSO
{
/// <summary>Tasten der Mod (F8-F11): "gerade gedrueckt" erkennen, nur wenn das Spielfenster vorne ist.</summary>
public static class Input
{
[DllImport("user32.dll")] static extern short GetAsyncKeyState(int key);
[DllImport("user32.dll")] static extern nint GetForegroundWindow();
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(nint window, out uint process);
public const int F8 = 0x77, F9 = 0x78, F10 = 0x79, F11 = 0x7A;
static readonly bool[] down = new bool[256], pressed = new bool[256];
static readonly int[] watched = { F8, F9, F10, F11 };
/// <summary>Einmal pro Bild vor allen Pressed-Abfragen.</summary>
public static void Update()
{
bool focus = GameHasFocus();
foreach (int key in watched)
{
bool now = focus && GetAsyncKeyState(key) < 0;
pressed[key] = now && !down[key];
down[key] = now;
}
}
public static bool Pressed(int key) => pressed[key];
public static bool GameHasFocus()
{
GetWindowThreadProcessId(GetForegroundWindow(), out uint pid);
return pid == (uint)Environment.ProcessId;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
// Test ohne Spiel: laedt dist\dinput8.dll wie das Spiel, erstellt die Tastatur und fragt sie ein paar Mal ab.
// Danach muss dist\BSO\BSO_Loader.txt den .NET-Start und den Bild-Takt zeigen.
// Bauen (x64 Native Tools Prompt): cl /EHsc ProxyTest.cpp /link dinput8.lib dxguid.lib user32.lib
// Starten: ProxyTest.exe <voller Pfad zu dist\dinput8.dll>
// Erwartet im Protokoll: "Vision90.dll ist nicht geladen" - ohne Spiel gibt es keine Engine.
#define DIRECTINPUT_VERSION 0x0800
#include <windows.h>
#include <dinput.h>
#include <stdio.h>
int wmain(int argc, wchar_t** argv)
{
HMODULE proxy = LoadLibraryW(argc > 1 ? argv[1] : L"dinput8.dll");
if (!proxy) { printf("LoadLibrary fehlgeschlagen: %lu\n", GetLastError()); return 1; }
auto create = (decltype(&DirectInput8Create))GetProcAddress(proxy, "DirectInput8Create");
IDirectInput8W* di = nullptr;
HRESULT hr = create(GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8W, (void**)&di, nullptr);
printf("DirectInput8Create: 0x%08lX\n", hr);
if (FAILED(hr)) return 1;
IDirectInputDevice8W* kb = nullptr;
hr = di->CreateDevice(GUID_SysKeyboard, &kb, nullptr);
printf("CreateDevice(Tastatur): 0x%08lX\n", hr);
if (FAILED(hr)) return 1;
kb->SetDataFormat(&c_dfDIKeyboard);
kb->SetCooperativeLevel(GetConsoleWindow(), DISCL_BACKGROUND | DISCL_NONEXCLUSIVE);
kb->Acquire();
BYTE keys[256];
for (int i = 0; i < 30; i++) { kb->GetDeviceState(sizeof(keys), keys); Sleep(16); }
printf("30 Bilder abgefragt\n");
kb->Release();
di->Release();
return 0;
}
+305
View File
@@ -0,0 +1,305 @@
// BSO Loader - dinput8.dll-Proxy (wie Unity Doorstop bei MSCO, nur fuer die Vision Engine).
//
// Game.vPlugin importiert DINPUT8.dll, Windows sucht sie zuerst im Spielordner. Diese DLL reicht alles an die echte
// dinput8.dll aus System32 weiter und
// 1. startet beim ersten DirectInput8Create .NET (hostfxr) und ruft BSO.Bootstrap.Start in BSO\BSO.dll auf,
// 2. haengt sich in GetDeviceState/GetDeviceData der Tastatur: das Spiel fragt die Tastatur einmal pro Bild im
// Hauptthread ab - dort wird BSO.Bootstrap.OnFrame aufgerufen. Kein Spielcode wird gepatcht.
// Protokoll: <Spielordner>\BSO\BSO_Loader.txt (schreibt auch die Mod ueber HostApi.Log).
#define WIN32_LEAN_AND_MEAN
#define NETHOST_USE_AS_STATIC
#include <windows.h>
#include <stdio.h>
#include <stdarg.h>
#include <string>
#include <nethost.h>
#include <hostfxr.h>
#include <coreclr_delegates.h>
static HMODULE self, real;
static FILE* logFile;
static CRITICAL_SECTION logLock;
static std::wstring bsoDir;
static void LogLine(const char* text)
{
EnterCriticalSection(&logLock);
if (!logFile)
{
CreateDirectoryW(bsoDir.c_str(), nullptr);
logFile = _wfsopen((bsoDir + L"\\BSO_Loader.txt").c_str(), L"w", _SH_DENYWR);
}
if (logFile)
{
SYSTEMTIME t;
GetLocalTime(&t);
fprintf(logFile, "%02d:%02d:%02d %s\n", t.wHour, t.wMinute, t.wSecond, text);
fflush(logFile);
}
LeaveCriticalSection(&logLock);
}
static void Log(const char* fmt, ...)
{
char buf[1024];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
LogLine(buf);
}
// ---------------------------------------------------------------------------------------------------------------
// .NET starten
/// <summary>Was die Mod vom Loader bekommt (Gegenstueck: BSO.HostApi).</summary>
struct HostApi
{
void (*log)(const char* utf8);
const wchar_t* gameDir;
const wchar_t* bsoDir;
};
typedef void (CORECLR_DELEGATE_CALLTYPE* StartFn)(HostApi*);
typedef void (CORECLR_DELEGATE_CALLTYPE* FrameFn)();
static HostApi hostApi;
static std::wstring gameDir;
static FrameFn onFrame;
static bool managedTried;
static void StartManaged()
{
if (managedTried) return;
managedTried = true;
std::wstring assembly = bsoDir + L"\\BSO.dll";
std::wstring config = bsoDir + L"\\BSO.runtimeconfig.json";
if (GetFileAttributesW(assembly.c_str()) == INVALID_FILE_ATTRIBUTES)
{
Log("BSO\\BSO.dll fehlt - nur Proxy, keine Mod");
return;
}
char_t fxrPath[MAX_PATH];
size_t fxrSize = MAX_PATH;
int rc = get_hostfxr_path(fxrPath, &fxrSize, nullptr);
if (rc != 0) { Log(".NET nicht gefunden (get_hostfxr_path 0x%08X) - .NET 10 Runtime installieren", rc); return; }
HMODULE fxr = LoadLibraryW(fxrPath);
auto init = (hostfxr_initialize_for_runtime_config_fn)GetProcAddress(fxr, "hostfxr_initialize_for_runtime_config");
auto getDelegate = (hostfxr_get_runtime_delegate_fn)GetProcAddress(fxr, "hostfxr_get_runtime_delegate");
auto close = (hostfxr_close_fn)GetProcAddress(fxr, "hostfxr_close");
if (!init || !getDelegate || !close) { Log("hostfxr unvollstaendig"); return; }
hostfxr_handle ctx = nullptr;
rc = init(config.c_str(), nullptr, &ctx);
if (rc < 0 || !ctx) { Log("hostfxr_initialize_for_runtime_config 0x%08X", rc); if (ctx) close(ctx); return; }
load_assembly_and_get_function_pointer_fn load = nullptr;
rc = getDelegate(ctx, hdt_load_assembly_and_get_function_pointer, (void**)&load);
close(ctx);
if (rc != 0 || !load) { Log("hostfxr_get_runtime_delegate 0x%08X", rc); return; }
StartFn start = nullptr;
rc = load(assembly.c_str(), L"BSO.Bootstrap, BSO", L"Start", UNMANAGEDCALLERSONLY_METHOD, nullptr, (void**)&start);
if (rc != 0 || !start) { Log("BSO.Bootstrap.Start nicht gefunden (0x%08X)", rc); return; }
rc = load(assembly.c_str(), L"BSO.Bootstrap, BSO", L"OnFrame", UNMANAGEDCALLERSONLY_METHOD, nullptr, (void**)&onFrame);
if (rc != 0) { Log("BSO.Bootstrap.OnFrame nicht gefunden (0x%08X)", rc); onFrame = nullptr; }
hostApi.log = LogLine;
hostApi.gameDir = gameDir.c_str();
hostApi.bsoDir = bsoDir.c_str();
start(&hostApi);
}
// ---------------------------------------------------------------------------------------------------------------
// Bild-Takt ueber die Tastatur-Abfrage
static const GUID GUID_SysKeyboard_ = { 0x6F1D2B61, 0xD5A0, 0x11CF, { 0xBF, 0xC7, 0x44, 0x45, 0x53, 0x54, 0x00, 0x00 } };
// Feste Plaetze in den COM-vtables (IDirectInput8 / IDirectInputDevice8, A und W gleich)
enum { SlotCreateDevice = 3, SlotGetDeviceState = 9, SlotGetDeviceData = 10 };
typedef HRESULT (WINAPI* CreateDeviceFn)(void* self, REFGUID guid, void** device, void* outer);
typedef HRESULT (WINAPI* GetDeviceStateFn)(void* self, DWORD size, void* data);
typedef HRESULT (WINAPI* GetDeviceDataFn)(void* self, DWORD size, void* data, DWORD* count, DWORD flags);
/// <summary>Originalfunktionen je vtable (A- und W-Schnittstellen haben eigene vtables).</summary>
struct Patched { void** vtable; void* original[3]; };
static Patched patched[16];
static int patchedCount;
static Patched* Find(void* object)
{
void** vtable = *(void***)object;
for (int i = 0; i < patchedCount; i++) if (patched[i].vtable == vtable) return &patched[i];
return nullptr;
}
/// <summary>Ersetzt Eintraege einer vtable (liegt schreibgeschuetzt in dinput8.dll) und merkt sich die Originale.</summary>
static Patched* Patch(void* object, const int* slots, void* const* hooks, int n)
{
if (Patched* p = Find(object)) return p;
if (patchedCount == 16) return nullptr;
Patched* p = &patched[patchedCount++];
p->vtable = *(void***)object;
for (int i = 0; i < n; i++)
{
void** entry = &p->vtable[slots[i]];
p->original[i] = *entry;
DWORD old;
VirtualProtect(entry, sizeof(void*), PAGE_READWRITE, &old);
*entry = hooks[i];
VirtualProtect(entry, sizeof(void*), old, &old);
}
return p;
}
static void* keyboard;
static DWORD mainThread; // Thread, der Game.vPlugin (und damit uns) geladen hat
static int frameSlot; // 0 = noch offen, sonst SlotGetDeviceState/SlotGetDeviceData - nur einer zaehlt
static DWORD statStart, statMain, statOther;
static bool statDone;
// Das Spiel fragt die Tastatur mehrmals pro Bild ab (1. Lauf: ~7400/s). Die Mod erkennt ein neues Bild selbst an der
// Engine-Uhr - hier wird nur sichergestellt, dass sie ausschliesslich im Hauptthread laeuft.
static void Frame(void* device, int slot)
{
if (device != keyboard) return;
if (!frameSlot)
{
frameSlot = slot;
Log("Takt ueber Tastatur-%s", slot == SlotGetDeviceState ? "GetDeviceState" : "GetDeviceData");
statStart = GetTickCount();
}
if (slot != frameSlot) return;
bool main = GetCurrentThreadId() == mainThread;
if (!statDone)
{
if (main) statMain++; else statOther++;
if (GetTickCount() - statStart >= 5000)
{
statDone = true;
Log("Takt: in 5 s %lu Aufrufe im Hauptthread, %lu in anderen Threads", statMain, statOther);
}
}
if (main && onFrame) onFrame();
}
static HRESULT WINAPI HookGetDeviceState(void* self, DWORD size, void* data)
{
HRESULT hr = ((GetDeviceStateFn)Find(self)->original[0])(self, size, data);
Frame(self, SlotGetDeviceState);
return hr;
}
static HRESULT WINAPI HookGetDeviceData(void* self, DWORD size, void* data, DWORD* count, DWORD flags)
{
HRESULT hr = ((GetDeviceDataFn)Find(self)->original[1])(self, size, data, count, flags);
Frame(self, SlotGetDeviceData);
return hr;
}
static HRESULT WINAPI HookCreateDevice(void* self, REFGUID guid, void** device, void* outer)
{
HRESULT hr = ((CreateDeviceFn)Find(self)->original[0])(self, guid, device, outer);
if (SUCCEEDED(hr) && device && *device && IsEqualGUID(guid, GUID_SysKeyboard_))
{
keyboard = *device;
static const int slots[] = { SlotGetDeviceState, SlotGetDeviceData };
static void* const hooks[] = { (void*)HookGetDeviceState, (void*)HookGetDeviceData };
Patch(*device, slots, hooks, 2);
Log("Tastatur-Geraet erstellt");
}
return hr;
}
// ---------------------------------------------------------------------------------------------------------------
// Exporte der echten dinput8.dll
static FARPROC Real(const char* name)
{
if (!real)
{
wchar_t path[MAX_PATH];
GetSystemDirectoryW(path, MAX_PATH);
wcscat_s(path, L"\\dinput8.dll");
real = LoadLibraryW(path);
if (!real) Log("Echte dinput8.dll nicht ladbar (%lu)", GetLastError());
}
return real ? GetProcAddress(real, name) : nullptr;
}
extern "C" HRESULT WINAPI Proxy_DirectInput8Create(HINSTANCE inst, DWORD version, REFIID iid, void** out, void* outer)
{
typedef HRESULT (WINAPI* Fn)(HINSTANCE, DWORD, REFIID, void**, void*);
Fn fn = (Fn)Real("DirectInput8Create");
if (!fn) return E_FAIL;
HRESULT hr = fn(inst, version, iid, out, outer);
StartManaged();
if (SUCCEEDED(hr) && out && *out)
{
static const int slots[] = { SlotCreateDevice };
static void* const hooks[] = { (void*)HookCreateDevice };
Patch(*out, slots, hooks, 1);
}
return hr;
}
extern "C" HRESULT WINAPI Proxy_DllCanUnloadNow()
{
typedef HRESULT (WINAPI* Fn)();
Fn fn = (Fn)Real("DllCanUnloadNow");
return fn ? fn() : S_FALSE;
}
extern "C" HRESULT WINAPI Proxy_DllGetClassObject(REFCLSID clsid, REFIID iid, void** out)
{
typedef HRESULT (WINAPI* Fn)(REFCLSID, REFIID, void**);
Fn fn = (Fn)Real("DllGetClassObject");
return fn ? fn(clsid, iid, out) : CLASS_E_CLASSNOTAVAILABLE;
}
extern "C" HRESULT WINAPI Proxy_DllRegisterServer()
{
typedef HRESULT (WINAPI* Fn)();
Fn fn = (Fn)Real("DllRegisterServer");
return fn ? fn() : E_FAIL;
}
extern "C" HRESULT WINAPI Proxy_DllUnregisterServer()
{
typedef HRESULT (WINAPI* Fn)();
Fn fn = (Fn)Real("DllUnregisterServer");
return fn ? fn() : E_FAIL;
}
extern "C" const void* WINAPI Proxy_GetdfDIJoystick()
{
typedef const void* (WINAPI* Fn)();
Fn fn = (Fn)Real("GetdfDIJoystick");
return fn ? fn() : nullptr;
}
BOOL WINAPI DllMain(HINSTANCE module, DWORD reason, void*)
{
if (reason == DLL_PROCESS_ATTACH)
{
// Unter dem Loader-Lock nur Pfade merken - .NET startet erst im ersten DirectInput8Create
self = module;
mainThread = GetCurrentThreadId();
DisableThreadLibraryCalls(module);
InitializeCriticalSection(&logLock);
wchar_t path[MAX_PATH];
GetModuleFileNameW(module, path, MAX_PATH);
gameDir = path;
gameDir = gameDir.substr(0, gameDir.find_last_of(L'\\'));
bsoDir = gameDir + L"\\BSO";
Log("BSO Loader geladen aus %ls", path);
}
return TRUE;
}
+8
View File
@@ -0,0 +1,8 @@
LIBRARY dinput8
EXPORTS
DirectInput8Create=Proxy_DirectInput8Create @1
DllCanUnloadNow=Proxy_DllCanUnloadNow PRIVATE
DllGetClassObject=Proxy_DllGetClassObject PRIVATE
DllRegisterServer=Proxy_DllRegisterServer PRIVATE
DllUnregisterServer=Proxy_DllUnregisterServer PRIVATE
GetdfDIJoystick=Proxy_GetdfDIJoystick
+140
View File
@@ -0,0 +1,140 @@
using System.Diagnostics;
using BSO.Net;
namespace BSO
{
/// <summary>
/// Der eigene Spieler: Kamera, Spielfigur (KeyControlledCharacter) und eigener Bus (CarVehicle). Im Bus verschwindet
/// die Figur aus der Szene - nur dann wird der eigene Bus festgelegt (das CarVehicle naechst der Kamera, im Cockpit
/// ~5 m) und bleibt es, bis er verschwindet oder man in einem anderen Bus sitzt. Sonst springt die Zuordnung zu
/// vorbeifahrendem Verkehr. Die Engine rechnet in cm.
/// </summary>
public static class LocalPlayer
{
const float RelockDistance = 3000; // cm: so weit weg von der Kamera, obwohl man faehrt -> anderer Bus
/// <summary>Ein verfolgtes Objekt: Platz in der Entity-Tabelle + Zeiger (zusammen = lebt noch).</summary>
public class Tracked
{
public int Slot = -1;
public nint Ptr;
public Vec3 Pos, Rot;
public bool Valid => Ptr != 0;
public void Set(EntityInfo? e)
{
Ptr = e?.Ptr ?? 0;
Slot = e?.Slot ?? -1;
}
public void Refresh()
{
if (Ptr != 0 && !Entities.StillThere(Slot, Ptr)) Ptr = 0;
if (Ptr == 0) return;
Pos = Vision.GetPosition(Ptr);
Rot = Vision.GetOrientation(Ptr);
}
}
public static readonly Tracked Bus = new(), Character = new();
public static bool InScene { get; private set; }
public static Vec3 Camera { get; private set; }
/// <summary>cm pro Sekunde (aus der Bewegung des Busses bzw. der Figur).</summary>
public static float Speed { get; private set; }
public static PlayerMode Mode => !InScene ? PlayerMode.Menu : Character.Valid ? PlayerMode.OnFoot : Bus.Valid ? PlayerMode.InBus : PlayerMode.Menu;
/// <summary>Was an die anderen geht: Position/Drehung des Busses im Bus, sonst der Figur.</summary>
public static PlayerState State
{
get
{
PlayerMode mode = Mode;
Tracked t = mode == PlayerMode.InBus ? Bus : Character;
if (mode == PlayerMode.Menu) return new PlayerState { Mode = mode };
return new PlayerState
{
Mode = mode,
X = t.Pos.X, Y = t.Pos.Y, Z = t.Pos.Z,
Yaw = t.Rot.X, Pitch = t.Rot.Y, Roll = t.Rot.Z,
Speed = Speed
};
}
}
static bool sceneLogged;
static readonly Stopwatch speedClock = new(), findClock = new();
static Vec3 lastPos;
static PlayerMode lastMode;
public static void Update()
{
nint cam = Vision.MainCamera;
InScene = cam != 0 && Rtti.ClassName(cam) != null; // sonst Menue / Laden
if (!InScene) return;
Camera = Vision.GetPosition(cam);
if (!sceneLogged)
{
sceneLogged = true;
Bootstrap.Log("Szene da, Kamera bei " + Camera);
}
Bus.Refresh();
Character.Refresh();
// Alle Entities durchsuchen kostet - 4x pro Sekunde reicht
if (!findClock.IsRunning || findClock.ElapsedMilliseconds >= 250)
{
findClock.Restart();
Find();
}
UpdateSpeed();
}
static void Find()
{
EntityInfo? nearestCar = null, character = null;
float bestDist = float.MaxValue;
foreach (EntityInfo e in Entities.Scan())
{
if (e.Class == "KeyControlledCharacter") character = e;
if (e.Class != "CarVehicle") continue;
float d = (e.Pos - Camera).Length;
if (d < bestDist) { bestDist = d; nearestCar = e; }
}
if ((character?.Ptr ?? 0) != Character.Ptr)
{
Bootstrap.Log(character != null ? $"Spieler = KeyControlledCharacter in Platz {character.Value.Slot}" : "Spielfigur weg - Spieler sitzt im Bus");
Character.Set(character);
Character.Refresh();
}
// Bus nur festlegen, wenn man drinsitzt (keine Spielfigur), und nur wechseln, wenn der alte fehlt oder weit weg ist
bool driving = character == null;
bool keep = Bus.Valid && (!driving || (Bus.Pos - Camera).Length < RelockDistance);
if (keep || !driving || nearestCar == null || nearestCar.Value.Ptr == Bus.Ptr) return;
Bootstrap.Log($"Eigener Bus = CarVehicle in Platz {nearestCar.Value.Slot}, {bestDist / 100:0.0} m von der Kamera");
Bus.Set(nearestCar);
Bus.Refresh();
}
static void UpdateSpeed()
{
PlayerMode mode = Mode;
Vec3 pos = mode == PlayerMode.InBus ? Bus.Pos : Character.Pos;
if (mode != lastMode || !speedClock.IsRunning)
{
lastMode = mode;
lastPos = pos;
speedClock.Restart();
Speed = 0;
return;
}
double seconds = speedClock.Elapsed.TotalSeconds;
if (seconds < 0.25) return;
Speed = (float)((pos - lastPos).Length / seconds);
lastPos = pos;
speedClock.Restart();
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Runtime.InteropServices;
namespace BSO
{
/// <summary>
/// Speicher des Spiels lesen. Vorher immer mit VirtualQuery pruefen: eine Zugriffsverletzung laesst sich in .NET nicht
/// abfangen und beendet das Spiel. Die Bereiche werden pro Bild zwischengespeichert (NewFrame leert den Cache).
/// </summary>
public static unsafe class Mem
{
[StructLayout(LayoutKind.Sequential)]
struct MemoryBasicInformation
{
public nint BaseAddress, AllocationBase;
public uint AllocationProtect;
public ushort PartitionId;
public nint RegionSize;
public uint State, Protect, Type;
}
[DllImport("kernel32.dll")]
static extern nint VirtualQuery(nint address, out MemoryBasicInformation info, nint length);
const uint MemCommit = 0x1000, MemImage = 0x1000000;
const uint Readable = 0x02 | 0x04 | 0x08 | 0x20 | 0x40 | 0x80; // R, RW, WC, XR, XRW, XWC
const uint Guard = 0x100;
struct Region { public nint Start, End; public bool Readable, Image; }
static readonly Region[] cache = new Region[16];
static int cacheCount, cacheNext;
public static void NewFrame() { cacheCount = 0; cacheNext = 0; }
static bool Query(nint address, out Region region)
{
for (int i = 0; i < cacheCount; i++)
if (address >= cache[i].Start && address < cache[i].End) { region = cache[i]; return true; }
region = default;
if (address < 0x10000) return false;
if (VirtualQuery(address, out MemoryBasicInformation info, sizeof(MemoryBasicInformation)) == 0) return false;
region.Start = info.BaseAddress;
region.End = info.BaseAddress + info.RegionSize;
region.Readable = info.State == MemCommit && (info.Protect & Readable) != 0 && (info.Protect & Guard) == 0;
region.Image = info.Type == MemImage;
cache[cacheNext] = region;
cacheNext = (cacheNext + 1) % cache.Length;
if (cacheCount < cache.Length) cacheCount++;
return true;
}
public static bool IsReadable(nint address, int size)
{
nint end = address + size;
while (address < end)
{
if (!Query(address, out Region r) || !r.Readable) return false;
address = r.End;
}
return true;
}
/// <summary>Liegt die Adresse in einer geladenen DLL/EXE (z.B. vtables, Typinfos)?</summary>
public static bool IsImage(nint address) => Query(address, out Region r) && r.Readable && r.Image;
public static bool TryReadPtr(nint address, out nint value)
{
value = 0;
if (!IsReadable(address, 8)) return false;
value = *(nint*)address;
return true;
}
public static string ReadAscii(nint address, int max)
{
var chars = new char[max];
int n = 0;
for (; n < max; n++)
{
if (!IsReadable(address + n, 1)) break;
byte b = *(byte*)(address + n);
if (b == 0) break;
chars[n] = (char)b;
}
return new string(chars, 0, n);
}
}
}
+204
View File
@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Sockets;
using System.Threading.Tasks;
using BSO.Net;
namespace BSO
{
/// <summary>
/// Verbindung zum BSO-Server (wie MSCO per IP): F8 verbindet/trennt, Server und Name stehen in BSO.json. Laeuft im
/// Hauptthread: Verbindungsaufbau wird nur abgefragt (nicht blockierend), empfangen wird ueber Connection.Poll().
/// Schickt den eigenen Zustand 10x pro Sekunde, alle 2 s einen Ping.
/// </summary>
public static class Multiplayer
{
public enum NetStatus { Offline, Connecting, Connected }
public class Remote
{
public int Id;
public string Name;
public PlayerState State;
public bool HasState;
}
const int ConnectTimeoutMs = 5000;
const int SendIntervalMs = 100, PingIntervalMs = 2000;
public static NetStatus Status { get; private set; }
/// <summary>Warum offline (letzter Fehler / Trennung) - fuer die Anzeige.</summary>
public static string LastError { get; private set; } = "";
public static string ServerName { get; private set; } = "";
public static int MyId { get; private set; }
public static int PingMs { get; private set; } = -1;
public static readonly Dictionary<int, Remote> Players = new();
static TcpClient tcp;
static Task connectTask;
static Connection conn;
static readonly Stopwatch connectClock = new(), sendClock = new(), pingClock = new();
static bool autoConnectDone;
public static void Update()
{
if (!autoConnectDone && LocalPlayer.InScene)
{
autoConnectDone = true;
if (Config.Current.AutoConnect) Connect();
}
if (Input.Pressed(Input.F8))
{
if (Status == NetStatus.Offline) Connect();
else Disconnect("Getrennt mit F8");
}
if (Status == NetStatus.Connecting) PollConnect();
if (Status != NetStatus.Connected) return;
foreach (Message m in conn.Poll())
{
try { Handle(m); }
catch (Exception e) { Bootstrap.Log($"Nachricht {m.Type} fehlerhaft: {e.Message}"); }
if (Status != NetStatus.Connected) return; // z.B. Reject
}
if (conn.IsClosed)
{
Disconnect(conn.CloseReason);
return;
}
if (sendClock.ElapsedMilliseconds >= SendIntervalMs)
{
sendClock.Restart();
PlayerState s = LocalPlayer.State;
conn.Send(MsgType.PlayerState, Wire.Build(w => s.Write(w)));
}
if (pingClock.ElapsedMilliseconds >= PingIntervalMs)
{
pingClock.Restart();
conn.Send(MsgType.Ping, Wire.Build(w => w.Write(Stopwatch.GetTimestamp())));
}
}
public static string ServerAddress => Config.Current.Server;
static void Connect()
{
(string host, int port) = ParseAddress(ServerAddress);
Bootstrap.Log($"Verbinde mit {host}:{port} ...");
LastError = "";
tcp = new TcpClient();
connectTask = tcp.ConnectAsync(host, port);
connectClock.Restart();
Status = NetStatus.Connecting;
}
static void PollConnect()
{
if (!connectTask.IsCompleted)
{
if (connectClock.ElapsedMilliseconds > ConnectTimeoutMs) Fail("Server antwortet nicht");
return;
}
if (connectTask.IsFaulted || !tcp.Connected)
{
Fail(connectTask.Exception?.GetBaseException().Message ?? "Verbindung fehlgeschlagen");
return;
}
conn = new Connection(tcp);
conn.Send(MsgType.Hello, Wire.Build(w =>
{
w.Write(Protocol.Version);
w.Write(Bootstrap.ModVersion);
w.Write(Config.Current.Name ?? "");
}));
// Erst nach Welcome gilt man als verbunden - bis dahin bleibt der Status "Verbinde"
Status = NetStatus.Connected;
sendClock.Restart();
pingClock.Restart();
ServerName = "";
}
static void Fail(string reason)
{
try { tcp?.Close(); } catch { }
Status = NetStatus.Offline;
LastError = reason;
Bootstrap.Log("Verbindung fehlgeschlagen: " + reason);
}
static void Handle(Message m)
{
BinaryReader r = m.Reader();
switch (m.Type)
{
case MsgType.Welcome:
MyId = r.ReadInt32();
ServerName = r.ReadString();
Players.Clear();
int n = r.ReadInt32();
for (int i = 0; i < n; i++)
{
var p = new Remote { Id = r.ReadInt32(), Name = r.ReadString() };
Players[p.Id] = p;
}
Bootstrap.Log($"Verbunden mit \"{ServerName}\" als #{MyId}, {n} andere Spieler");
break;
case MsgType.Reject:
Disconnect("Abgelehnt: " + r.ReadString());
break;
case MsgType.PlayerJoined:
var joined = new Remote { Id = r.ReadInt32(), Name = r.ReadString() };
Players[joined.Id] = joined;
Bootstrap.Log($"{joined.Name} ist beigetreten");
break;
case MsgType.PlayerLeft:
int left = r.ReadInt32();
if (Players.Remove(left, out Remote gone)) Bootstrap.Log($"{gone.Name} ist gegangen");
break;
case MsgType.PlayerState:
int id = r.ReadInt32();
if (!Players.TryGetValue(id, out Remote p2)) return; // PlayerJoined kommt immer vorher
p2.State = PlayerState.Read(r);
p2.HasState = true;
break;
case MsgType.Pong:
PingMs = (int)Stopwatch.GetElapsedTime(r.ReadInt64()).TotalMilliseconds;
break;
}
}
public static void Disconnect(string reason)
{
if (Status == NetStatus.Offline) return;
conn?.Close(reason);
conn = null;
try { tcp?.Close(); } catch { }
Status = NetStatus.Offline;
LastError = reason;
Players.Clear();
PingMs = -1;
Bootstrap.Log("Getrennt: " + reason);
}
/// <summary>Ist der Server schon angekommen (Welcome)?</summary>
public static bool Welcomed => Status == NetStatus.Connected && ServerName.Length > 0;
static (string, int) ParseAddress(string address)
{
address = (address ?? "").Trim();
int colon = address.LastIndexOf(':');
if (colon > 0 && int.TryParse(address.Substring(colon + 1), out int port)) return (address.Substring(0, colon), port);
return (address.Length > 0 ? address : "127.0.0.1", Protocol.DefaultPort);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using BSO.Net;
namespace BSO
{
/// <summary>
/// Anzeige oben links: eigene Position (LocalPlayer) und Online-Status mit allen Mitspielern (Multiplayer).
/// F9 blendet ein/aus, F10 schreibt alle Entities in eine Datei.
///
/// Update rechnet (wenn die Engine-Uhr weiterlaeuft), Draw gibt den fertigen Text bei jedem Durchlauf aus - die
/// Engine zeigt Vision::Message-Text nur fuer einen Durchlauf, sonst flackert die Anzeige.
/// </summary>
public static class PositionHud
{
const int X = 10, Y = 10, Line = 18;
static bool visible = true, rateLogged;
static int frame;
static string status = "";
static byte[][] lines = Array.Empty<byte[]>();
static readonly Stopwatch rateClock = Stopwatch.StartNew();
static int draws;
public static void Update()
{
frame++;
if (Input.Pressed(Input.F9)) visible = !visible;
if (!LocalPlayer.InScene) { lines = Array.Empty<byte[]>(); return; } // Menue / Laden
if (Input.Pressed(Input.F10))
{
Entities.Dump(LocalPlayer.Camera);
status = "Entities gespeichert: BSO\\entities.txt";
}
// Text nur alle paar Durchlaeufe neu bauen - Draw gibt ihn dazwischen unveraendert aus
if (frame % 5 != 0 && lines.Length > 0) return;
PlayerState me = LocalPlayer.State;
var text = new List<string>
{
$"BSO {Bootstrap.ModVersion} [F8] Online [F9] Anzeige [F10] Entities [F11] Vollbild/Fenster",
me.Mode switch
{
PlayerMode.InBus => $"Du: im Bus {LocalPlayer.Bus.Pos} {LocalPlayer.Speed * 0.036f:0} km/h",
PlayerMode.OnFoot => $"Du: zu Fuss {LocalPlayer.Character.Pos}" + (LocalPlayer.Bus.Valid ? $" Bus {Meters(LocalPlayer.Bus.Pos - LocalPlayer.Character.Pos)} entfernt" : ""),
_ => "Du: -"
},
"",
};
AddOnline(text, me);
if (status.Length > 0) text.Add(status);
lines = text.ConvertAll(Vision.ToAscii).ToArray();
}
static void AddOnline(List<string> text, PlayerState me)
{
switch (Multiplayer.Status)
{
case Multiplayer.NetStatus.Offline:
text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerAddress}");
if (Multiplayer.LastError.Length > 0) text.Add(" " + Multiplayer.LastError);
return;
case Multiplayer.NetStatus.Connecting:
text.Add($"Verbinde mit {Multiplayer.ServerAddress} ...");
return;
}
if (!Multiplayer.Welcomed)
{
text.Add($"Verbinde mit {Multiplayer.ServerAddress} ... warte auf Server");
return;
}
string ping = Multiplayer.PingMs >= 0 ? $"{Multiplayer.PingMs} ms" : "...";
text.Add($"Online: \"{Multiplayer.ServerName}\" ({ping}) - {Multiplayer.Players.Count + 1} Spieler [F8] trennen");
foreach (Multiplayer.Remote p in Multiplayer.Players.Values.OrderBy(p => p.HasState ? p.State.DistanceTo(me) : float.MaxValue))
{
if (!p.HasState || p.State.Mode == PlayerMode.Menu) { text.Add($" {p.Name,-16} im Menue"); continue; }
string mode = p.State.Mode == PlayerMode.InBus ? "Bus " : "zu Fuss ";
string dist = me.Mode == PlayerMode.Menu ? "" : $"{p.State.DistanceTo(me) / 100,6:0} m";
text.Add($" {p.Name,-16} {mode} {dist} {p.State.Speed * 0.036f,3:0} km/h");
}
}
static string Meters(Vec3 d) => $"{d.Length / 100:0} m";
public static void Draw()
{
CountDraws();
if (!visible) return;
byte[][] current = lines;
for (int i = 0; i < current.Length; i++) Vision.Print(X, Y + i * Line, current[i]);
}
static void CountDraws()
{
draws++;
if (rateLogged || rateClock.ElapsedMilliseconds < 5000) return;
if (!LocalPlayer.InScene) { draws = 0; rateClock.Restart(); return; } // erst im Spiel messen
rateLogged = true;
Bootstrap.Log($"Anzeige: {draws / 5} Durchlaeufe pro Sekunde");
}
}
}
+86
View File
@@ -0,0 +1,86 @@
# BSO – Bus Simulator Online
Multiplayer-Mod für **Bus-Simulator 2012** (European Bus Simulator 2012, TML-Studios), aufgebaut wie MSCO:
eigenständiger Loader im Spielordner, die Mod selbst in C# (.NET 10).
- Mod: dieses Repository
- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server)
- Launcher (installiert und aktualisiert die Mod): [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher)
- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website)
**Stand:** Spieler verbinden sich per IP mit einem Server und sehen die anderen als Liste (Name, zu Fuß/Bus,
Entfernung, Tempo). Als Nächstes kommen sichtbare Busse der Mitspieler in der Welt.
## Wie es funktioniert
| Teil | Was | MSCO-Gegenstück |
|---|---|---|
| `Loader\dinput8.cpp` | Proxy-DLL `dinput8.dll` im Spielordner: reicht alles an die echte DirectInput-DLL weiter, startet .NET und ruft die Mod jedes Bild auf | Unity Doorstop (`winhttp.dll`) |
| `Bootstrap.cs` | Einstieg (`Start`, `OnFrame`), Protokoll | `Bootstrap.cs` |
| `Vision.cs` | Aufrufe in die Havok Vision Engine (`Vision90.dll` exportiert ihre Funktionen mit Namen) | UnityEngine-Referenz |
| `Rtti.cs`, `Entities.cs` | Spielobjekte und ihre Klassennamen (`CarVehicle`, `KeyControlledCharacter` …) finden | – |
| `LocalPlayer.cs` | eigene Figur und eigener Bus, daraus der `PlayerState` | – |
| `Multiplayer.cs` | Verbindung zum Server, Mitspieler | `MultiplayerManager.cs` |
| `Config.cs` | `BSO.json` (Name, Server, AutoConnect) | – |
| `PositionHud.cs` | Anzeige im Spiel | `Overlays.cs` |
| `BorderlessWindow.cs` | Vollbild im Fenster (F11) | – |
| `Shared\*.cs` | Protokoll, `PacketReader`, `Connection`/`FrameParser` – **gleiche Dateien wie in BSO-Server** | `NetSession.cs` |
- **Bild-Takt:** Das Spiel fragt die Tastatur mehrmals pro Bild über DirectInput ab. Der Loader hängt sich dort ein
und ruft `Bootstrap.OnFrame` im Hauptthread auf. Gerechnet wird, wenn die Engine-Uhr weiterläuft; Text wird bei
jedem Aufruf ausgegeben (sonst flackert er). Spielcode wird nicht verändert.
- **Speicher lesen:** `Mem` prüft jede Adresse vorher, denn ein falscher Zeiger würde das Spiel abstürzen lassen.
- **Netzwerk** wie MSCO: `[int Länge][byte Typ][Daten]` über TCP, Lesen per `Poll()` im Hauptthread, Senden über
eigenen Thread. Jeder Spieler schickt 10× pro Sekunde seinen Zustand, der Server verteilt ihn.
- Nur die **64-Bit-Version** (`Bin_High_Win64`). Steam startet sie auf 64-Bit-Windows automatisch.
- Die Engine rechnet in **cm**. Der eigene Bus ist ein `CarVehicle` (Ursprung ~5 m hinter dem Fahrersitz). Solange
man im Bus sitzt, ist die Spielfigur (`KeyControlledCharacter`) aus der Szene verschwunden.
## Installation
**Mit dem Launcher (empfohlen):** [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher) installiert und
aktualisiert BSO und startet das Spiel.
**Von Hand:** `dinput8.dll` nach `<Spielordner>\Bin_High_Win64`, `BSO.dll`, `BSO.runtimeconfig.json` und
`BSO.deps.json` nach `Bin_High_Win64\BSO`. Braucht die **.NET 10 Runtime (x64)**.
## Bauen
Voraussetzungen: Visual Studio mit C++, .NET 10 SDK.
```powershell
.\build.ps1 # -> dist\dinput8.dll, dist\BSO\, dist\BSO-Mod.zip
.\install.ps1 # -> F:\SteamLibrary\steamapps\common\Bus-Simulator 2012\Bin_High_Win64
.\install.ps1 -Uninstall
.\release.ps1 -Product mod -Version 0.2.0 -File dist\BSO-Mod.zip -Changelog "..."
```
Test ohne Spiel: `Loader\ProxyTest.cpp` lädt die Proxy-DLL wie das Spiel (Bauen siehe Kopf der Datei).
## Im Spiel
| Taste | |
|---|---|
| **F8** | mit dem Server verbinden / trennen |
| **F9** | Anzeige ein/aus |
| **F10** | alle Objekte der Szene nach `Bin_High_Win64\BSO\entities.txt` (Diagnose) |
| **F11** | Vollbild im Fenster ↔ normales Fenster (nur wenn im Spiel „Vollbild“ aus ist) |
Einstellungen in `Bin_High_Win64\BSO\BSO.json` (der Launcher schreibt Name und Server hinein):
```json
{
"Name": "MrMaho",
"Server": "127.0.0.1:7777",
"AutoConnect": false
}
```
Protokoll: `Bin_High_Win64\BSO\BSO_Loader.txt`
## Nächste Schritte
1. ~~Mod lädt, Busposition anzeigen~~
2. ~~Netzwerk: Server + Positionen austauschen, Mitspieler als Text~~
3. Mitspieler-Busse in der Welt (`VisGame_cl::CreateEntity`), Namensschild mit `VisMessage_cl::DrawMessage3D`
4. Chat, Konsole, Steam-P2P, Serverliste
+47
View File
@@ -0,0 +1,47 @@
using System.Collections.Generic;
namespace BSO
{
/// <summary>
/// Klassenname eines C++-Objekts aus den MSVC-Typinfos (vtable[-1] -> CompleteObjectLocator -> TypeDescriptor).
/// So erkennen wir Spielobjekte (BaseVehicle, CarVehicle ...), ohne ihren Aufbau zu kennen.
/// </summary>
public static unsafe class Rtti
{
static readonly Dictionary<nint, string> byVtable = new();
/// <summary>Klassenname oder null, wenn obj kein gueltiges C++-Objekt ist.</summary>
public static string ClassName(nint obj)
{
if (!Mem.TryReadPtr(obj, out nint vtable) || !Mem.IsImage(vtable)) return null;
if (byVtable.TryGetValue(vtable, out string name)) return name;
name = Resolve(vtable);
byVtable[vtable] = name;
return name;
}
static string Resolve(nint vtable)
{
if (!Mem.TryReadPtr(vtable - 8, out nint col) || !Mem.IsImage(col) || !Mem.IsReadable(col, 24)) return null;
// x64: Signatur 1, Adressen als RVA relativ zum Modul; pSelf (RVA des Locators selbst) liefert die Modulbasis
if (*(int*)col != 1) return null;
uint typeRva = *(uint*)(col + 12);
uint selfRva = *(uint*)(col + 20);
nint typeDescriptor = col - (nint)selfRva + (nint)typeRva;
string raw = Mem.ReadAscii(typeDescriptor + 16, 256); // ".?AVBaseVehicle@@"
return Demangle(raw);
}
/// <summary>".?AVFoo@Bar@@" -> "Bar::Foo".</summary>
static string Demangle(string raw)
{
if (raw.Length < 6 || !raw.StartsWith(".?A")) return null;
string body = raw.Substring(4);
if (body.EndsWith("@@")) body = body.Substring(0, body.Length - 2);
if (body.Contains('?')) return body; // Templates usw. roh lassen
string[] parts = body.Split('@');
System.Array.Reverse(parts);
return string.Join("::", parts);
}
}
}
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Sockets;
using System.Threading;
namespace BSO.Net
{
/// <summary>Setzt aus einem Byte-Strom Nachrichten zusammen: [int Laenge][byte Typ][Payload].</summary>
public class FrameParser
{
const int MaxFrame = 1024 * 1024;
byte[] buffer = new byte[64 * 1024];
int count;
public void Append(byte[] data, int length)
{
EnsureCapacity(count + length);
Buffer.BlockCopy(data, 0, buffer, count, length);
count += length;
}
public void Parse(List<Message> messages)
{
int offset = 0;
while (count - offset >= 5)
{
int length = BitConverter.ToInt32(buffer, offset);
if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
if (count - offset - 4 < length) break;
var payload = new byte[length - 1];
Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
offset += 4 + length;
}
if (offset > 0)
{
Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset);
count -= offset;
}
}
public static byte[] Frame(MsgType type, byte[] payload)
{
payload ??= Array.Empty<byte>();
var frame = new byte[5 + payload.Length];
int length = payload.Length + 1; // Little Endian wie BitConverter
frame[0] = (byte)length; frame[1] = (byte)(length >> 8); frame[2] = (byte)(length >> 16); frame[3] = (byte)(length >> 24);
frame[4] = (byte)type;
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
return frame;
}
void EnsureCapacity(int needed)
{
if (needed <= buffer.Length) return;
var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
Buffer.BlockCopy(buffer, 0, bigger, 0, count);
buffer = bigger;
}
}
/// <summary>
/// TCP-Verbindung wie bei MSCO: Lesen ist nicht-blockierend und wird ueber Poll() abgefragt (im Spiel im Hauptthread),
/// Senden laeuft ueber eine Warteschlange und einen eigenen Thread - eine kurz volle Leitung haelt das Spiel nicht an.
/// Kleine Pakete werden dabei zusammengefasst.
/// </summary>
public class Connection
{
volatile bool closed;
volatile string closeReason;
public bool IsClosed => closed;
public string CloseReason => closeReason;
// Mehr als das unterwegs = die Gegenseite nimmt nichts mehr an
const long MaxQueuedBytes = 16L * 1024 * 1024;
const int MaxBatchBytes = 256 * 1024;
readonly TcpClient client;
readonly NetworkStream stream;
readonly FrameParser parser = new();
readonly byte[] readBuffer = new byte[64 * 1024];
readonly Queue<byte[]> outgoing = new();
readonly object gate = new();
long queuedBytes;
readonly Thread writer;
public Connection(TcpClient client)
{
this.client = client;
client.NoDelay = true;
stream = client.GetStream();
writer = new Thread(WriteLoop) { IsBackground = true, Name = "BSO send" };
writer.Start();
}
/// <summary>Sende-Thread: wartet auf Pakete und schreibt sie (gebuendelt) in die Verbindung.</summary>
void WriteLoop()
{
var batch = new MemoryStream(MaxBatchBytes);
try
{
while (true)
{
batch.SetLength(0);
lock (gate)
{
while (outgoing.Count == 0 && !closed) Monitor.Wait(gate);
if (outgoing.Count == 0) return; // geschlossen und nichts mehr zu senden
do
{
byte[] frame = outgoing.Dequeue();
queuedBytes -= frame.Length;
batch.Write(frame, 0, frame.Length);
} while (outgoing.Count > 0 && batch.Length + outgoing.Peek().Length <= MaxBatchBytes);
Monitor.PulseAll(gate); // Close() wartet evtl. aufs Leerwerden
}
stream.Write(batch.GetBuffer(), 0, (int)batch.Length);
}
}
catch (Exception e)
{
if (!closed) Close("Netzwerkfehler: " + e.Message);
}
}
readonly List<Message> polled = new(); // wiederverwendet: der Aufrufer geht sie sofort durch
public List<Message> Poll()
{
var messages = polled;
messages.Clear();
if (IsClosed) return messages;
try
{
// Verbindung von der Gegenseite geschlossen?
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
{
Close("Verbindung getrennt");
return messages;
}
while (stream.DataAvailable)
{
int read = stream.Read(readBuffer, 0, readBuffer.Length);
if (read <= 0) break;
parser.Append(readBuffer, read);
}
parser.Parse(messages);
}
catch (Exception e)
{
Close("Netzwerkfehler: " + e.Message);
}
return messages;
}
public void Send(MsgType type, byte[] payload)
{
if (closed) return;
byte[] frame = FrameParser.Frame(type, payload);
bool tooMuch;
lock (gate)
{
tooMuch = queuedBytes + frame.Length > MaxQueuedBytes;
if (!tooMuch)
{
outgoing.Enqueue(frame);
queuedBytes += frame.Length;
Monitor.Pulse(gate);
}
}
if (tooMuch) Close("Verbindung zu langsam - zu viele Daten warten aufs Senden");
}
public void Close(string reason)
{
lock (gate)
{
if (closed) return;
// Was schon in der Warteschlange liegt (z.B. eine Abschiedsnachricht), kurz noch rausgehen lassen
var until = DateTime.UtcNow.AddMilliseconds(300);
while (outgoing.Count > 0 && DateTime.UtcNow < until && Thread.CurrentThread != writer)
Monitor.Wait(gate, 50);
closed = true;
closeReason = reason;
outgoing.Clear();
queuedBytes = 0;
Monitor.PulseAll(gate);
}
try { client.Close(); } catch { }
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.IO;
namespace BSO.Net
{
/// <summary>
/// BinaryReader fuer Daten aus dem Netz (wie bei MSCO): jede Laengenangabe wird gegen die tatsaechlich vorhandenen
/// Bytes geprueft - eine gefaelschte Laenge darf nicht zu einer riesigen Speicheranforderung fuehren.
/// </summary>
public sealed class PacketReader : BinaryReader
{
public PacketReader(byte[] data) : base(new MemoryStream(data ?? new byte[0], false)) { }
long Remaining => BaseStream.Length - BaseStream.Position;
public override byte[] ReadBytes(int count)
{
if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count);
return base.ReadBytes(count);
}
public override char[] ReadChars(int count)
{
if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count);
return base.ReadChars(count);
}
public override string ReadString()
{
long start = BaseStream.Position;
int length = Read7BitEncodedInt();
if (length < 0 || length > Remaining) throw new EndOfStreamException("Invalid string length " + length);
BaseStream.Position = start;
return base.ReadString();
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.IO;
namespace BSO.Net
{
/// <summary>
/// Netzwerkprotokoll von Mod und Server - beide binden diese Dateien ein (Shared\*.cs), es gibt also nur eine
/// Fassung. Aufbau wie bei MSCO: [int Laenge][byte Typ][Daten], Daten mit BinaryWriter, Positionen in cm (Engine).
/// </summary>
public static class Protocol
{
/// <summary>Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab.</summary>
public const int Version = 1;
public const int DefaultPort = 7777;
}
public enum MsgType : byte
{
Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name
Welcome = 2, // Server -> Client: int eigene ID, string Servername, int n, je [int ID, string Name]
Reject = 3, // Server -> Client: string Grund
PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState
PlayerJoined = 5, // Server -> Client: int ID, string Name
PlayerLeft = 6, // Server -> Client: int ID
Ping = 7, // long Zeitstempel (Stopwatch-Ticks des Senders)
Pong = 8, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen)
Info = 9, // Abfrage ohne Hello (spaeter Server-Browser, jetzt --probe)
InfoReply = 10 // string Servername, string Version, int Spieler, int max. Spieler
}
public enum PlayerMode : byte
{
Menu = 0, // noch keine Szene / Ladebildschirm
OnFoot = 1, // Spielfigur (KeyControlledCharacter)
InBus = 2 // sitzt im eigenen Bus (CarVehicle)
}
public struct Message
{
public MsgType Type;
public byte[] Payload;
public BinaryReader Reader() => new PacketReader(Payload);
}
/// <summary>Was jeder Spieler ~10x pro Sekunde von sich schickt. Drehung wie die Engine: Grad (Gier, Nick, Roll).</summary>
public struct PlayerState
{
public PlayerMode Mode;
public float X, Y, Z;
public float Yaw, Pitch, Roll;
public float Speed; // cm/s
public void Write(BinaryWriter w)
{
w.Write((byte)Mode);
w.Write(X); w.Write(Y); w.Write(Z);
w.Write(Yaw); w.Write(Pitch); w.Write(Roll);
w.Write(Speed);
}
public static PlayerState Read(BinaryReader r) => new PlayerState
{
Mode = (PlayerMode)r.ReadByte(),
X = r.ReadSingle(), Y = r.ReadSingle(), Z = r.ReadSingle(),
Yaw = r.ReadSingle(), Pitch = r.ReadSingle(), Roll = r.ReadSingle(),
Speed = r.ReadSingle()
};
public float DistanceTo(PlayerState o)
{
float dx = X - o.X, dy = Y - o.Y, dz = Z - o.Z;
return MathF.Sqrt(dx * dx + dy * dy + dz * dz);
}
}
public static class Wire
{
public static byte[] Build(Action<BinaryWriter> write)
{
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms);
write(w);
w.Flush();
return ms.ToArray();
}
}
}
+157
View File
@@ -0,0 +1,157 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace BSO
{
public struct Vec3
{
public float X, Y, Z;
public Vec3(float x, float y, float z) { X = x; Y = y; Z = z; }
public static Vec3 operator -(Vec3 a, Vec3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
public float Length => MathF.Sqrt(X * X + Y * Y + Z * Z);
public override string ToString() => $"{X:0.0} / {Y:0.0} / {Z:0.0}";
}
/// <summary>
/// Zugriff auf die Havok Vision Engine (Vision90.dll). Die Engine exportiert ihre C++-Funktionen und globalen Objekte
/// mit Namen - die Adressen holen wir ueber die dekorierten Namen, aufgerufen wird ueber Funktionszeiger
/// (x64: "this" ist einfach das erste Argument).
/// </summary>
public static unsafe class Vision
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
static extern nint GetModuleHandleW(string name);
static bool tried;
public static bool Loaded { get; private set; }
static nint message; // Vision::Message (VisMessage_cl)
static nint camera; // Vision::Camera (VisCamera_cl)
static nint entityTable; // VisElementManager_cl<VisBaseEntity_cl*>::elementTable (DynArray_cl)
static delegate* unmanaged<nint, int, int, int, byte*, byte*, void> print;
static delegate* unmanaged<nint, nint> getMainCamera;
static delegate* unmanaged<nint, float*, float*, float*, void> getPosition;
static delegate* unmanaged<nint, float*, float*, float*, void> getOrientation;
static delegate* unmanaged<nint> getTimer;
static delegate* unmanaged<nint, float> getTime;
public static bool Init()
{
if (tried) return Loaded;
tried = true;
nint module = GetModuleHandleW("Vision90.dll");
if (module == 0) { Bootstrap.Log("Vision90.dll ist nicht geladen"); return false; }
message = Export(module, "?Message@Vision@@2VVisMessage_cl@@A");
camera = Export(module, "?Camera@Vision@@2VVisCamera_cl@@A");
entityTable = Export(module, "?elementTable@?$VisElementManager_cl@PEAVVisBaseEntity_cl@@@@0V?$DynArray_cl@PEAVVisBaseEntity_cl@@@@A");
// void VisMessage_cl::Print(int, int x, int y, const char* format, ...) - variadisch, auf x64 normal aufrufbar
print = (delegate* unmanaged<nint, int, int, int, byte*, byte*, void>)Export(module, "?Print@VisMessage_cl@@QEAAXHHHPEBDZZ");
getMainCamera = (delegate* unmanaged<nint, nint>)Export(module, "?GetMainCamera@VisCamera_cl@@QEBAPEAVVisContextCamera_cl@@XZ");
getPosition = (delegate* unmanaged<nint, float*, float*, float*, void>)Export(module, "?GetPosition@VisObject3D_cl@@QEBAXAEAM00@Z");
getOrientation = (delegate* unmanaged<nint, float*, float*, float*, void>)Export(module, "?GetOrientation@VisObject3D_cl@@QEBAXAEAM00@Z");
getTimer = (delegate* unmanaged<nint>)Export(module, "?GetTimer@Vision@@SAPEAVIVTimer@@XZ");
getTime = (delegate* unmanaged<nint, float>)Export(module, "?GetTime@IVTimer@@QEBAMXZ");
Loaded = message != 0 && camera != 0 && entityTable != 0 && print != null && getMainCamera != null && getPosition != null
&& getOrientation != null && getTimer != null && getTime != null;
Bootstrap.Log(Loaded ? "Vision Engine gefunden, alle Funktionen da" : "Vision Engine: Funktionen fehlen");
return Loaded;
}
static nint Export(nint module, string name)
{
if (NativeLibrary.TryGetExport(module, name, out nint address)) return address;
Bootstrap.Log("Export fehlt: " + name);
return 0;
}
static float lastTime = -1;
static long lastTick;
/// <summary>
/// Ist die Engine-Uhr (Vision::GetTimer()->GetTime()) weitergelaufen? Sie hat nur ~1 ms Aufloesung, die Spielschleife
/// laeuft im Menue aber ~9000-mal pro Sekunde - fuer Berechnungen reicht das, fuer die Textausgabe nicht (siehe
/// PositionHud.Draw). Steht die Uhr (Pause), zaehlt hoechstens alle 16 ms ein Aufruf.
/// </summary>
public static bool TimerAdvanced()
{
nint timer = getTimer();
float now = timer != 0 ? getTime(timer) : 0;
long tick = Environment.TickCount64;
if (now == lastTime && tick - lastTick < 16) return false;
lastTime = now;
lastTick = tick;
return true;
}
/// <summary>Text fuer dieses Bild auf den Bildschirm (Vision::Message.Print).</summary>
public static void Print(int x, int y, string text) => Print(x, y, ToAscii(text));
/// <summary>Wie Print(string), mit vorbereitetem Text (ToAscii) - fuer Ausgaben bei jedem Durchlauf.</summary>
public static void Print(int x, int y, byte[] text)
{
byte* format = stackalloc byte[] { (byte)'%', (byte)'s', 0 };
fixed (byte* p = text) print(message, 1, x, y, format, p);
}
public static byte[] ToAscii(string text) => Encoding.ASCII.GetBytes(text + "\0");
/// <summary>Hauptkamera (VisContextCamera_cl) oder 0, solange keine Szene geladen ist.</summary>
public static nint MainCamera => getMainCamera(camera);
/// <summary>Position eines VisObject3D_cl (Entity, Kamera). obj muss gueltig sein.</summary>
public static Vec3 GetPosition(nint obj)
{
float x, y, z;
getPosition(obj, &x, &y, &z);
return new Vec3(x, y, z);
}
/// <summary>Drehung eines VisObject3D_cl in Grad (Gier, Nick, Roll), wie SetOrientation sie nimmt.</summary>
public static Vec3 GetOrientation(nint obj)
{
float yaw, pitch, roll;
getOrientation(obj, &yaw, &pitch, &roll);
return new Vec3(yaw, pitch, roll);
}
static bool tableLogged;
/// <summary>
/// Liste aller Entities (Zeiger, freie Plaetze sind 0). Der Aufbau von DynArray_cl ist nicht dokumentiert -
/// wir probieren die ueblichen Anordnungen und nehmen die erste, deren Datenzeiger lesbar ist.
/// </summary>
public static bool TryGetEntities(out nint* data, out int count)
{
data = null;
count = 0;
nint* h = (nint*)entityTable;
if (!tableLogged)
{
tableLogged = true;
Bootstrap.Log($"Entity-Tabelle: {h[0]:X} {h[1]:X} {h[2]:X} {h[3]:X}");
}
// (Groesse, Daten): [size][data] / [vtable][size][data] / [data][size]
if (TryLayout(h[1], h[0], out data, out count)) return true;
if (Mem.IsImage(h[0]) && TryLayout(h[2], h[1], out data, out count)) return true;
return TryLayout(h[0], h[1], out data, out count);
}
static bool TryLayout(nint dataPtr, nint sizeField, out nint* data, out int count)
{
data = null;
count = 0;
uint size = (uint)sizeField;
if (size == 0 || size > 1 << 20 || dataPtr == 0 || Mem.IsImage(dataPtr)) return false;
if (!Mem.IsReadable(dataPtr, (int)size * 8)) return false;
data = (nint*)dataPtr;
count = (int)size;
return true;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Baut Loader (C++) und Mod (C#) nach dist\:
# dist\dinput8.dll -> in den Spielordner (Bin_High_Win64)
# dist\BSO\BSO.dll + *.json -> nach Bin_High_Win64\BSO
# dist\BSO-Mod.zip -> Release-Paket fuer den Launcher (release.ps1 -Product mod)
# Voraussetzungen: Visual Studio mit C++, .NET 10 SDK.
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
$dist = Join-Path $root "dist"
$obj = Join-Path $root "obj\loader"
New-Item -ItemType Directory -Force "$dist\BSO", $obj | Out-Null
# --- Loader -------------------------------------------------------------------------------------------------------
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -property installationPath
if (-not $vs) { throw "Visual Studio mit C++ nicht gefunden" }
$vcvars = Join-Path $vs "VC\Auxiliary\Build\vcvars64.bat"
$env:PATH = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer;$env:PATH" # vcvars64 ruft vswhere auf
$hostPack = Get-ChildItem "C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64" -Directory | Sort-Object { [version]$_.Name } | Select-Object -Last 1
$nethost = Join-Path $hostPack.FullName "runtimes\win-x64\native"
$src = Join-Path $root "Loader"
$cmd = "`"$vcvars`" >nul && cl /nologo /LD /O2 /MT /EHsc /W3 /DUNICODE /D_UNICODE /I`"$nethost`" " +
"/Fo`"$obj\\`" `"$src\dinput8.cpp`" /Fe`"$dist\dinput8.dll`" " +
"/link /DEF:`"$src\dinput8.def`" /IMPLIB:`"$obj\dinput8.lib`" `"$nethost\libnethost.lib`" advapi32.lib"
cmd /c $cmd
if ($LASTEXITCODE -ne 0) { throw "Loader-Build fehlgeschlagen" }
# --- Mod ----------------------------------------------------------------------------------------------------------
dotnet build (Join-Path $root "BSO.csproj") -c Release -o "$dist\BSO" --nologo -v q
if ($LASTEXITCODE -ne 0) { throw "Mod-Build fehlgeschlagen" }
# --- Release-Paket: wird vom Launcher nach Bin_High_Win64 entpackt (BSO.json gehoert dem Spieler, ist nicht dabei) --
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
$zip = Join-Path $dist "BSO-Mod.zip"
Remove-Item $zip -ErrorAction SilentlyContinue
$archive = [IO.Compression.ZipFile]::Open($zip, "Create")
try {
$files = [ordered]@{
"dinput8.dll" = "$dist\dinput8.dll"
"BSO/BSO.dll" = "$dist\BSO\BSO.dll"
"BSO/BSO.runtimeconfig.json" = "$dist\BSO\BSO.runtimeconfig.json"
"BSO/BSO.deps.json" = "$dist\BSO\BSO.deps.json"
}
foreach ($name in $files.Keys) { [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $files[$name], $name) }
}
finally { $archive.Dispose() }
Write-Host "Fertig: $dist"
+22
View File
@@ -0,0 +1,22 @@
# Kopiert dist\ in den Spielordner (64-Bit-Version). Entfernen: install.ps1 -Uninstall
param(
[string]$Game = "F:\SteamLibrary\steamapps\common\Bus-Simulator 2012",
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$bin = Join-Path $Game "Bin_High_Win64"
if (-not (Test-Path "$bin\BusSimulator2012.exe")) { throw "Spiel nicht gefunden: $bin" }
if ($Uninstall) {
Remove-Item "$bin\dinput8.dll" -ErrorAction SilentlyContinue
Remove-Item "$bin\BSO" -Recurse -ErrorAction SilentlyContinue
Write-Host "BSO entfernt aus $bin"
return
}
$dist = Join-Path $PSScriptRoot "dist"
if (-not (Test-Path "$dist\dinput8.dll")) { throw "Erst build.ps1 ausfuehren" }
New-Item -ItemType Directory -Force "$bin\BSO" | Out-Null
Copy-Item "$dist\dinput8.dll" $bin -Force
Copy-Item "$dist\BSO\BSO.dll", "$dist\BSO\BSO.runtimeconfig.json", "$dist\BSO\BSO.deps.json" "$bin\BSO" -Force
Write-Host "BSO installiert nach $bin"
+45
View File
@@ -0,0 +1,45 @@
# Signiert ein Release und laedt es auf den BSO Master (wie release.ps1 bei MSCO).
# .\release.ps1 -Product mod|launcher|server -Version 0.2.0 -File dist\BSO-Mod.zip [-Changelog "..."]
# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1), BSO_RELEASE_TOKEN (= RELEASE_TOKEN des Masters),
# optional BSO_MASTER_URL (Standard https://bso.mbm.wtf).
# Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten oeffentlichen Schluessel.
param(
[Parameter(Mandatory)][ValidateSet("mod", "launcher", "server")][string]$Product,
[Parameter(Mandatory)][string]$Version,
[Parameter(Mandatory)][string]$File,
[string]$Changelog = ""
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Security
Add-Type -AssemblyName System.Net.Http
$master = ($env:BSO_MASTER_URL, "https://bso.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/')
$token = $env:BSO_RELEASE_TOKEN
if (-not $token) { Write-Host "BSO_RELEASE_TOKEN nicht gesetzt - nicht hochgeladen"; return }
$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi"
if (-not (Test-Path $keyFile)) { throw "Kein Release-Schluessel ($keyFile) - erst new-release-key.ps1 im BSO-Launcher-Repo" }
$data = [IO.File]::ReadAllBytes((Resolve-Path $File))
$sha = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join ""
$private = [System.Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser)
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private))
$message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha")
$signature = [Convert]::ToBase64String($rsa.SignData($message, [System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1))
$http = New-Object System.Net.Http.HttpClient
$http.Timeout = [TimeSpan]::FromMinutes(10)
$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
$form = New-Object System.Net.Http.MultipartFormDataContent
foreach ($pair in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) {
$form.Add((New-Object System.Net.Http.StringContent($pair[1])), "`"$($pair[0])`"")
}
$content = New-Object System.Net.Http.ByteArrayContent(, $data)
$form.Add($content, "`"file`"", "`"$(Split-Path $File -Leaf)`"")
$res = $http.PostAsync("$master/api/releases/upload", $form).Result
$body = $res.Content.ReadAsStringAsync().Result
if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$Product $Version gibt es schon auf $master"; return }
if (-not $res.IsSuccessStatusCode) { throw "Hochladen fehlgeschlagen ($([int]$res.StatusCode)): $body" }
Write-Host "$Product $Version hochgeladen nach $master (sha256 $sha)"