From 35f2d1330d384c9c8fb3a31fbe5a802b71e9dd4c Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 17:26:23 +0100 Subject: [PATCH] Erste Version Co-Authored-By: Claude Opus 5.5 --- .gitignore | 6 + BSO.csproj | 23 ++++ Bootstrap.cs | 81 +++++++++++ BorderlessWindow.cs | 126 +++++++++++++++++ Config.cs | 37 +++++ Entities.cs | 61 +++++++++ Input.cs | 38 +++++ Loader/ProxyTest.cpp | 36 +++++ Loader/dinput8.cpp | 305 +++++++++++++++++++++++++++++++++++++++++ Loader/dinput8.def | 8 ++ LocalPlayer.cs | 140 +++++++++++++++++++ Mem.cs | 88 ++++++++++++ Multiplayer.cs | 204 +++++++++++++++++++++++++++ PositionHud.cs | 106 ++++++++++++++ README.md | 86 ++++++++++++ Rtti.cs | 47 +++++++ Shared/Connection.cs | 194 ++++++++++++++++++++++++++ Shared/PacketReader.cs | 36 +++++ Shared/Protocol.cs | 88 ++++++++++++ Vision.cs | 157 +++++++++++++++++++++ build.ps1 | 47 +++++++ install.ps1 | 22 +++ release.ps1 | 45 ++++++ 23 files changed, 1981 insertions(+) create mode 100644 .gitignore create mode 100644 BSO.csproj create mode 100644 Bootstrap.cs create mode 100644 BorderlessWindow.cs create mode 100644 Config.cs create mode 100644 Entities.cs create mode 100644 Input.cs create mode 100644 Loader/ProxyTest.cpp create mode 100644 Loader/dinput8.cpp create mode 100644 Loader/dinput8.def create mode 100644 LocalPlayer.cs create mode 100644 Mem.cs create mode 100644 Multiplayer.cs create mode 100644 PositionHud.cs create mode 100644 README.md create mode 100644 Rtti.cs create mode 100644 Shared/Connection.cs create mode 100644 Shared/PacketReader.cs create mode 100644 Shared/Protocol.cs create mode 100644 Vision.cs create mode 100644 build.ps1 create mode 100644 install.ps1 create mode 100644 release.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08a4571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +dist/ +publish/ +.vs/ +*.user diff --git a/BSO.csproj b/BSO.csproj new file mode 100644 index 0000000..3d3e4fa --- /dev/null +++ b/BSO.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + latest + BSO + BSO + 0.2.0 + x64 + true + disable + + true + false + + + + + + + + + diff --git a/Bootstrap.cs b/Bootstrap.cs new file mode 100644 index 0000000..adf8996 --- /dev/null +++ b/Bootstrap.cs @@ -0,0 +1,81 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace BSO +{ + /// Was der Loader (dinput8.dll) mitgibt - Gegenstueck zu HostApi in BSO.Loader\dinput8.cpp. + [StructLayout(LayoutKind.Sequential)] + public unsafe struct HostApi + { + public delegate* unmanaged Log; + public char* GameDir; + public char* BsoDir; + } + + /// + /// Einstieg: der Loader startet .NET beim ersten DirectInput8Create und ruft Start auf, danach OnFrame einmal pro + /// Bild im Hauptthread des Spiels. Protokoll: <Spiel>\BSO\BSO_Loader.txt. + /// + public static unsafe class Bootstrap + { + public const string ModVersion = "0.2.0"; + + static HostApi* host; + static int frameErrors; + + /// Ordner von BSO.dll (<Spiel>\BSO). + public static string Folder { get; private set; } + /// Ordner der Spiel-EXE (Bin_High_Win64). + 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); + } + } +} diff --git a/BorderlessWindow.cs b/BorderlessWindow.cs new file mode 100644 index 0000000..b58d3b8 --- /dev/null +++ b/BorderlessWindow.cs @@ -0,0 +1,126 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Xml.Linq; + +namespace BSO +{ + /// + /// 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). + /// + 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(); + + /// Einmal pro Bild im Hauptthread (dem Thread des Fensters). + 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() }; + 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; + + /// Fullscreen-Einstellung des Spiels (%LOCALAPPDATA%\European Bus Simulator 2012\Settings.xml). + 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; + } + } + } +} diff --git a/Config.cs b/Config.cs new file mode 100644 index 0000000..bfbf2dc --- /dev/null +++ b/Config.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace BSO +{ + /// Einstellungen in <Spiel>\BSO\BSO.json - wird beim ersten Start mit Standardwerten angelegt. + public class Config + { + /// Name, den die anderen sehen. + public string Name { get; set; } = Environment.UserName; + /// Server als host oder host:port. + public string Server { get; set; } = "127.0.0.1:7777"; + /// Beim Spielstart automatisch verbinden (sonst F8). + 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(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); + } + } + } +} diff --git a/Entities.cs b/Entities.cs new file mode 100644 index 0000000..e3bdc4a --- /dev/null +++ b/Entities.cs @@ -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; + } + + /// Alle Entities der Szene (VisBaseEntity_cl) mit Klassenname und Position. + public static unsafe class Entities + { + public static List Scan() + { + var list = new List(); + 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; + } + + /// Steht in Platz slot noch dasselbe Objekt? Dann lebt es noch und darf gelesen werden. + public static bool StillThere(int slot, nint ptr) + { + return Vision.TryGetEntities(out nint* data, out int count) && slot < count && data[slot] == ptr; + } + + /// Diagnose (F10): alle Entities nach BSO\entities.txt, naechste zur Kamera zuerst. + 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; + } + } +} diff --git a/Input.cs b/Input.cs new file mode 100644 index 0000000..86a28fe --- /dev/null +++ b/Input.cs @@ -0,0 +1,38 @@ +using System; +using System.Runtime.InteropServices; + +namespace BSO +{ + /// Tasten der Mod (F8-F11): "gerade gedrueckt" erkennen, nur wenn das Spielfenster vorne ist. + 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 }; + + /// Einmal pro Bild vor allen Pressed-Abfragen. + 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; + } + } +} diff --git a/Loader/ProxyTest.cpp b/Loader/ProxyTest.cpp new file mode 100644 index 0000000..1d0889f --- /dev/null +++ b/Loader/ProxyTest.cpp @@ -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 +// Erwartet im Protokoll: "Vision90.dll ist nicht geladen" - ohne Spiel gibt es keine Engine. +#define DIRECTINPUT_VERSION 0x0800 +#include +#include +#include + +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; +} diff --git a/Loader/dinput8.cpp b/Loader/dinput8.cpp new file mode 100644 index 0000000..22de86c --- /dev/null +++ b/Loader/dinput8.cpp @@ -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: \BSO\BSO_Loader.txt (schreibt auch die Mod ueber HostApi.Log). + +#define WIN32_LEAN_AND_MEAN +#define NETHOST_USE_AS_STATIC +#include +#include +#include +#include +#include +#include +#include + +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 + +/// Was die Mod vom Loader bekommt (Gegenstueck: BSO.HostApi). +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); + +/// Originalfunktionen je vtable (A- und W-Schnittstellen haben eigene vtables). +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; +} + +/// Ersetzt Eintraege einer vtable (liegt schreibgeschuetzt in dinput8.dll) und merkt sich die Originale. +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; +} diff --git a/Loader/dinput8.def b/Loader/dinput8.def new file mode 100644 index 0000000..e1ce0de --- /dev/null +++ b/Loader/dinput8.def @@ -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 diff --git a/LocalPlayer.cs b/LocalPlayer.cs new file mode 100644 index 0000000..f1a05d9 --- /dev/null +++ b/LocalPlayer.cs @@ -0,0 +1,140 @@ +using System.Diagnostics; +using BSO.Net; + +namespace BSO +{ + /// + /// 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. + /// + public static class LocalPlayer + { + const float RelockDistance = 3000; // cm: so weit weg von der Kamera, obwohl man faehrt -> anderer Bus + + /// Ein verfolgtes Objekt: Platz in der Entity-Tabelle + Zeiger (zusammen = lebt noch). + 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; } + /// cm pro Sekunde (aus der Bewegung des Busses bzw. der Figur). + public static float Speed { get; private set; } + + public static PlayerMode Mode => !InScene ? PlayerMode.Menu : Character.Valid ? PlayerMode.OnFoot : Bus.Valid ? PlayerMode.InBus : PlayerMode.Menu; + + /// Was an die anderen geht: Position/Drehung des Busses im Bus, sonst der Figur. + 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(); + } + } +} diff --git a/Mem.cs b/Mem.cs new file mode 100644 index 0000000..5a364d9 --- /dev/null +++ b/Mem.cs @@ -0,0 +1,88 @@ +using System.Runtime.InteropServices; + +namespace BSO +{ + /// + /// 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). + /// + 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; + } + + /// Liegt die Adresse in einer geladenen DLL/EXE (z.B. vtables, Typinfos)? + 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); + } + } +} diff --git a/Multiplayer.cs b/Multiplayer.cs new file mode 100644 index 0000000..ac071bf --- /dev/null +++ b/Multiplayer.cs @@ -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 +{ + /// + /// 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. + /// + 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; } + /// Warum offline (letzter Fehler / Trennung) - fuer die Anzeige. + 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 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); + } + + /// Ist der Server schon angekommen (Welcome)? + 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); + } + } +} diff --git a/PositionHud.cs b/PositionHud.cs new file mode 100644 index 0000000..39d8928 --- /dev/null +++ b/PositionHud.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using BSO.Net; + +namespace BSO +{ + /// + /// 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. + /// + 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(); + + 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(); 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 + { + $"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 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"); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6206ff8 --- /dev/null +++ b/README.md @@ -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 `\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 diff --git a/Rtti.cs b/Rtti.cs new file mode 100644 index 0000000..6f6b08a --- /dev/null +++ b/Rtti.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace BSO +{ + /// + /// Klassenname eines C++-Objekts aus den MSVC-Typinfos (vtable[-1] -> CompleteObjectLocator -> TypeDescriptor). + /// So erkennen wir Spielobjekte (BaseVehicle, CarVehicle ...), ohne ihren Aufbau zu kennen. + /// + public static unsafe class Rtti + { + static readonly Dictionary byVtable = new(); + + /// Klassenname oder null, wenn obj kein gueltiges C++-Objekt ist. + 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); + } + + /// ".?AVFoo@Bar@@" -> "Bar::Foo". + 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); + } + } +} diff --git a/Shared/Connection.cs b/Shared/Connection.cs new file mode 100644 index 0000000..23ea1af --- /dev/null +++ b/Shared/Connection.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Threading; + +namespace BSO.Net +{ + /// Setzt aus einem Byte-Strom Nachrichten zusammen: [int Laenge][byte Typ][Payload]. + 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 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(); + 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; + } + } + + /// + /// 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. + /// + 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 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(); + } + + /// Sende-Thread: wartet auf Pakete und schreibt sie (gebuendelt) in die Verbindung. + 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 polled = new(); // wiederverwendet: der Aufrufer geht sie sofort durch + public List 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 { } + } + } +} diff --git a/Shared/PacketReader.cs b/Shared/PacketReader.cs new file mode 100644 index 0000000..9753108 --- /dev/null +++ b/Shared/PacketReader.cs @@ -0,0 +1,36 @@ +using System.IO; + +namespace BSO.Net +{ + /// + /// 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. + /// + 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(); + } + } +} diff --git a/Shared/Protocol.cs b/Shared/Protocol.cs new file mode 100644 index 0000000..f47b0c0 --- /dev/null +++ b/Shared/Protocol.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; + +namespace BSO.Net +{ + /// + /// 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). + /// + public static class Protocol + { + /// Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab. + 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); + } + + /// Was jeder Spieler ~10x pro Sekunde von sich schickt. Drehung wie die Engine: Grad (Gier, Nick, Roll). + 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 write) + { + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + write(w); + w.Flush(); + return ms.ToArray(); + } + } +} diff --git a/Vision.cs b/Vision.cs new file mode 100644 index 0000000..c44eab0 --- /dev/null +++ b/Vision.cs @@ -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}"; + } + + /// + /// 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). + /// + 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::elementTable (DynArray_cl) + + static delegate* unmanaged print; + static delegate* unmanaged getMainCamera; + static delegate* unmanaged getPosition; + static delegate* unmanaged getOrientation; + static delegate* unmanaged getTimer; + static delegate* unmanaged 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)Export(module, "?Print@VisMessage_cl@@QEAAXHHHPEBDZZ"); + getMainCamera = (delegate* unmanaged)Export(module, "?GetMainCamera@VisCamera_cl@@QEBAPEAVVisContextCamera_cl@@XZ"); + getPosition = (delegate* unmanaged)Export(module, "?GetPosition@VisObject3D_cl@@QEBAXAEAM00@Z"); + getOrientation = (delegate* unmanaged)Export(module, "?GetOrientation@VisObject3D_cl@@QEBAXAEAM00@Z"); + getTimer = (delegate* unmanaged)Export(module, "?GetTimer@Vision@@SAPEAVIVTimer@@XZ"); + getTime = (delegate* unmanaged)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; + + /// + /// 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. + /// + 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; + } + + /// Text fuer dieses Bild auf den Bildschirm (Vision::Message.Print). + public static void Print(int x, int y, string text) => Print(x, y, ToAscii(text)); + + /// Wie Print(string), mit vorbereitetem Text (ToAscii) - fuer Ausgaben bei jedem Durchlauf. + 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"); + + /// Hauptkamera (VisContextCamera_cl) oder 0, solange keine Szene geladen ist. + public static nint MainCamera => getMainCamera(camera); + + /// Position eines VisObject3D_cl (Entity, Kamera). obj muss gueltig sein. + public static Vec3 GetPosition(nint obj) + { + float x, y, z; + getPosition(obj, &x, &y, &z); + return new Vec3(x, y, z); + } + + /// Drehung eines VisObject3D_cl in Grad (Gier, Nick, Roll), wie SetOrientation sie nimmt. + public static Vec3 GetOrientation(nint obj) + { + float yaw, pitch, roll; + getOrientation(obj, &yaw, &pitch, &roll); + return new Vec3(yaw, pitch, roll); + } + + static bool tableLogged; + + /// + /// 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. + /// + 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; + } + } +} diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8c55c7d --- /dev/null +++ b/build.ps1 @@ -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" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..5201f29 --- /dev/null +++ b/install.ps1 @@ -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" diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..4a3273d --- /dev/null +++ b/release.ps1 @@ -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)"