commit 35f2d1330d384c9c8fb3a31fbe5a802b71e9dd4c Author: MrMaho Date: Wed Oct 28 17:26:23 2026 +0100 Erste Version Co-Authored-By: Claude Opus 5.5 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)"