diff --git a/.github/workflows/mingw-build-dev.yml b/.github/workflows/mingw-build-dev.yml index c77e7e0e9..197dbd820 100644 --- a/.github/workflows/mingw-build-dev.yml +++ b/.github/workflows/mingw-build-dev.yml @@ -23,6 +23,14 @@ jobs: sudo apt-get update sudo apt-get install -y mingw-w64 g++-mingw-w64-i686 cmake ninja-build + - name: Install .NET SDK 10 + # Required at CMake configure time to restore the + # MUnique.OpenMU.Network.Packets NuGet package, which the wire-size + # codegen reads ServerToClientPackets.xml from. + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Cache libjpeg-turbo (MinGW i686) uses: actions/cache@v4 with: diff --git a/.github/workflows/mingw-build-pr.yml b/.github/workflows/mingw-build-pr.yml index 935647a9d..bdb39b789 100644 --- a/.github/workflows/mingw-build-pr.yml +++ b/.github/workflows/mingw-build-pr.yml @@ -27,6 +27,14 @@ jobs: sudo apt-get update sudo apt-get install -y mingw-w64 g++-mingw-w64-i686 cmake ninja-build wine wine32:i386 + - name: Install .NET SDK 10 + # Required at CMake configure time to restore the + # MUnique.OpenMU.Network.Packets NuGet package, which the wire-size + # codegen reads ServerToClientPackets.xml from. + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Initialise wine prefix run: | wineboot --init >/dev/null 2>&1 || true diff --git a/.github/workflows/mingw-build.yml b/.github/workflows/mingw-build.yml index 22f49c378..932e2cab1 100644 --- a/.github/workflows/mingw-build.yml +++ b/.github/workflows/mingw-build.yml @@ -18,12 +18,20 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - + - name: Install MinGW-w64 toolchain run: | sudo apt-get update sudo apt-get install -y mingw-w64 g++-mingw-w64-i686 cmake ninja-build + - name: Install .NET SDK 10 + # Required at CMake configure time to restore the + # MUnique.OpenMU.Network.Packets NuGet package, which the wire-size + # codegen reads ServerToClientPackets.xml from. + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Cache libjpeg-turbo (MinGW i686) uses: actions/cache@v4 with: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8b758ad62..654a94df7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -198,10 +198,106 @@ if (CMAKE_SYSTEM_NAME STREQUAL "Linux") ) endif() +# --- .NET / NuGet infrastructure (shared by wire-sizes codegen and ClientLibrary) +# Lifted up here so the wire-sizes block below can resolve the OpenMU packet +# XML from the same NuGet cache the ClientLibrary uses. +find_program(DOTNET_EXECUTABLE dotnet.exe) +if (NOT DOTNET_EXECUTABLE) + find_program(DOTNET_EXECUTABLE dotnet) +endif() + +# Helper macro: convert a path to Windows-native format when using +# Windows dotnet.exe from WSL. On all other platforms this is a no-op. +function(mu_native_path input_path output_var) + if (DOTNET_EXECUTABLE MATCHES "\\.exe$" AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + execute_process( + COMMAND wslpath -w "${input_path}" + OUTPUT_VARIABLE native_path + RESULT_VARIABLE rc + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if (rc EQUAL 0) + set(${output_var} "${native_path}" PARENT_SCOPE) + else() + message(FATAL_ERROR "wslpath failed for '${input_path}'. Ensure wslpath is available.") + endif() + else() + set(${output_var} "${input_path}" PARENT_SCOPE) + endif() +endfunction() + +# NuGet cache: defaults to /.nuget, override with -DMU_NUGET_CACHE_DIR=... +set(MU_NUGET_CACHE_DIR "${CMAKE_SOURCE_DIR}/.nuget" CACHE PATH "NuGet package cache directory") +file(MAKE_DIRECTORY "${MU_NUGET_CACHE_DIR}") + +# --- Generated wire-size guards ---------------------------------------------- +# tools/gen_wire_sizes.py reads OpenMU's authoritative packet XML and emits +# wire_sizes.generated.h with static_asserts that lock each client packet +# struct's sizeof <= the wire length declared by the server. Guards against +# the PR #402 class of bug (silent #pragma pack drift that freezes the client). +# +# The XML lives in the MUnique.OpenMU.Network.Packets NuGet package -- the +# same package the .NET ClientLibrary consumes (see MUnique.Client.Library.csproj). +# Sourcing from NuGet means a single versioned dependency instead of vendoring +# the entire OpenMU repository as a submodule. +set(OPENMU_PACKETS_VERSION "0.9.9" CACHE STRING + "Version of MUnique.OpenMU.Network.Packets to source packet XML from") +set(OPENMU_PACKETS_XML + "${MU_NUGET_CACHE_DIR}/munique.openmu.network.packets/${OPENMU_PACKETS_VERSION}/contentFiles/any/net10.0/ServerToClient/ServerToClientPackets.xml") + +# Restore the package on demand at configure time if it isn't cached yet. +# dotnet restore is fast on cache-hit, so re-configuring stays cheap. +if (NOT EXISTS "${OPENMU_PACKETS_XML}") + if (NOT DOTNET_EXECUTABLE) + message(FATAL_ERROR + "wire-size codegen needs MUnique.OpenMU.Network.Packets v${OPENMU_PACKETS_VERSION} " + "but it is not cached at ${MU_NUGET_CACHE_DIR} and no .NET SDK was found to restore " + "it. Install dotnet (Linux or Windows) or pre-populate the NuGet cache.") + endif() + message(STATUS "Restoring MUnique.OpenMU.Network.Packets v${OPENMU_PACKETS_VERSION} for wire-size codegen...") + set(_packets_csproj "${CMAKE_CURRENT_SOURCE_DIR}/../ClientLibrary/MUnique.Client.Library.csproj") + mu_native_path("${_packets_csproj}" _packets_csproj_native) + mu_native_path("${MU_NUGET_CACHE_DIR}" _packets_nuget_native) + # WSLENV=NUGET_PACKAGES/w is needed so the env var crosses the WSL->Windows + # interop boundary when DOTNET_EXECUTABLE is dotnet.exe; harmless otherwise. + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + "WSLENV=NUGET_PACKAGES/w" + "NUGET_PACKAGES=${_packets_nuget_native}" + "${DOTNET_EXECUTABLE}" restore "${_packets_csproj_native}" --nologo + RESULT_VARIABLE _restore_rc + ) + if (NOT _restore_rc EQUAL 0) + message(FATAL_ERROR "dotnet restore failed (rc=${_restore_rc}) -- see output above.") + endif() + if (NOT EXISTS "${OPENMU_PACKETS_XML}") + message(FATAL_ERROR + "dotnet restore completed but ServerToClientPackets.xml is still missing at " + "${OPENMU_PACKETS_XML}. Did the NuGet package layout change?") + endif() +endif() + +set(WIRE_SIZES_GEN_SCRIPT "${CMAKE_SOURCE_DIR}/tools/gen_wire_sizes.py") +set(WIRE_SIZES_GEN_HEADER "${CMAKE_BINARY_DIR}/generated/Network/Server/wire_sizes.generated.h") +find_package(Python3 COMPONENTS Interpreter REQUIRED) +add_custom_command( + OUTPUT "${WIRE_SIZES_GEN_HEADER}" + COMMAND "${Python3_EXECUTABLE}" "${WIRE_SIZES_GEN_SCRIPT}" + --xml "${OPENMU_PACKETS_XML}" + --source-label "MUnique.OpenMU.Network.Packets v${OPENMU_PACKETS_VERSION} (NuGet)" + --output "${WIRE_SIZES_GEN_HEADER}" + DEPENDS "${WIRE_SIZES_GEN_SCRIPT}" "${OPENMU_PACKETS_XML}" + COMMENT "Generating wire_sizes.generated.h from OpenMU NuGet packet XML..." + VERBATIM +) +add_custom_target(GenWireSizes DEPENDS "${WIRE_SIZES_GEN_HEADER}") +add_dependencies(Main GenWireSizes) + target_include_directories(Main PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/source" "${CMAKE_CURRENT_SOURCE_DIR}/dependencies/include" "${CMAKE_CURRENT_SOURCE_DIR}/dependencies/netcore/includes" + "${CMAKE_BINARY_DIR}/generated" $<$:${CMAKE_CURRENT_SOURCE_DIR}/MuEditor> ) @@ -279,46 +375,23 @@ if (MSVC) endif() # .NET Client Library and tools (platform-agnostic, requires dotnet SDK) +# DOTNET_EXECUTABLE, mu_native_path() and MU_NUGET_CACHE_DIR are already +# resolved above (shared with the wire-size codegen block). +# # Native AOT can only target the OS it runs on, so when cross-compiling from # WSL we need the Windows dotnet.exe (available via WSL interop) rather than -# a Linux dotnet. -find_program(DOTNET_EXECUTABLE dotnet.exe) -if (NOT DOTNET_EXECUTABLE) - find_program(DOTNET_EXECUTABLE dotnet) -endif() -if (DOTNET_EXECUTABLE) - # Native AOT cannot cross-compile across OS boundaries. A Linux dotnet - # targeting win-x86/win-x64 will fail. Only proceed when we have a Windows - # dotnet.exe (native or via WSL interop). - if (NOT DOTNET_EXECUTABLE MATCHES "\\.exe$" AND CMAKE_SYSTEM_NAME STREQUAL "Windows") - message(WARNING "Found Linux dotnet but target is Windows. " - "Cross-OS Native AOT is not supported. MUnique.Client.Library.dll will NOT be built. " - "Install the Windows .NET SDK or use WSL interop (dotnet.exe) to enable.") - set(DOTNET_EXECUTABLE "") - endif() +# a Linux dotnet. The AOT publish below is gated on _dotnet_for_aot so that a +# cross-OS scenario only disables the AOT step, not the package restore that +# the wire-sizes block needs. +set(_dotnet_for_aot "${DOTNET_EXECUTABLE}") +if (_dotnet_for_aot AND NOT _dotnet_for_aot MATCHES "\\.exe$" AND CMAKE_SYSTEM_NAME STREQUAL "Windows") + message(WARNING "Found Linux dotnet but target is Windows. " + "Cross-OS Native AOT is not supported. MUnique.Client.Library.dll will NOT be built. " + "Install the Windows .NET SDK or use WSL interop (dotnet.exe) to enable.") + set(_dotnet_for_aot "") endif() -if (DOTNET_EXECUTABLE) - message(STATUS "Found .NET SDK: ${DOTNET_EXECUTABLE}") - - # Helper macro: convert a path to Windows-native format when using - # Windows dotnet.exe from WSL. On all other platforms this is a no-op. - function(mu_native_path input_path output_var) - if (DOTNET_EXECUTABLE MATCHES "\\.exe$" AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") - execute_process( - COMMAND wslpath -w "${input_path}" - OUTPUT_VARIABLE native_path - RESULT_VARIABLE rc - OUTPUT_STRIP_TRAILING_WHITESPACE - ) - if (rc EQUAL 0) - set(${output_var} "${native_path}" PARENT_SCOPE) - else() - message(FATAL_ERROR "wslpath failed for '${input_path}'. Ensure wslpath is available.") - endif() - else() - set(${output_var} "${input_path}" PARENT_SCOPE) - endif() - endfunction() +if (_dotnet_for_aot) + message(STATUS "Found .NET SDK: ${_dotnet_for_aot}") # 1. Define the output path using a variable CMake can understand early set(DOTNET_DLL_PATH "${CMAKE_CURRENT_BINARY_DIR}/MUnique.Client.Library.dll") @@ -327,13 +400,9 @@ if (DOTNET_EXECUTABLE) # Create temp directories for Native AOT build. set(DOTNET_TEMP_OUTPUT "${CMAKE_BINARY_DIR}/dotnet_out") set(DOTNET_TEMP_DIR "${CMAKE_BINARY_DIR}/.dotnet_temp") - - # NuGet cache: defaults to /.nuget, override with -DMU_NUGET_CACHE_DIR=... - set(MU_NUGET_CACHE_DIR "${CMAKE_SOURCE_DIR}/.nuget" CACHE PATH "NuGet package cache directory") set(DOTNET_NUGET_DIR "${MU_NUGET_CACHE_DIR}") file(MAKE_DIRECTORY "${DOTNET_TEMP_OUTPUT}") file(MAKE_DIRECTORY "${DOTNET_TEMP_DIR}") - file(MAKE_DIRECTORY "${DOTNET_NUGET_DIR}") # Convert paths to Windows-native format for MSBuild (no-op outside WSL). mu_native_path("${DOTNET_PROJ}" DOTNET_PROJ_NATIVE) @@ -365,7 +434,7 @@ if (DOTNET_EXECUTABLE) "DOTNET_CLI_HOME=${DOTNET_TEMP_DIR_NATIVE}" "TEMP=${DOTNET_TEMP_DIR_NATIVE}" "TMP=${DOTNET_TEMP_DIR_NATIVE}" - "${DOTNET_EXECUTABLE}" publish "${DOTNET_PROJ_NATIVE}" -c $ -r ${DOTNET_RID} -p:PlatformTarget=${DOTNET_PLATFORM} -o "${DOTNET_TEMP_OUTPUT_NATIVE}" --nologo + "${_dotnet_for_aot}" publish "${DOTNET_PROJ_NATIVE}" -c $ -r ${DOTNET_RID} -p:PlatformTarget=${DOTNET_PLATFORM} -o "${DOTNET_TEMP_OUTPUT_NATIVE}" --nologo COMMAND ${CMAKE_COMMAND} -E copy_if_different "${DOTNET_TEMP_OUTPUT}/MUnique.Client.Library.dll" "${DOTNET_DLL_PATH}" DEPENDS "${DOTNET_PROJ}" ${DOTNET_SOURCES} COMMENT "Checking for .NET Client Library updates..." @@ -397,7 +466,7 @@ if (DOTNET_EXECUTABLE) add_custom_command( OUTPUT "${CONSTANTS_REPLACER_OUTPUT}" - COMMAND "${DOTNET_EXECUTABLE}" build "${CONSTANTS_REPLACER_PROJ_NATIVE}" -c $ -o "${CONSTANTS_REPLACER_OUTDIR_NATIVE}" --nologo + COMMAND "${_dotnet_for_aot}" build "${CONSTANTS_REPLACER_PROJ_NATIVE}" -c $ -o "${CONSTANTS_REPLACER_OUTDIR_NATIVE}" --nologo DEPENDS "${CONSTANTS_REPLACER_PROJ}" ${CONSTANTS_REPLACER_SOURCES} COMMENT "Building ConstantsReplacer tool..." VERBATIM diff --git a/src/source/Core/Utilities/Log/muConsoleDebug.cpp b/src/source/Core/Utilities/Log/muConsoleDebug.cpp index 92655c9b2..ccb68b18f 100644 --- a/src/source/Core/Utilities/Log/muConsoleDebug.cpp +++ b/src/source/Core/Utilities/Log/muConsoleDebug.cpp @@ -53,12 +53,13 @@ CmuConsoleDebug::~CmuConsoleDebug() CmuConsoleDebug* CmuConsoleDebug::GetInstance() { -#ifdef CSK_LH_DEBUG_CONSOLE + // Always return a valid instance. Previously returned nullptr in builds + // without CSK_LH_DEBUG_CONSOLE, which made every g_ConsoleDebug->Write + // call site a null-deref in disguise (it "worked" only because the Write + // body was empty when CONSOLE_DEBUG was undefined). Returning a real + // instance is required for the always-on MCD_ERROR path below to be safe. static CmuConsoleDebug sInstance; return &sInstance; -#else - return 0; -#endif } void CmuConsoleDebug::UpdateMainScene() @@ -232,6 +233,22 @@ bool CmuConsoleDebug::CheckCommand(const std::wstring& strCommand) void CmuConsoleDebug::Write(int iType, const wchar_t* pStr, ...) { + // MCD_ERROR is always logged to MuError.log, regardless of CONSOLE_DEBUG. + // Other log levels remain debug-only so they don't spam production logs. + if (iType == MCD_ERROR) + { + wchar_t szErrorBuffer[256] = L""; + va_list pArgsForFile; + va_start(pArgsForFile, pStr); + // C99 4-arg vswprintf -- explicit buffer size, bounded write. The + // 3-arg MS-extension form is unsafe (no size param, can overflow). + _vsnwprintf(szErrorBuffer, + sizeof(szErrorBuffer) / sizeof(szErrorBuffer[0]), + pStr, pArgsForFile); + va_end(pArgsForFile); + g_ErrorReport.Write(L"[MCD_ERROR] %ls\r\n", szErrorBuffer); + } + #ifdef CONSOLE_DEBUG if (m_bInit) { diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index e2ea2e584..94bfdf703 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -930,6 +930,17 @@ BOOL ReceiveLogOut(const BYTE* ReceiveBuffer, BOOL bEncrypted) int HeroIndex; +void LogSafeCastSizeMismatch(const char* packet_type, std::size_t received, std::size_t expected) +{ + // %u + cast (instead of %zu) keeps the format compatible with older msvcrt + // builds where vswprintf does not recognise C99 length modifiers. Packet + // sizes always fit in 32 bits. + g_ConsoleDebug->Write(MCD_ERROR, + L"safe_cast<%.64hs>: received %u bytes, expected at least %u -- packet dropped", + packet_type ? packet_type : "?", + static_cast(received), static_cast(expected)); +} + BOOL ReceiveJoinMapServer(std::span ReceiveBuffer) { MouseLButton = false; @@ -941,7 +952,8 @@ BOOL ReceiveJoinMapServer(std::span ReceiveBuffer) CharacterAttribute->AbilityTime[1] = 0; CharacterAttribute->AbilityTime[2] = 0; - auto const Data = safe_cast(ReceiveBuffer); + auto const Data = safe_cast( + ReceiveBuffer, "PRECEIVE_JOIN_MAP_SERVER_EXTENDED"); if (Data == nullptr) { assert(false); @@ -13206,6 +13218,11 @@ static void ProcessPacket(const BYTE* ReceiveBuffer, int32_t Size) case 0x03: //receive join map server if (!ReceiveJoinMapServer(received_span)) { + // safe_cast logged the size mismatch; reiterate the user-visible + // symptom so the cause is obvious in the console. + g_ConsoleDebug->Write(MCD_ERROR, + L"[ReceiveJoinMapServer] dropped -- protocol state stays REQUEST_JOIN_MAP_SERVER, " + L"main render will not be enabled (loading screen will appear frozen)."); //return ( FALSE); } break; diff --git a/src/source/Network/Server/WSclient.h b/src/source/Network/Server/WSclient.h index 211acec5c..29c1b8126 100644 --- a/src/source/Network/Server/WSclient.h +++ b/src/source/Network/Server/WSclient.h @@ -6,6 +6,7 @@ #include "Dotnet/Connection.h" #include "Network/Server/CSMapServer.h" #include +#include #define WM_ASYNCSELECTMSG (WM_USER+0) @@ -92,11 +93,21 @@ inline uint64_t ntoh64(uint64_t value) ((value & 0xFF00000000000000ULL) >> 56); } -// Template to cast a span to a packet struct in a safe way. -template T* safe_cast(const std::span span) +// Logs a size-mismatch when a typed safe_cast fails. Defined in WSclient.cpp +// so the header stays free of g_ConsoleDebug includes. +void LogSafeCastSizeMismatch(const char* packet_type, std::size_t received, std::size_t expected); + +// Casts a span to a packet struct, returning nullptr if the buffer is smaller +// than sizeof(T). On failure, logs an MCD_ERROR with the packet type and the +// observed vs expected size so the failure is never silent. Callers SHOULD +// pass a human-readable packet_type; if omitted, typeid(T).name() is used as +// a fallback (the compiler-mangled name, still better than nothing). +template T* safe_cast(const std::span span, const char* packet_type = nullptr) { if (span.size() < sizeof(T)) { + LogSafeCastSizeMismatch(packet_type ? packet_type : typeid(T).name(), + span.size(), sizeof(T)); return nullptr; } @@ -3657,4 +3668,9 @@ typedef struct DWORD Money; DWORD Pause; } PRECEIVE_MUHELPER_STATUS, * LPRECEIVE_MUHELPER_STATUS; -#pragma pack(pop) \ No newline at end of file +#pragma pack(pop) + +// Generated static_assert size guards. Sourced from OpenMU's authoritative +// packet XML via tools/gen_wire_sizes.py. Must appear after all packet struct +// declarations so the asserts can see them. +#include "Network/Server/wire_sizes.generated.h" \ No newline at end of file diff --git a/tools/gen_wire_sizes.py b/tools/gen_wire_sizes.py new file mode 100644 index 000000000..1233202a3 --- /dev/null +++ b/tools/gen_wire_sizes.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Generate wire_sizes.generated.h: static_assert size guards for client +packet structs, derived from OpenMU's authoritative packet XML. + +The XML lives in the MUnique.OpenMU.Network.Packets NuGet package (the same +package the .NET ClientLibrary consumes). CMake resolves the path and passes +it via --xml. + +For each entry in PACKET_MAPPING, look up the wire and emit: + + static_assert(sizeof() <= , + "wire size drift -- generated from ( bytes)"); + +We use <= rather than == because some client structs intentionally do not +decode all trailing fields (e.g. PreviewData). The bug class we are guarding +against (PR #402) is "client struct grew larger than the wire packet, so +safe_cast rejects it" -- <= catches that exactly. + +To add a packet: append to PACKET_MAPPING. There is no naming convention +linking XML names to C++ struct names, so the mapping must be hand-maintained. +""" + +import argparse +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +# XML packet name (without subcode/code prefix) -> client C++ struct name. +# Seeded with the structs PR #66 covered by hand. +PACKET_MAPPING = [ + ("CharacterCreationSuccessful", "PRECEIVE_CREATE_CHARACTER"), + ("CharacterInformationExtended", "PRECEIVE_JOIN_MAP_SERVER_EXTENDED"), + ("RespawnAfterDeathExtended", "PRECEIVE_REVIVAL_EXTENDED"), +] + + +def load_lengths(xml_path: Path) -> dict[str, int]: + """Return {packet_name: wire_length_bytes} from a Packets XML file.""" + tree = ET.parse(xml_path) + root = tree.getroot() + # Default namespace handling: strip any xmlns prefix so we can match + # element names directly. + def localname(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + lengths: dict[str, int] = {} + for packet in root.iter(): + if localname(packet.tag) != "Packet": + continue + name = None + length = None + for child in packet: + tag = localname(child.tag) + if tag == "Name": + name = (child.text or "").strip() + elif tag == "Length": + try: + length = int((child.text or "").strip()) + except ValueError: + length = None + if name and length is not None: + lengths[name] = length + return lengths + + +def emit_header(lengths: dict[str, int], out: Path, source_label: str) -> int: + missing = [xml for xml, _ in PACKET_MAPPING if xml not in lengths] + if missing: + print(f"error: XML packets not found in {source_label}: {missing}", + file=sys.stderr) + return 1 + + lines = [ + "// THIS FILE IS GENERATED. DO NOT EDIT.", + f"// Source: {source_label}", + "// Generator: tools/gen_wire_sizes.py", + "//", + "// Static-assert that each client packet struct fits within the wire packet", + "// length declared by OpenMU. Guards against the PR #402 class of bug where a", + "// dropped #pragma pack(1) silently inflates the client struct above the wire", + "// size, causing safe_cast to reject every packet (freezes the loading screen).", + "", + "#pragma once", + "", + ] + for xml_name, cpp_name in PACKET_MAPPING: + n = lengths[xml_name] + lines += [ + f"static_assert(sizeof({cpp_name}) <= {n},", + f" \"wire size drift -- generated from {xml_name} ({n} bytes)\");", + "", + ] + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text("\n".join(lines), encoding="utf-8") + print(f"wrote {out} ({len(PACKET_MAPPING)} asserts)", file=sys.stderr) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--xml", required=True, type=Path, + help="Path to ServerToClientPackets.xml " + "(from the MUnique.OpenMU.Network.Packets NuGet " + "package contentFiles)") + ap.add_argument("--source-label", default=None, + help="Optional label written into the generated header's " + "'Source:' comment (e.g. the NuGet package " + "version). Defaults to the XML basename.") + ap.add_argument("--output", required=True, type=Path, + help="Output header path") + args = ap.parse_args() + + if not args.xml.exists(): + print(f"error: not found: {args.xml}", file=sys.stderr) + return 1 + + lengths = load_lengths(args.xml) + label = args.source_label or args.xml.name + return emit_header(lengths, args.output, source_label=label) + + +if __name__ == "__main__": + raise SystemExit(main())