diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 03b5221..0b1fb61 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -59,6 +59,7 @@ jobs: ruff check ruff format --check pytest + python tools/verify_windower_abi.py python -m compileall -q tools clang-format: diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b5c971..1e5d5ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,13 @@ set(FLUFFYTAIL_SOURCES "src/exports.def" "src/fluffytail.cpp" "src/fluffytail.hpp" + "src/hook_utils.cpp" + "src/hook_utils.hpp" + "src/render_runtime.cpp" + "src/render_runtime.hpp" + "src/tail_policy.hpp" + "src/windower.cpp" + "src/windower.hpp" ) add_library(${ASHITA_PROJECT_NAME} MODULE ${FLUFFYTAIL_SOURCES}) @@ -67,9 +74,40 @@ ashita_sdk_set_compiler_warnings_as_errors(${ASHITA_PROJECT_NAME}) ashita_sdk_set_linker_defaults(${ASHITA_PROJECT_NAME}) # Required for D3D device calls in the 32-bit Ashita host. target_link_options(${ASHITA_PROJECT_NAME} PRIVATE /SAFESEH:NO) +if (MSVC) + # Host and COM callbacks use catch-all guards as their final fault boundary. + # /EHa is required for those guards to include Windows structured exceptions. + target_compile_options(${ASHITA_PROJECT_NAME} PRIVATE /EHa) +endif() if (CMAKE_BUILD_TYPE STREQUAL "Debug") ashita_sdk_set_linker_enable_debug_linking(${ASHITA_PROJECT_NAME}) else() ashita_sdk_set_linker_disable_debug_linking(${ASHITA_PROJECT_NAME}) endif() + +include(CTest) +if (BUILD_TESTING) + add_executable(fluffytail_native_tests + "tests/native_tests.cpp" + "src/hook_utils.cpp" + "src/hook_utils.hpp" + "src/tail_policy.hpp" + "src/windower.hpp" + ) + target_include_directories(fluffytail_native_tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + ) + set_target_properties(fluffytail_native_tests PROPERTIES + CXX_EXTENSIONS ON + CXX_STANDARD_REQUIRED ON + ) + ashita_sdk_attach(fluffytail_native_tests) + ashita_sdk_set_compiler_defaults(fluffytail_native_tests) + ashita_sdk_set_compiler_warn_level(fluffytail_native_tests 4) + ashita_sdk_set_compiler_warnings_as_errors(fluffytail_native_tests) + add_dependencies(fluffytail_native_tests ${ASHITA_PROJECT_NAME}) + add_test(NAME fluffytail_native_tests + COMMAND fluffytail_native_tests "$" + ) +endif() diff --git a/LICENSES.md b/LICENSES.md index ef1dff6..ce5c7d0 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -3,10 +3,12 @@ | Component | Source and provenance | Affected files | License text | | --- | --- | --- | --- | | Ashita CMake and Direct3D scaffold | [AshitaXI/screenshot-src at `2d8812f`](https://github.com/AshitaXI/screenshot-src/tree/2d8812fd98f652f457e273cf74bad31a4bcc3ec9) | `CMakeLists.txt`, `cmake/FindAshitaSDK.cmake`, `src/defines.hpp` | [GNU GPL v3](LICENSE.GPL.txt) | -| Ashita plugin interface scaffold | [AshitaXI/exampleplugin at `b67726f`](https://github.com/AshitaXI/exampleplugin/tree/b67726f02fb55e6d2aef94a402ef6875384116eb) | `src/fluffytail.cpp`, `src/fluffytail.hpp`, `src/exports.def` | [GNU LGPL v3](LICENSE.md) | +| Ashita plugin interface scaffold | [AshitaXI/exampleplugin at `b67726f`](https://github.com/AshitaXI/exampleplugin/tree/b67726f02fb55e6d2aef94a402ef6875384116eb) | `src/fluffytail.cpp`, `src/fluffytail.hpp` | [GNU LGPL v3](LICENSE.md) | | Ashita v4 SDK | Distributed separately with Ashita and selected by `ASHITA4_SDK_PATH` at build time | External headers and libraries, not included in this repository | [GNU LGPL v3](LICENSE.md) | -| FluffyTail C++ implementation | Modifications to the Ashita plugin scaffold authored in this repository | FluffyTail code in `src/fluffytail.cpp`, `src/fluffytail.hpp`, and `src/exports.def` | [GNU LGPL v3](LICENSE.md), as stated in the source headers | -| Release tooling and tests | Python tools and synthetic tests authored in this repository | `tools/`, `tests/`, and `pyproject.toml` | No license is currently stated for these files | +| Windower native plugin ABI declarations | [Shirk/Nameplate Windozer declarations](https://github.com/Shirk/Nameplate), including `Windozer.h` and `Windozer.cpp`; adapted for Windower 4.7.9 | `src/windower.hpp`, `src/windower.cpp` | [GNU GPL v3](LICENSE.GPL.txt), as stated in the source headers | +| Dual-host export definition | Ashita and Windower export names authored from their respective host contracts; the Windower entries derive from the GPLv3 Windozer declarations | `src/exports.def` | [GNU GPL v3](LICENSE.GPL.txt) | +| FluffyTail C++ implementation | Modifications to the Ashita plugin scaffold and shared runtime authored in this repository | `src/fluffytail.cpp`, `src/fluffytail.hpp`, `src/hook_utils.cpp`, `src/hook_utils.hpp`, `src/render_runtime.cpp`, `src/render_runtime.hpp`, and `src/tail_policy.hpp` | [GNU LGPL v3](LICENSE.md), as stated in the source headers | +| Release tooling and tests | Python tools and synthetic tests authored in this repository | `tools/`, `tests/`, and `pyproject.toml` | [GNU LGPL v3](LICENSE.md) | The release DAT overlay contains modified Final Fantasy XI model data. Final Fantasy XI and its models and textures are property of Square Enix. See the disclaimer in diff --git a/README.md b/README.md index 036f9eb..26bdc5a 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,111 @@ -

FluffyTail

+# FluffyTail -

-Fluffy tails calibrated to each Mithra face and hair variant.
-An Ashita v4.3 plugin and XiPivot overlay for retail Final Fantasy XI. -

+FluffyTail adds a fluffy tail to the retail Mithra body armor model and selects +the tail colour for each face and hair variant. One x86 DLL contains adapters for +both supported hosts: -

-License: GPLv3 -License: LGPLv3 -Checks -

+- Ashita v4.3 (the Ashita plugin ABI) +- Windower 4.7.9 (the native plugin ABI, interface version `0x04070300`) -## About - -FluffyTail combines an Ashita v4.3 plugin with the bundled XiPivot `pivot` -polplugin. It matches each normal Mithra tail to the character's face and hair -variant. Supported NPCs with fixed models receive colours baked into their DATs. +The release also contains a 408-file XiPivot overlay. Fixed-model NPC colours +are baked into their DATs; normal Mithra colours are selected by the plugin at +render time. ## Install +Choose the archive for the host you use. Do not install both copies of the +overlay into one host. + +### Ashita + Requirements: - [Ashita v4.3](https://github.com/AshitaXI/Ashita-v4beta) -- The bundled `pivot` polplugin enabled in the active Ashita boot profile +- The XiPivot `pivot` polplugin, installed separately and enabled in the active + Ashita boot profile -1. Download the +1. Download `fluffytail.zip` from the [latest release](https://github.com/Aeshur/FluffyTail/releases/latest/download/fluffytail.zip) - and extract it into the Ashita directory. Merge the included `plugins/` and - `polplugins/` folders with the existing folders. -2. Open the active profile in `Ashita/config/boot/` and ensure it contains: - - ```ini - [ashita.polplugins] - pivot = 1 - ``` - -3. Open `Ashita/config/pivot/pivot.ini` and add `FluffyTail` under `[overlays]` - using the next unused number. For example: - - ```ini - [overlays] - 0=another-overlay - 1=FluffyTail - ``` + and extract it into the Ashita directory. Merge `plugins/` and + `polplugins/` with the existing directories. +2. Add `FluffyTail` to the `[overlays]` section in your existing + `Ashita/config/pivot/pivot.ini`, using the next unused number. The package + does not include or replace this settings file or the XiPivot binary. +3. Ensure the active boot profile enables `pivot`, then load the plugin with + `/load fluffytail` (or add that command to the profile script). -4. Start FFXI and run `/load fluffytail`. +### Windower -To load the plugin automatically, add `/load fluffytail` to the script used by the -active Ashita boot profile. - -When updating an existing installation, exit FFXI before replacing the DLL or DATs. - -## Commands - -`/fluffytail inspect` prints the current target's entity type, race, actor pointer, -and armor model IDs. It requires a current target and is intended for diagnosing -unsupported Mithra with fixed models. - -## Troubleshooting +Requirements: -Keep the XiPivot overlay enabled while the plugin is loaded. With the plugin off, -normal Mithra tails use the neutral fallback colour while supported NPCs with fixed -models retain colours baked to match their hair. +- [Windower 4.7.9](https://windower.net/) +- The XiPivot `XIPivot` addon, installed separately -## Known limitations +1. Download `fluffytail-windower.zip` and extract it into the Windower + directory. Merge `plugins/` and `addons/` with the existing directories. +2. Add the `FluffyTail` overlay to your existing XIPivot configuration. The + package writes only `addons/XIPivot/data/DATs/FluffyTail/ROM/`; it never + overwrites XIPivot settings or bundles the XIPivot addon. +3. Load the native plugin with Windower's plugin loader (`//load fluffytail`). -- Costume bodies that hide the original tail remain unchanged. -- Nanaa Mihgo's special fixed model uses an unsupported tail-body rig and is not - modified. +Exit the game before replacing the DLL or DAT files. Keep XiPivot enabled while +the plugin is loaded. Costume bodies that hide the original tail remain +unchanged, and Nanaa Mihgo's unsupported fixed-model rig is not modified. ## Build -The build requires Ninja, CMake 3.22 or later, the Ashita v4 SDK, and the x86 -MSVC build environment. Set `ASHITA4_SDK_PATH` to the SDK directory: +The build requires CMake 3.22 or later, Ninja, an x86 MSVC Native Tools prompt, +the Ashita v4 SDK, and the private DAT rig assets used by the overlay tools. Set +`ASHITA4_SDK_PATH` to the SDK directory: ```batch set "ASHITA4_SDK_PATH=C:\Games\YourAshita\plugins\sdk" +cmake --preset x86-release-win +cmake --build --preset x86-release-win ``` -From an x86 Native Tools Command Prompt, run: +The result is the single dual-host `bin/fluffytail.dll`. Build the neutral +runtime overlay and both deterministic release archives with: ```batch -cmake --preset x86-release-win -cmake --build --preset x86-release-win +python tools/build_runtime_overlay.py +python tools/build_packages.py bin/fluffytail.dll work/runtime-overlay work/packages ``` -A successful configure and build writes `bin/fluffytail.dll`. +This writes `work/packages/fluffytail.zip` for Ashita and +`work/packages/fluffytail-windower.zip` for Windower. Existing output files +require `--force` or a new output directory. Each archive contains exactly 413 +entries: the shared DLL, 408 validated DAT files, the README and provenance +inventory, and the GPLv3 and LGPLv3 notices under `FluffyTail/`. -## License +Run the offline checks before packaging: -GNU GPLv3 logo -GNU LGPLv3 logo +```batch +python -m pytest +ruff check tools tests +python tools/verify_windower_abi.py +``` + +## Validation status -The Ashita CMake and Direct3D scaffold uses -[GNU GPL version 3](LICENSE.GPL.txt). The plugin interface scaffold and original -C++ work created by this project use [GNU LGPL version 3](LICENSE.md). The -[component license inventory](LICENSES.md) records exact provenance and affected -files. No license is currently stated for the Python release tools and tests. +Validation is offline only. The test suite checks DAT chunk structure, overlay +file counts and paths, deterministic ZIP metadata, the x86 PE header, and the +Windower 4.7.9 ABI (`0x04070300`) against pinned official Hook and Config binary +hashes. No in-game rendering or host loader validation has been performed for +this port. + +## License -*Final Fantasy XI* and its models and textures are property of Square Enix. -This project is an unofficial fan mod. It is unaffiliated with and unendorsed by +The Ashita CMake and Direct3D scaffold is [GNU GPL v3](LICENSE.GPL.txt). The +Ashita plugin interface scaffold and most FluffyTail C++ work are [GNU LGPL +v3](LICENSE.md). The Windower adapter and dual-host export definition include +GPLv3-derived Nameplate/Windozer material and are covered by [GNU GPL v3](LICENSE.GPL.txt). +The component inventory in [LICENSES.md](LICENSES.md) records exact provenance. +The Python release tools and tests are covered by [GNU LGPL v3](LICENSE.md). +The complete corresponding source for each published DLL is available from the +[FluffyTail source repository](https://github.com/Aeshur/FluffyTail) at the matching +release tag. + +*Final Fantasy XI* and its models and textures are property of Square Enix. This +project is an unofficial fan mod and is unaffiliated with or unendorsed by Square Enix. diff --git a/src/exports.def b/src/exports.def index ecb4821..3dfa3c5 100644 --- a/src/exports.def +++ b/src/exports.def @@ -4,6 +4,10 @@ EXPORTS ; Ashita v4 plugin ABI exports. Names and ordinals are part of the host contract. expCreatePlugin @1 - expDestroyPlugin @2 expGetInterfaceVersion @3 + +; Windower 4.7.9 native plugin ABI exports. These use C linkage and cdecl. + + CreateInstance + GetInterfaceVersion diff --git a/src/fluffytail.cpp b/src/fluffytail.cpp index 20b7666..ae41ca7 100644 --- a/src/fluffytail.cpp +++ b/src/fluffytail.cpp @@ -1,5 +1,5 @@ /** - * FluffyTail - per-face Mithra tail colour plugin for Ashita v4.3. + * FluffyTail - Ashita v4.3 host adapter. * * Derived from the Ashita v4 example plugin (src/exampleplugin.cpp), * Copyright (c) 2025 Ashita Development Team, GNU LGPL v3. Modifications for @@ -9,173 +9,30 @@ #include "fluffytail.hpp" -namespace -{ - -// FFXI race identifier for Mithra. -constexpr uint8_t RACE_MITHRA = 7; - -// The XiPivot overlay uses this fixed neutral only as a marker during drawing. -constexpr uint32_t BASELINE_RGB = 0x241C1A; - -// Established face -> tail colour map, keyed by the low 16 hair values ('1A'..'8B'). -constexpr const char* FACE_COLOURS[16] = { - "red", - "brunette", - "white", - "silver", - "silver", - "red", - "blonde", - "red", - "rose", - "brunette", - "white", - "red", - "blonde", - "brunette", - "brunette", - "blonde", -}; - -auto face_colour(uint8_t hair) -> const char* -{ - return hair < (sizeof(FACE_COLOURS) / sizeof(FACE_COLOURS[0])) ? FACE_COLOURS[hair] - : nullptr; -} +#include "hook_utils.hpp" -// These solid tail colours were calibrated manually against the face references. -// Do not change a value without checking it again in game, where lighting brightens it. -struct colour_t -{ - const char* name; - uint32_t rgb; -}; +#include +#include -constexpr colour_t COLOURS[] = { - { "white", 0xD8D8CA }, - { "silver", 0xCDC2D4 }, - { "blonde", 0xC3A261 }, - { "red", 0x652708 }, - { "rose", 0x814231 }, - { "brunette", 0x3E2412 }, -}; +static_assert(offsetof(Ashita::FFXI::entity_t, Race) == 0xEF, + "retail entity race offset changed"); +static_assert(offsetof(Ashita::FFXI::entity_t, Look) == 0xFC, + "retail entity look offset changed"); +static_assert(offsetof(Ashita::FFXI::look_t, Hair) == 0, + "retail look hair offset changed"); -constexpr auto rgb565(uint32_t rgb) -> uint16_t +namespace { - return static_cast((((rgb >> 19) & 0x1F) << 11) | (((rgb >> 10) & 0x3F) << 5) | - ((rgb >> 3) & 0x1F)); -} -auto is_tail_block(const uint8_t* block) -> bool +auto STDMETHODCALLTYPE submit_ashita_draw(IDirect3DDevice8* device, + D3DPRIMITIVETYPE primitive_type, + UINT min_index, + UINT num_vertices, + UINT start_index, + UINT primitive_count) -> HRESULT { - for (size_t index = 0; index < 8; ++index) - { - if (block[index] != 0xFF) - return false; - } - for (size_t index = 12; index < 16; ++index) - { - if (block[index] != 0) - return false; - } - - uint16_t endpoint{}; - uint16_t other{}; - CopyMemory(&endpoint, block + 8, sizeof(endpoint)); - CopyMemory(&other, block + 10, sizeof(other)); - if (other != 0xFFFF) - return false; - - return endpoint == rgb565(BASELINE_RGB); -} - -auto has_tail_fingerprint(IDirect3DBaseTexture8* source_texture) -> bool -{ - constexpr size_t TEXTURE_WIDTH = 256; - constexpr size_t TEXTURE_HEIGHT = 256; - constexpr size_t BLOCK_DIMENSION = 4; - constexpr size_t BLOCK_SIZE = 16; - constexpr size_t BLOCKS_PER_ROW = TEXTURE_WIDTH / BLOCK_DIMENSION; - constexpr size_t BLOCK_ROWS = TEXTURE_HEIGHT / BLOCK_DIMENSION; - - if (source_texture == nullptr || source_texture->GetType() != D3DRTYPE_TEXTURE) - return false; - - auto* texture = static_cast(source_texture); - D3DSURFACE_DESC desc{}; - if (FAILED(texture->GetLevelDesc(0, &desc)) || desc.Width != TEXTURE_WIDTH || - desc.Height != TEXTURE_HEIGHT || desc.Format != D3DFMT_DXT3) - return false; - - D3DLOCKED_RECT rect{}; - if (FAILED(texture->LockRect(0, &rect, nullptr, D3DLOCK_READONLY))) - return false; - - // Every DXT3 block must match the neutral marker, preventing false positives. - const auto* first = static_cast(rect.pBits); - bool matches = first != nullptr && - rect.Pitch >= static_cast(BLOCK_SIZE * BLOCKS_PER_ROW) && - is_tail_block(first); - for (size_t row = 0; matches && row < BLOCK_ROWS; ++row) - { - const auto* scan = first + (row * rect.Pitch); - for (size_t column = 0; column < BLOCKS_PER_ROW; ++column) - { - if (std::memcmp(scan + (column * BLOCK_SIZE), first, BLOCK_SIZE) != 0) - { - matches = false; - break; - } - } - } - texture->UnlockRect(0); - return matches; -} - -// rgb is 0xRRGGBB. Generated pixels are opaque. -auto make_texture(IDirect3DDevice8* device, uint32_t rgb) -> IDirect3DTexture8* -{ - constexpr uint32_t TEXTURE_SIZE = 8; - IDirect3DTexture8* texture = nullptr; - if (FAILED(device->CreateTexture(TEXTURE_SIZE, TEXTURE_SIZE, 1, 0, D3DFMT_A8R8G8B8, D3DPOOL_MANAGED, &texture))) - return nullptr; - - D3DLOCKED_RECT rect{}; - if (FAILED(texture->LockRect(0, &rect, nullptr, 0))) - { - safe_release(texture); - return nullptr; - } - - const uint32_t argb = 0xFF000000u | (rgb & 0x00FFFFFFu); - auto* pixels = static_cast(rect.pBits); - for (uint32_t y = 0; y < TEXTURE_SIZE; ++y) - { - auto* pixel_row = reinterpret_cast(pixels + (y * rect.Pitch)); - for (uint32_t x = 0; x < TEXTURE_SIZE; ++x) - pixel_row[x] = argb; - } - if (FAILED(texture->UnlockRect(0))) - { - safe_release(texture); - return nullptr; - } - return texture; -} - -auto write_code(uintptr_t address, const void* bytes, size_t size) -> bool -{ - DWORD oldProtect{}; - if (!VirtualProtect(reinterpret_cast(address), size, PAGE_EXECUTE_READWRITE, &oldProtect)) - return false; - - CopyMemory(reinterpret_cast(address), bytes, size); - FlushInstructionCache(GetCurrentProcess(), reinterpret_cast(address), size); - - DWORD ignored{}; - VirtualProtect(reinterpret_cast(address), size, oldProtect, &ignored); - return true; + return device->DrawIndexedPrimitive( + primitive_type, min_index, num_vertices, start_index, primitive_count); } } // namespace @@ -183,237 +40,128 @@ auto write_code(uintptr_t address, const void* bytes, size_t size) -> bool namespace fluffytail { -plugin* plugin::instance_ = nullptr; - -plugin::plugin(void) +ashita_plugin::ashita_plugin() : core_{ nullptr } -, device_{ nullptr } -, actor_draw_address_{ 0 } -, actor_draw_original_{} -, actor_draw_trampoline_{ nullptr } -, current_entity_{ nullptr } -, detection_frame_{ 0 } -, submitting_tail_draw_{ false } +, runtime_{ std::make_unique() } { } -plugin::~plugin(void) +ashita_plugin::~ashita_plugin() { - this->remove_actor_draw_hook(); - this->release_tail_texture_cache(); - this->release_textures(); + if (this->runtime_ != nullptr && !this->runtime_->shutdown()) + { + // Ashita's void Release contract cannot refuse destruction. The module + // was pinned before hook installation, so retain the runtime when a + // foreign owner or thread mismatch makes unhooking unprovable. + (void)this->runtime_.release(); + } } -auto plugin::GetName(void) const -> const char* +auto ashita_plugin::GetName() const -> const char* { return "fluffytail"; } -auto plugin::GetAuthor(void) const -> const char* +auto ashita_plugin::GetAuthor() const -> const char* { return "Aeshur"; } -auto plugin::GetDescription(void) const -> const char* +auto ashita_plugin::GetDescription() const -> const char* { return "Per-face Mithra tail colour."; } -auto plugin::GetLink(void) const -> const char* +auto ashita_plugin::GetLink() const -> const char* { return "https://github.com/Aeshur/FluffyTail"; } -auto plugin::GetVersion(void) const -> double +auto ashita_plugin::GetVersion() const -> double { return 1.0; } -auto plugin::GetPriority(void) const -> int32_t +auto ashita_plugin::GetPriority() const -> int32_t { return 0; } -auto plugin::GetInterfaceVersion(void) const -> double +auto ashita_plugin::GetInterfaceVersion() const -> double { return ASHITA_INTERFACE_VERSION; } -auto plugin::GetFlags(void) const -> uint32_t +auto ashita_plugin::GetFlags() const -> uint32_t { return static_cast(Ashita::PluginFlags::UseCommands) | static_cast(Ashita::PluginFlags::UseDirect3D); } -auto plugin::Initialize(IAshitaCore* core, ILogManager* logger, const uint32_t id) -> bool +auto ashita_plugin::Initialize(IAshitaCore* core, ILogManager* logger, uint32_t id) -> bool { UNREFERENCED_PARAMETER(logger); UNREFERENCED_PARAMETER(id); + if (core == nullptr) + return false; this->core_ = core; // Retail FFXI PE timestamp 2026-07-07: the actor virtual draw entry calls this core. - // The pattern covers its setup through the first stable actor/entity accesses. - const auto actorDraw = - core->GetPointerManager()->Add("fluffytail_actor_draw", "FFXiMain.dll", "81EC2C0100005355568BF15733FF8B4670897C24143BC7897C241C" - "BB01000000741B8B882C0100008A8030010000C1E91123CB23C3" - "894C24148944241C8D8E74060000", - 0, - 0); + const auto actorDraw = core->GetPointerManager()->Add( + "fluffytail_actor_draw", + "FFXiMain.dll", + "81EC2C0100005355568BF15733FF8B4670897C24143BC7897C241C" + "BB01000000741B8B882C0100008A8030010000C1E91123CB23C3" + "894C24148944241C8D8E74060000", + 0, + 0); if (actorDraw == 0) { - std::ostringstream actorMsg; - actorMsg << Ashita::Chat::Header("fluffytail"); - actorMsg << Ashita::Chat::Error( - "sig scan FAILED: actor draw not found (client update?)"); - core->GetChatManager()->Write(1, false, actorMsg.str().c_str()); - return false; - } - if (!this->install_actor_draw_hook(actorDraw)) - { - std::ostringstream actorMsg; - actorMsg << Ashita::Chat::Header("fluffytail"); - actorMsg << Ashita::Chat::Error("actor draw hook FAILED at 0x%08X"); - core->GetChatManager()->Writef(1, false, actorMsg.str().c_str(), actorDraw); - return false; - } - - return true; -} - -auto plugin::Release(void) -> void -{ - this->remove_actor_draw_hook(); - this->release_tail_texture_cache(); - this->release_textures(); - this->device_ = nullptr; -} - -auto plugin::release_textures(void) -> void -{ - for (auto& [name, texture] : this->textures_) - safe_release(texture); - this->textures_.clear(); -} - -auto plugin::release_tail_texture_cache(void) -> void -{ - for (auto* texture : this->tail_textures_) - texture->Release(); - this->tail_textures_.clear(); - this->rejected_textures_.clear(); - this->detection_frame_ = 0; -} - -auto plugin::is_tail_texture(IDirect3DBaseTexture8* texture) -> bool -{ - if (this->tail_textures_.find(texture) != this->tail_textures_.end()) - return true; - if (this->rejected_textures_.find(texture) != this->rejected_textures_.end()) + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Error("sig scan FAILED: actor draw not found (client update?)"); + core->GetChatManager()->Write(1, false, message.str().c_str()); return false; - - if (has_tail_fingerprint(texture)) - { - texture->AddRef(); - this->tail_textures_.insert(texture); - return true; } - - this->rejected_textures_.insert(texture); - return false; -} - -auto plugin::install_actor_draw_hook(uintptr_t address) -> bool -{ - constexpr size_t PROLOGUE_SIZE = sizeof(this->actor_draw_original_); - constexpr size_t RELATIVE_JUMP_SIZE = 5; - constexpr uint8_t RELATIVE_JUMP_OPCODE = 0xE9; - constexpr size_t NOP_SIZE = 1; - constexpr uint8_t NOP_INSTRUCTION = 0x90; - constexpr size_t TRAMPOLINE_SIZE = PROLOGUE_SIZE + RELATIVE_JUMP_SIZE; - - if (instance_ != nullptr || address == 0) - return false; - - auto* trampoline = static_cast(VirtualAlloc( - nullptr, TRAMPOLINE_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); - if (trampoline == nullptr) - return false; - - CopyMemory(this->actor_draw_original_, reinterpret_cast(address), PROLOGUE_SIZE); - CopyMemory(trampoline, this->actor_draw_original_, PROLOGUE_SIZE); - trampoline[PROLOGUE_SIZE] = RELATIVE_JUMP_OPCODE; - const auto resumeDisplacement = - static_cast((address + PROLOGUE_SIZE) - - (reinterpret_cast(trampoline) + TRAMPOLINE_SIZE)); - CopyMemory(trampoline + PROLOGUE_SIZE + 1, &resumeDisplacement, sizeof(resumeDisplacement)); - FlushInstructionCache(GetCurrentProcess(), trampoline, TRAMPOLINE_SIZE); - - uint8_t patch[PROLOGUE_SIZE] = { RELATIVE_JUMP_OPCODE, 0, 0, 0, 0, 0 }; - patch[PROLOGUE_SIZE - NOP_SIZE] = NOP_INSTRUCTION; - const auto hookDisplacement = static_cast( - reinterpret_cast(&plugin::actor_draw_hook) - (address + RELATIVE_JUMP_SIZE)); - CopyMemory(patch + 1, &hookDisplacement, sizeof(hookDisplacement)); - - // Keep the trampoline until removal restores all six bytes of the game prologue. - this->actor_draw_address_ = address; - this->actor_draw_trampoline_ = trampoline; - instance_ = this; - if (!write_code(address, patch, sizeof(patch))) + if (!pin_current_module() || this->runtime_ == nullptr || + !this->runtime_->install_actor_draw_hook(actorDraw)) { - instance_ = nullptr; - this->actor_draw_address_ = 0; - this->actor_draw_trampoline_ = nullptr; - VirtualFree(trampoline, 0, MEM_RELEASE); + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Error("actor draw hook FAILED at 0x%08X"); + core->GetChatManager()->Writef(1, false, message.str().c_str(), actorDraw); return false; } return true; } -auto plugin::remove_actor_draw_hook(void) -> void +auto ashita_plugin::Release() -> void { - if (this->actor_draw_address_ != 0) - write_code(this->actor_draw_address_, this->actor_draw_original_, sizeof(this->actor_draw_original_)); - - instance_ = nullptr; - this->current_entity_ = nullptr; - this->actor_draw_address_ = 0; - if (this->actor_draw_trampoline_ != nullptr) + if (this->runtime_ != nullptr && !this->runtime_->shutdown() && this->core_ != nullptr) { - VirtualFree(this->actor_draw_trampoline_, 0, MEM_RELEASE); - this->actor_draw_trampoline_ = nullptr; + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Error("unload blocked: actor hook ownership changed"); + this->core_->GetChatManager()->Write(1, false, message.str().c_str()); } + this->core_ = nullptr; } -void __fastcall plugin::actor_draw_hook(void* actor, void* edx) -{ - UNREFERENCED_PARAMETER(edx); - - // Save and restore this draw's entity so nested draws see their own actor. - auto* self = instance_; - auto* previous = self->current_entity_; - constexpr size_t ACTOR_ENTITY_OFFSET = 0x70; - self->current_entity_ = *reinterpret_cast( - static_cast(actor) + ACTOR_ENTITY_OFFSET); - - const auto original = reinterpret_cast(self->actor_draw_trampoline_); - original(actor); - self->current_entity_ = previous; -} - -auto plugin::HandleEvent(const char* event_name, const void* event_data, const uint32_t event_size) -> void +auto ashita_plugin::HandleEvent(const char* event_name, + const void* event_data, + uint32_t event_size) -> void { UNREFERENCED_PARAMETER(event_name); UNREFERENCED_PARAMETER(event_data); UNREFERENCED_PARAMETER(event_size); } -auto plugin::HandleCommand(int32_t mode, const char* command, bool injected) -> bool +auto ashita_plugin::HandleCommand(int32_t mode, const char* command, bool injected) -> bool { UNREFERENCED_PARAMETER(mode); UNREFERENCED_PARAMETER(injected); - - if (command == nullptr) + if (command == nullptr || this->core_ == nullptr) return false; std::istringstream input(command); @@ -425,40 +173,72 @@ auto plugin::HandleCommand(int32_t mode, const char* command, bool injected) -> if (_stricmp(action.c_str(), "inspect") != 0) { - std::ostringstream msg; - msg << Ashita::Chat::Header("fluffytail") - << Ashita::Chat::Message("usage: /fluffytail inspect"); - this->core_->GetChatManager()->Write(1, false, msg.str().c_str()); + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Message("usage: /fluffytail inspect"); + this->core_->GetChatManager()->Write(1, false, message.str().c_str()); + return true; + } + + const auto index = this->core_->GetMemoryManager()->GetTarget()->GetTargetIndex(0); + if (index == 0) + { + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Error("inspect failed: no target"); + this->core_->GetChatManager()->Write(1, false, message.str().c_str()); return true; } - const auto index = this->core_->GetMemoryManager()->GetTarget()->GetTargetIndex(0); const auto* entity = this->core_->GetMemoryManager()->GetEntity()->GetRawEntity(index); - if (index == 0 || entity == nullptr) + if (entity == nullptr) { - std::ostringstream msg; - msg << Ashita::Chat::Header("fluffytail") - << Ashita::Chat::Error("inspect failed: no target"); - this->core_->GetChatManager()->Write(1, false, msg.str().c_str()); + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Error("inspect failed: no target"); + this->core_->GetChatManager()->Write(1, false, message.str().c_str()); return true; } const auto* name = this->core_->GetMemoryManager()->GetEntity()->GetName(index); - std::ostringstream msg; - msg << Ashita::Chat::Header("fluffytail") - << Ashita::Chat::Message("inspect %s idx=%u sid=0x%08X type=%u race=%u actor=0x%08X"); - this->core_->GetChatManager()->Writef(1, false, msg.str().c_str(), name ? name : "?", index, entity->ServerId, entity->Type, entity->Race, static_cast(entity->ActorPointer)); - - std::ostringstream lookMsg; - lookMsg << Ashita::Chat::Header("fluffytail") + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") << Ashita::Chat::Message( - "look hair=%04X head=%04X body=%04X hands=%04X legs=%04X feet=%04X"); - this->core_->GetChatManager()->Writef( - 1, false, lookMsg.str().c_str(), entity->Look.Hair, entity->Look.Head, entity->Look.Body, entity->Look.Hands, entity->Look.Legs, entity->Look.Feet); + "inspect %s idx=%u sid=0x%08X type=%u race=%u actor=0x%08X"); + this->core_->GetChatManager()->Writef(1, + false, + message.str().c_str(), + name ? name : "?", + index, + entity->ServerId, + entity->Type, + entity->Race, + static_cast(entity->ActorPointer)); + + std::ostringstream lookMessage; + lookMessage << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Message( + "look hair=%04X head=%04X body=%04X hands=%04X legs=%04X feet=%04X"); + this->core_->GetChatManager()->Writef(1, + false, + lookMessage.str().c_str(), + entity->Look.Hair, + entity->Look.Head, + entity->Look.Body, + entity->Look.Hands, + entity->Look.Legs, + entity->Look.Feet); return true; } -auto plugin::HandleIncomingText(int32_t mode, bool indent, const char* message, int32_t* modified_mode, bool* modified_indent, char* modified_message, bool injected, bool blocked) -> bool +auto ashita_plugin::HandleIncomingText(int32_t mode, + bool indent, + const char* message, + int32_t* modified_mode, + bool* modified_indent, + char* modified_message, + bool injected, + bool blocked) -> bool { UNREFERENCED_PARAMETER(mode); UNREFERENCED_PARAMETER(indent); @@ -471,7 +251,12 @@ auto plugin::HandleIncomingText(int32_t mode, bool indent, const char* message, return false; } -auto plugin::HandleOutgoingText(int32_t mode, const char* message, int32_t* modified_mode, char* modified_message, bool injected, bool blocked) -> bool +auto ashita_plugin::HandleOutgoingText(int32_t mode, + const char* message, + int32_t* modified_mode, + char* modified_message, + bool injected, + bool blocked) -> bool { UNREFERENCED_PARAMETER(mode); UNREFERENCED_PARAMETER(message); @@ -482,8 +267,14 @@ auto plugin::HandleOutgoingText(int32_t mode, const char* message, int32_t* modi return false; } -auto plugin::HandleIncomingPacket(uint16_t id, uint32_t size, const uint8_t* data, uint8_t* modified, uint32_t size_chunk, const uint8_t* data_chunk, bool injected, bool blocked) - -> bool +auto ashita_plugin::HandleIncomingPacket(uint16_t id, + uint32_t size, + const uint8_t* data, + uint8_t* modified, + uint32_t size_chunk, + const uint8_t* data_chunk, + bool injected, + bool blocked) -> bool { UNREFERENCED_PARAMETER(id); UNREFERENCED_PARAMETER(size); @@ -496,8 +287,14 @@ auto plugin::HandleIncomingPacket(uint16_t id, uint32_t size, const uint8_t* dat return false; } -auto plugin::HandleOutgoingPacket(uint16_t id, uint32_t size, const uint8_t* data, uint8_t* modified, uint32_t size_chunk, const uint8_t* data_chunk, bool injected, bool blocked) - -> bool +auto ashita_plugin::HandleOutgoingPacket(uint16_t id, + uint32_t size, + const uint8_t* data, + uint8_t* modified, + uint32_t size_chunk, + const uint8_t* data_chunk, + bool injected, + bool blocked) -> bool { UNREFERENCED_PARAMETER(id); UNREFERENCED_PARAMETER(size); @@ -510,65 +307,65 @@ auto plugin::HandleOutgoingPacket(uint16_t id, uint32_t size, const uint8_t* dat return false; } -auto plugin::Direct3DInitialize(IDirect3DDevice8* device) -> bool +auto ashita_plugin::Direct3DInitialize(IDirect3DDevice8* device) -> bool { - this->release_tail_texture_cache(); - this->device_ = device; - - // Direct3DInitialize may run again after a device reset. - this->release_textures(); - for (const auto& c : COLOURS) + try { - if (auto* tex = make_texture(device, c.rgb); tex != nullptr) - this->textures_[c.name] = tex; + if (this->runtime_ != nullptr && this->runtime_->initialize_device(device)) + return true; } - - constexpr size_t COLOUR_COUNT = sizeof(COLOURS) / sizeof(COLOURS[0]); - if (this->textures_.size() != COLOUR_COUNT) + catch (...) { - std::ostringstream msg; - msg << Ashita::Chat::Header("fluffytail") - << Ashita::Chat::Error("texture creation FAILED: loaded %zu/%zu colours"); - this->core_->GetChatManager()->Writef(1, false, msg.str().c_str(), this->textures_.size(), COLOUR_COUNT); - return false; } - return true; + if (this->core_ != nullptr) + { + std::ostringstream message; + message << Ashita::Chat::Header("fluffytail") + << Ashita::Chat::Error("texture creation FAILED"); + this->core_->GetChatManager()->Write(1, false, message.str().c_str()); + } + return false; } -auto plugin::Direct3DBeginScene(bool is_rendering_back_buffer) -> void +auto ashita_plugin::Direct3DBeginScene(bool is_rendering_back_buffer) -> void { - constexpr uint32_t REJECTED_TEXTURE_EXPIRY_FRAMES = 600; - if (is_rendering_back_buffer && ++this->detection_frame_ >= REJECTED_TEXTURE_EXPIRY_FRAMES) + try + { + if (is_rendering_back_buffer && this->runtime_ != nullptr) + this->runtime_->begin_frame(); + } + catch (...) { - // Negative entries do not own a COM reference, so periodically discard them - // in case FFXI reused an address after unloading a model texture. - this->rejected_textures_.clear(); - this->detection_frame_ = 0; } } -auto plugin::Direct3DEndScene(bool is_rendering_back_buffer) -> void +auto ashita_plugin::Direct3DEndScene(bool is_rendering_back_buffer) -> void { UNREFERENCED_PARAMETER(is_rendering_back_buffer); } -auto plugin::Direct3DPresent(const RECT* p_source_rect, const RECT* p_dest_rect, HWND h_dest_window_override, const RGNDATA* p_dirty_region) -> void +auto ashita_plugin::Direct3DPresent(const RECT* source_rect, + const RECT* dest_rect, + HWND dest_window_override, + const RGNDATA* dirty_region) -> void { - UNREFERENCED_PARAMETER(p_source_rect); - UNREFERENCED_PARAMETER(p_dest_rect); - UNREFERENCED_PARAMETER(h_dest_window_override); - UNREFERENCED_PARAMETER(p_dirty_region); + UNREFERENCED_PARAMETER(source_rect); + UNREFERENCED_PARAMETER(dest_rect); + UNREFERENCED_PARAMETER(dest_window_override); + UNREFERENCED_PARAMETER(dirty_region); } -auto plugin::Direct3DSetRenderState(D3DRENDERSTATETYPE state, DWORD* value) -> bool +auto ashita_plugin::Direct3DSetRenderState(D3DRENDERSTATETYPE state, DWORD* value) -> bool { UNREFERENCED_PARAMETER(state); UNREFERENCED_PARAMETER(value); return false; } -auto plugin::Direct3DDrawPrimitive(D3DPRIMITIVETYPE primitive_type, UINT start_vertex, UINT primitive_count) -> bool +auto ashita_plugin::Direct3DDrawPrimitive(D3DPRIMITIVETYPE primitive_type, + UINT start_vertex, + UINT primitive_count) -> bool { UNREFERENCED_PARAMETER(primitive_type); UNREFERENCED_PARAMETER(start_vertex); @@ -576,48 +373,27 @@ auto plugin::Direct3DDrawPrimitive(D3DPRIMITIVETYPE primitive_type, UINT start_v return false; } -auto plugin::Direct3DDrawIndexedPrimitive(D3DPRIMITIVETYPE primitive_type, UINT min_index, UINT num_vertices, UINT start_index, UINT primitive_count) -> bool +auto ashita_plugin::Direct3DDrawIndexedPrimitive(D3DPRIMITIVETYPE primitive_type, + UINT min_index, + UINT num_vertices, + UINT start_index, + UINT primitive_count) -> bool { - if (this->submitting_tail_draw_ || this->device_ == nullptr || - this->current_entity_ == nullptr || this->current_entity_->Race != RACE_MITHRA) - return false; - - const auto hair = static_cast(this->current_entity_->Look.Hair & 0xFF); - if (hair >= 16) - return false; - - const auto* colour = face_colour(hair); - const auto replacement = this->textures_.find(colour ? colour : ""); - if (replacement == this->textures_.end()) - return false; - - IDirect3DBaseTexture8* bound = nullptr; - if (FAILED(this->device_->GetTexture(0, &bound)) || bound == nullptr) - return false; - if (!this->is_tail_texture(bound)) - { - bound->Release(); - return false; - } - - if (FAILED(this->device_->SetTexture(0, replacement->second))) - { - bound->Release(); - return false; - } - - // Submit this one draw ourselves so the original texture can be restored before - // any later game draw. The guard lets Ashita's recursive callback pass through. - this->submitting_tail_draw_ = true; - this->device_->DrawIndexedPrimitive(primitive_type, min_index, num_vertices, start_index, primitive_count); - this->submitting_tail_draw_ = false; - this->device_->SetTexture(0, bound); - bound->Release(); - - return true; // The replacement draw was already submitted. + const indexed_draw_args args{ + primitive_type, + min_index, + num_vertices, + start_index, + primitive_count, + }; + return this->runtime_ != nullptr && + this->runtime_->try_draw_indexed(args, &submit_ashita_draw).handled; } -auto plugin::Direct3DDrawPrimitiveUP(D3DPRIMITIVETYPE primitive_type, UINT primitive_count, CONST void* vertex_stream_zero_data, UINT vertex_stream_zero_stride) -> bool +auto ashita_plugin::Direct3DDrawPrimitiveUP(D3DPRIMITIVETYPE primitive_type, + UINT primitive_count, + const void* vertex_stream_zero_data, + UINT vertex_stream_zero_stride) -> bool { UNREFERENCED_PARAMETER(primitive_type); UNREFERENCED_PARAMETER(primitive_count); @@ -626,14 +402,14 @@ auto plugin::Direct3DDrawPrimitiveUP(D3DPRIMITIVETYPE primitive_type, UINT primi return false; } -auto plugin::Direct3DDrawIndexedPrimitiveUP(D3DPRIMITIVETYPE primitive_type, - UINT min_vertex_index, - UINT num_vertex_indices, - UINT primitive_count, - CONST void* index_data, - D3DFORMAT index_data_format, - CONST void* vertex_stream_zero_data, - UINT vertex_stream_zero_stride) -> bool +auto ashita_plugin::Direct3DDrawIndexedPrimitiveUP(D3DPRIMITIVETYPE primitive_type, + UINT min_vertex_index, + UINT num_vertex_indices, + UINT primitive_count, + const void* index_data, + D3DFORMAT index_data_format, + const void* vertex_stream_zero_data, + UINT vertex_stream_zero_stride) -> bool { UNREFERENCED_PARAMETER(primitive_type); UNREFERENCED_PARAMETER(min_vertex_index); @@ -648,18 +424,18 @@ auto plugin::Direct3DDrawIndexedPrimitiveUP(D3DPRIMITIVETYPE primitive_type, } // namespace fluffytail -__declspec(dllexport) auto __stdcall expCreatePlugin(const char* args) -> IPlugin* +extern "C" __declspec(dllexport) auto __stdcall expCreatePlugin(const char* args) -> IPlugin* { UNREFERENCED_PARAMETER(args); - return new fluffytail::plugin(); + return new fluffytail::ashita_plugin(); } -__declspec(dllexport) auto __stdcall expDestroyPlugin(void* instance) -> void +extern "C" __declspec(dllexport) auto __stdcall expDestroyPlugin(void* instance) -> void { - delete static_cast(instance); + delete static_cast(instance); } -__declspec(dllexport) auto __stdcall expGetInterfaceVersion(void) -> double +extern "C" __declspec(dllexport) auto __stdcall expGetInterfaceVersion() -> double { return ASHITA_INTERFACE_VERSION; } diff --git a/src/fluffytail.hpp b/src/fluffytail.hpp index 46a0e1d..5ed71fa 100644 --- a/src/fluffytail.hpp +++ b/src/fluffytail.hpp @@ -1,5 +1,5 @@ /** - * FluffyTail - per-face Mithra tail colour plugin for Ashita v4.3. + * FluffyTail - Ashita v4.3 host adapter. * * Derived from the Ashita v4 example plugin (src/exampleplugin.hpp), * Copyright (c) 2025 Ashita Development Team, GNU LGPL v3. Modifications for @@ -10,75 +10,97 @@ #pragma once #include "defines.hpp" +#include "render_runtime.hpp" + +#include namespace fluffytail { -class plugin final : public IPlugin +class ashita_plugin final : public IPlugin { - IAshitaCore* core_; - IDirect3DDevice8* device_; - - using actor_draw_fn = void(__thiscall*)(void*); - - static plugin* instance_; - uintptr_t actor_draw_address_; - uint8_t actor_draw_original_[6]; - void* actor_draw_trampoline_; - Ashita::FFXI::entity_t* current_entity_; - uint32_t detection_frame_; - bool submitting_tail_draw_; - - std::unordered_map textures_; - std::unordered_set tail_textures_; - std::unordered_set rejected_textures_; - - auto release_textures(void) -> void; - auto release_tail_texture_cache(void) -> void; - auto is_tail_texture(IDirect3DBaseTexture8* texture) -> bool; - auto install_actor_draw_hook(uintptr_t address) -> bool; - auto remove_actor_draw_hook(void) -> void; - static void __fastcall actor_draw_hook(void* actor, void* edx); + IAshitaCore* core_; + std::unique_ptr runtime_; public: - plugin(void); - ~plugin(void) override; - - auto GetName(void) const -> const char* override; - auto GetAuthor(void) const -> const char* override; - auto GetDescription(void) const -> const char* override; - auto GetLink(void) const -> const char* override; - auto GetVersion(void) const -> double override; - auto GetInterfaceVersion(void) const -> double override; - auto GetPriority(void) const -> int32_t override; - auto GetFlags(void) const -> uint32_t override; + ashita_plugin(); + ~ashita_plugin() override; + + auto GetName() const -> const char* override; + auto GetAuthor() const -> const char* override; + auto GetDescription() const -> const char* override; + auto GetLink() const -> const char* override; + auto GetVersion() const -> double override; + auto GetInterfaceVersion() const -> double override; + auto GetPriority() const -> int32_t override; + auto GetFlags() const -> uint32_t override; auto Initialize(IAshitaCore* core, ILogManager* logger, uint32_t id) -> bool override; - auto Release(void) -> void override; - - auto HandleEvent(const char* event_name, const void* event_data, const uint32_t event_size) - -> void override; + auto Release() -> void override; + auto HandleEvent(const char* event_name, + const void* event_data, + uint32_t event_size) -> void override; auto HandleCommand(int32_t mode, const char* command, bool injected) -> bool override; - auto HandleIncomingText(int32_t mode, bool indent, const char* message, int32_t* modified_mode, bool* modified_indent, char* modified_message, bool injected, bool blocked) - -> bool override; - auto HandleOutgoingText(int32_t mode, const char* message, int32_t* modified_mode, char* modified_message, bool injected, bool blocked) - -> bool override; - - auto HandleIncomingPacket(uint16_t id, uint32_t size, const uint8_t* data, uint8_t* modified, uint32_t size_chunk, const uint8_t* data_chunk, bool injected, bool blocked) -> bool override; - auto HandleOutgoingPacket(uint16_t id, uint32_t size, const uint8_t* data, uint8_t* modified, uint32_t size_chunk, const uint8_t* data_chunk, bool injected, bool blocked) -> bool override; + auto HandleIncomingText(int32_t mode, + bool indent, + const char* message, + int32_t* modified_mode, + bool* modified_indent, + char* modified_message, + bool injected, + bool blocked) -> bool override; + auto HandleOutgoingText(int32_t mode, + const char* message, + int32_t* modified_mode, + char* modified_message, + bool injected, + bool blocked) -> bool override; + auto HandleIncomingPacket(uint16_t id, + uint32_t size, + const uint8_t* data, + uint8_t* modified, + uint32_t size_chunk, + const uint8_t* data_chunk, + bool injected, + bool blocked) -> bool override; + auto HandleOutgoingPacket(uint16_t id, + uint32_t size, + const uint8_t* data, + uint8_t* modified, + uint32_t size_chunk, + const uint8_t* data_chunk, + bool injected, + bool blocked) -> bool override; auto Direct3DInitialize(IDirect3DDevice8* device) -> bool override; auto Direct3DBeginScene(bool is_rendering_back_buffer) -> void override; auto Direct3DEndScene(bool is_rendering_back_buffer) -> void override; - auto Direct3DPresent(const RECT* p_source_rect, const RECT* p_dest_rect, HWND h_dest_window_override, const RGNDATA* p_dirty_region) - -> void override; + auto Direct3DPresent(const RECT* source_rect, + const RECT* dest_rect, + HWND dest_window_override, + const RGNDATA* dirty_region) -> void override; auto Direct3DSetRenderState(D3DRENDERSTATETYPE state, DWORD* value) -> bool override; - auto Direct3DDrawPrimitive(D3DPRIMITIVETYPE primitive_type, UINT start_vertex, UINT primitive_count) -> bool override; - auto Direct3DDrawIndexedPrimitive(D3DPRIMITIVETYPE primitive_type, UINT min_index, UINT num_vertices, UINT start_index, UINT primitive_count) - -> bool override; - auto Direct3DDrawPrimitiveUP(D3DPRIMITIVETYPE primitive_type, UINT primitive_count, CONST void* vertex_stream_zero_data, UINT vertex_stream_zero_stride) -> bool override; - auto Direct3DDrawIndexedPrimitiveUP(D3DPRIMITIVETYPE primitive_type, UINT min_vertex_index, UINT num_vertex_indices, UINT primitive_count, CONST void* index_data, D3DFORMAT index_data_format, CONST void* vertex_stream_zero_data, UINT vertex_stream_zero_stride) -> bool override; + auto Direct3DDrawPrimitive(D3DPRIMITIVETYPE primitive_type, + UINT start_vertex, + UINT primitive_count) -> bool override; + auto Direct3DDrawIndexedPrimitive(D3DPRIMITIVETYPE primitive_type, + UINT min_index, + UINT num_vertices, + UINT start_index, + UINT primitive_count) -> bool override; + auto Direct3DDrawPrimitiveUP(D3DPRIMITIVETYPE primitive_type, + UINT primitive_count, + const void* vertex_stream_zero_data, + UINT vertex_stream_zero_stride) -> bool override; + auto Direct3DDrawIndexedPrimitiveUP(D3DPRIMITIVETYPE primitive_type, + UINT min_vertex_index, + UINT num_vertex_indices, + UINT primitive_count, + const void* index_data, + D3DFORMAT index_data_format, + const void* vertex_stream_zero_data, + UINT vertex_stream_zero_stride) -> bool override; }; } // namespace fluffytail diff --git a/src/hook_utils.cpp b/src/hook_utils.cpp new file mode 100644 index 0000000..9831584 --- /dev/null +++ b/src/hook_utils.cpp @@ -0,0 +1,196 @@ +/** + * FluffyTail - process signature and owned-pointer patch helpers. + * + * Copyright (c) 2026 Aeshur. GNU LGPL v3. See LICENSE.md. + */ + +#include "hook_utils.hpp" + +#include +#include +#include + +namespace +{ + +auto readable_region(const MEMORY_BASIC_INFORMATION& info) -> bool +{ + constexpr DWORD BLOCKED = PAGE_GUARD | PAGE_NOACCESS; + return info.State == MEM_COMMIT && (info.Protect & BLOCKED) == 0; +} + +auto patch_pointer(void** slot, void* expected, void* replacement) + -> fluffytail::slot_patch_result +{ + if (slot == nullptr || (reinterpret_cast(slot) % alignof(void*)) != 0) + return { false, true, nullptr }; + + SYSTEM_INFO systemInfo{}; + GetSystemInfo(&systemInfo); + const auto pageOffset = reinterpret_cast(slot) % systemInfo.dwPageSize; + if (pageOffset > systemInfo.dwPageSize - sizeof(void*)) + return { false, true, nullptr }; + + DWORD oldProtect{}; + if (!VirtualProtect(slot, sizeof(void*), PAGE_READWRITE, &oldProtect)) + return { false, false, nullptr }; + + auto* prior = InterlockedCompareExchangePointer( + reinterpret_cast(slot), replacement, expected); + + DWORD ignored{}; + bool protectionRestored = + VirtualProtect(slot, sizeof(void*), oldProtect, &ignored) != FALSE; + if (!protectionRestored) + protectionRestored = + VirtualProtect(slot, sizeof(void*), oldProtect, &ignored) != FALSE; + return { prior == expected, protectionRestored, prior }; +} + +} // namespace + +namespace fluffytail +{ + +auto pin_current_module() noexcept -> bool +{ + HMODULE module{}; + return GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(&pin_current_module), + &module) != FALSE; +} + +auto find_unique_bytes(const uint8_t* begin, + size_t size, + const uint8_t* pattern, + size_t pattern_size) -> const uint8_t* +{ + if (begin == nullptr || pattern == nullptr || pattern_size == 0 || pattern_size > size) + return nullptr; + + const auto* first = std::search(begin, begin + size, pattern, pattern + pattern_size); + if (first == begin + size) + return nullptr; + + const auto* second = std::search(first + 1, begin + size, pattern, pattern + pattern_size); + return second == begin + size ? first : nullptr; +} + +auto find_module_signature(const char* module_name, + const uint8_t* pattern, + size_t pattern_size) -> uintptr_t +{ + const auto module = GetModuleHandleA(module_name); + if (module == nullptr || pattern == nullptr || pattern_size == 0) + return 0; + + const auto* base = reinterpret_cast(module); + const auto* dos = reinterpret_cast(base); + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0) + return 0; + + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.SizeOfImage == 0) + return 0; + + const auto* imageEnd = base + nt->OptionalHeader.SizeOfImage; + const uint8_t* match = nullptr; + const auto* sections = IMAGE_FIRST_SECTION(nt); + for (uint16_t sectionIndex = 0; sectionIndex < nt->FileHeader.NumberOfSections; + ++sectionIndex) + { + const auto& section = sections[sectionIndex]; + if ((section.Characteristics & IMAGE_SCN_MEM_EXECUTE) == 0) + continue; + + const auto sectionSize = (std::max)(section.Misc.VirtualSize, section.SizeOfRawData); + const auto* sectionBegin = base + section.VirtualAddress; + const auto* sectionEnd = (std::min)(imageEnd, sectionBegin + sectionSize); + for (const uint8_t* cursor = sectionBegin; cursor < sectionEnd;) + { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(cursor, &info, sizeof(info)) != sizeof(info)) + return 0; + + const auto* regionBegin = + (std::max)(cursor, static_cast(info.BaseAddress)); + const auto* regionEnd = (std::min)(sectionEnd, static_cast(info.BaseAddress) + info.RegionSize); + if (readable_region(info) && regionEnd > regionBegin && + static_cast(regionEnd - regionBegin) >= pattern_size) + { + const auto* local = + std::search(regionBegin, regionEnd, pattern, pattern + pattern_size); + if (local != regionEnd) + { + if (match != nullptr || + std::search(local + 1, regionEnd, pattern, pattern + pattern_size) != + regionEnd) + return 0; + match = local; + } + } + cursor = regionEnd; + } + } + return reinterpret_cast(match); +} + +auto write_executable(uintptr_t address, const void* bytes, size_t size) + -> executable_patch_result +{ + if (address == 0 || bytes == nullptr || size == 0) + return { false, false, true }; + + DWORD oldProtect{}; + if (!VirtualProtect(reinterpret_cast(address), size, PAGE_EXECUTE_READWRITE, &oldProtect)) + return { false, false, false }; + + bool bytesWritten = false; + bool flushed = false; + __try + { + CopyMemory(reinterpret_cast(address), bytes, size); + flushed = FlushInstructionCache( + GetCurrentProcess(), reinterpret_cast(address), size) != FALSE; + bytesWritten = + std::memcmp(reinterpret_cast(address), bytes, size) == 0; + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + } + + DWORD ignored{}; + bool restored = VirtualProtect( + reinterpret_cast(address), size, oldProtect, &ignored) != FALSE; + if (!restored) + restored = VirtualProtect( + reinterpret_cast(address), size, oldProtect, &ignored) != FALSE; + return { bytesWritten, flushed, restored }; +} + +auto compare_bytes(uintptr_t address, const void* bytes, size_t size) -> bool +{ + if (address == 0 || bytes == nullptr || size == 0) + return false; + __try + { + return std::memcmp(reinterpret_cast(address), bytes, size) == 0; + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + return false; + } +} + +auto replace_vtable_slot(void** slot, void* expected, void* replacement) -> slot_patch_result +{ + return patch_pointer(slot, expected, replacement); +} + +auto restore_vtable_slot(void** slot, void* replacement, void* previous) -> slot_patch_result +{ + return patch_pointer(slot, replacement, previous); +} + +} // namespace fluffytail diff --git a/src/hook_utils.hpp b/src/hook_utils.hpp new file mode 100644 index 0000000..610df16 --- /dev/null +++ b/src/hook_utils.hpp @@ -0,0 +1,48 @@ +/** + * FluffyTail - process signature and owned-pointer patch helpers. + * + * Copyright (c) 2026 Aeshur. GNU LGPL v3. See LICENSE.md. + */ + +#pragma once + +#include + +#include +#include + +namespace fluffytail +{ + +struct slot_patch_result +{ + bool exchanged; + bool protection_restored; + void* observed; +}; + +struct executable_patch_result +{ + bool bytes_written; + bool cache_flushed; + bool protection_restored; +}; + +auto pin_current_module() noexcept -> bool; + +auto find_unique_bytes(const uint8_t* begin, + size_t size, + const uint8_t* pattern, + size_t pattern_size) -> const uint8_t*; +auto find_module_signature(const char* module_name, + const uint8_t* pattern, + size_t pattern_size) -> uintptr_t; + +auto write_executable(uintptr_t address, const void* bytes, size_t size) + -> executable_patch_result; +auto compare_bytes(uintptr_t address, const void* bytes, size_t size) -> bool; + +auto replace_vtable_slot(void** slot, void* expected, void* replacement) -> slot_patch_result; +auto restore_vtable_slot(void** slot, void* replacement, void* previous) -> slot_patch_result; + +} // namespace fluffytail diff --git a/src/render_runtime.cpp b/src/render_runtime.cpp new file mode 100644 index 0000000..4ab3d83 --- /dev/null +++ b/src/render_runtime.cpp @@ -0,0 +1,763 @@ +/** + * FluffyTail - shared actor and Direct3D8 render runtime. + * + * Copyright (c) 2026 Aeshur. GNU LGPL v3. See LICENSE.md. + */ + +#include "render_runtime.hpp" + +#include "hook_utils.hpp" +#include "tail_policy.hpp" + +#include +#include + +namespace +{ + +constexpr size_t ACTOR_ENTITY_OFFSET = 0x70; +constexpr size_t ENTITY_RACE_OFFSET = 0xEF; +constexpr size_t ENTITY_LOOK_OFFSET = 0xFC; + +struct raw_actor_snapshot +{ + bool valid; + uint8_t race; + uint8_t hair; +}; + +auto read_raw_actor_snapshot(void* actor, raw_actor_snapshot& out) noexcept -> bool +{ + out = { false, 0, 0 }; + if (actor == nullptr) + return false; + + __try + { + auto* entity = *reinterpret_cast( + static_cast(actor) + ACTOR_ENTITY_OFFSET); + if (entity == nullptr) + return false; + + const auto* bytes = static_cast(entity); + uint16_t look{}; + CopyMemory(&look, bytes + ENTITY_LOOK_OFFSET, sizeof(look)); + out = { true, bytes[ENTITY_RACE_OFFSET], static_cast(look & 0xFF) }; + return true; + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + out = { false, 0, 0 }; + return false; + } +} + +template +auto safe_release(T*& resource) -> void +{ + if (resource != nullptr) + { + auto* owned = resource; + resource = nullptr; + try + { + owned->Release(); + } + catch (...) + { + } + } +} + +auto is_tail_block(const uint8_t* block) -> bool +{ + for (size_t index = 0; index < 8; ++index) + { + if (block[index] != 0xFF) + return false; + } + for (size_t index = 12; index < 16; ++index) + { + if (block[index] != 0) + return false; + } + + uint16_t endpoint{}; + uint16_t other{}; + CopyMemory(&endpoint, block + 8, sizeof(endpoint)); + CopyMemory(&other, block + 10, sizeof(other)); + return other == 0xFFFF && endpoint == fluffytail::rgb565(fluffytail::BASELINE_RGB); +} + +struct texture_lock_scope final +{ + IDirect3DTexture8* texture; + bool locked; + + auto unlock() noexcept -> HRESULT + { + if (!locked) + return D3D_OK; + HRESULT result = D3DERR_DRIVERINTERNALERROR; + try + { + result = texture->UnlockRect(0); + if (SUCCEEDED(result)) + locked = false; + } + catch (...) + { + } + return result; + } + + ~texture_lock_scope() noexcept + { + for (size_t attempt = 0; locked && attempt < 2; ++attempt) + (void)this->unlock(); + } +}; + +auto has_tail_fingerprint(IDirect3DBaseTexture8* source_texture) -> bool +{ + constexpr size_t TEXTURE_WIDTH = 256; + constexpr size_t TEXTURE_HEIGHT = 256; + constexpr size_t BLOCK_DIMENSION = 4; + constexpr size_t BLOCK_SIZE = 16; + constexpr size_t BLOCKS_PER_ROW = TEXTURE_WIDTH / BLOCK_DIMENSION; + constexpr size_t BLOCK_ROWS = TEXTURE_HEIGHT / BLOCK_DIMENSION; + + if (source_texture == nullptr || source_texture->GetType() != D3DRTYPE_TEXTURE) + return false; + + auto* texture = static_cast(source_texture); + D3DSURFACE_DESC desc{}; + if (FAILED(texture->GetLevelDesc(0, &desc)) || desc.Width != TEXTURE_WIDTH || + desc.Height != TEXTURE_HEIGHT || desc.Format != D3DFMT_DXT3) + return false; + + D3DLOCKED_RECT rect{}; + if (FAILED(texture->LockRect(0, &rect, nullptr, D3DLOCK_READONLY))) + return false; + texture_lock_scope lockScope{ texture, true }; + + const auto* first = static_cast(rect.pBits); + bool matches = first != nullptr && + rect.Pitch >= static_cast(BLOCK_SIZE * BLOCKS_PER_ROW) && + is_tail_block(first); + for (size_t row = 0; matches && row < BLOCK_ROWS; ++row) + { + const auto* scan = first + (row * rect.Pitch); + for (size_t column = 0; column < BLOCKS_PER_ROW; ++column) + { + if (std::memcmp(scan + (column * BLOCK_SIZE), first, BLOCK_SIZE) != 0) + { + matches = false; + break; + } + } + } + return SUCCEEDED(lockScope.unlock()) && matches; +} + +auto make_texture(IDirect3DDevice8* device, uint32_t rgb) -> IDirect3DTexture8* +{ + constexpr uint32_t TEXTURE_SIZE = 8; + if (device == nullptr) + return nullptr; + + IDirect3DTexture8* texture = nullptr; + if (FAILED(device->CreateTexture(TEXTURE_SIZE, + TEXTURE_SIZE, + 1, + 0, + D3DFMT_A8R8G8B8, + D3DPOOL_MANAGED, + &texture))) + return nullptr; + + D3DLOCKED_RECT rect{}; + if (FAILED(texture->LockRect(0, &rect, nullptr, 0))) + { + safe_release(texture); + return nullptr; + } + texture_lock_scope lockScope{ texture, true }; + + const uint32_t argb = 0xFF000000u | (rgb & 0x00FFFFFFu); + auto* pixels = static_cast(rect.pBits); + for (uint32_t y = 0; y < TEXTURE_SIZE; ++y) + { + auto* pixelRow = reinterpret_cast(pixels + (y * rect.Pitch)); + for (uint32_t x = 0; x < TEXTURE_SIZE; ++x) + pixelRow[x] = argb; + } + if (FAILED(lockScope.unlock())) + { + // The lock guard must finish all retries while the COM object is live. + // If the driver still refuses to unlock, retain the texture rather than + // releasing an object whose locked surface may still be in use. + (void)lockScope.unlock(); + if (lockScope.locked) + { + lockScope.locked = false; + return nullptr; + } + safe_release(texture); + return nullptr; + } + return texture; +} + +struct draw_scope final +{ + uint32_t& depth; + + ~draw_scope() noexcept + { + --depth; + } +}; + +struct actor_entry_scope final +{ + std::atomic_uint& entries; + uint32_t& depth; + + ~actor_entry_scope() noexcept + { + --depth; + entries.fetch_sub(1, std::memory_order_acq_rel); + } +}; + +struct texture_scope final +{ + IDirect3DBaseTexture8* original; + + ~texture_scope() noexcept + { + if (original != nullptr) + { + try + { + original->Release(); + } + catch (...) + { + } + } + } +}; + +auto restore_texture(IDirect3DDevice8* device, IDirect3DBaseTexture8* texture) noexcept -> HRESULT +{ + HRESULT result = D3DERR_DRIVERINTERNALERROR; + for (size_t attempt = 0; attempt < 2; ++attempt) + { + try + { + result = device->SetTexture(0, texture); + if (SUCCEEDED(result)) + return result; + } + catch (...) + { + } + } + return result; +} + +} // namespace + +namespace fluffytail +{ + +static_assert((sizeof(COLOURS) / sizeof(COLOURS[0])) == 6, + "replacement texture slots must match the calibrated colours"); + +std::atomic render_runtime::instance_{ nullptr }; +std::atomic_uint render_runtime::hook_entries_{ 0 }; +thread_local uint32_t render_runtime::actor_hook_depth_ = 0; +thread_local uint32_t render_runtime::draw_depth_ = 0; +thread_local render_runtime::actor_snapshot render_runtime::actor_snapshot_{ false, 0, 0 }; + +render_runtime::render_runtime() +: actor_draw_address_{ 0 } +, actor_draw_original_{} +, actor_draw_patch_{} +, actor_draw_trampoline_{ nullptr } +, stopping_{ false } +, device_{ nullptr } +, device_identity_{ nullptr } +, owner_thread_id_{ 0 } +, reset_pending_{ false } +, replacement_textures_{} +, detection_frame_{ 0 } +{ +} + +render_runtime::~render_runtime() +{ + this->shutdown(); +} + +auto render_runtime::install_actor_draw_hook(uintptr_t address) -> bool +{ + constexpr size_t PROLOGUE_SIZE = sizeof(this->actor_draw_original_); + constexpr size_t RELATIVE_JUMP_SIZE = 5; + constexpr uint8_t RELATIVE_JUMP_OPCODE = 0xE9; + constexpr uint8_t NOP_INSTRUCTION = 0x90; + constexpr size_t TRAMPOLINE_SIZE = PROLOGUE_SIZE + RELATIVE_JUMP_SIZE; + constexpr uint8_t EXPECTED_PROLOGUE[PROLOGUE_SIZE] = { 0x81, 0xEC, 0x2C, 0x01, 0x00, 0x00 }; + + if (instance_.load(std::memory_order_acquire) != nullptr || address == 0 || + (this->owner_thread_id_ != 0 && this->owner_thread_id_ != GetCurrentThreadId()) || + !compare_bytes(address, EXPECTED_PROLOGUE, sizeof(EXPECTED_PROLOGUE))) + return false; + + auto* trampoline = static_cast(VirtualAlloc( + nullptr, TRAMPOLINE_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); + if (trampoline == nullptr) + return false; + + CopyMemory(this->actor_draw_original_, reinterpret_cast(address), PROLOGUE_SIZE); + CopyMemory(trampoline, this->actor_draw_original_, PROLOGUE_SIZE); + trampoline[PROLOGUE_SIZE] = RELATIVE_JUMP_OPCODE; + const auto resumeDisplacement = static_cast( + (address + PROLOGUE_SIZE) - (reinterpret_cast(trampoline) + TRAMPOLINE_SIZE)); + CopyMemory(trampoline + PROLOGUE_SIZE + 1, &resumeDisplacement, sizeof(resumeDisplacement)); + if (!FlushInstructionCache(GetCurrentProcess(), trampoline, TRAMPOLINE_SIZE)) + { + VirtualFree(trampoline, 0, MEM_RELEASE); + return false; + } + + this->actor_draw_patch_[0] = RELATIVE_JUMP_OPCODE; + this->actor_draw_patch_[5] = NOP_INSTRUCTION; + const auto hookDisplacement = static_cast( + reinterpret_cast(&render_runtime::actor_draw_hook) - + (address + RELATIVE_JUMP_SIZE)); + CopyMemory(this->actor_draw_patch_ + 1, &hookDisplacement, sizeof(hookDisplacement)); + + this->actor_draw_address_ = address; + this->actor_draw_trampoline_ = trampoline; + this->owner_thread_id_ = GetCurrentThreadId(); + this->stopping_.store(false, std::memory_order_release); + render_runtime* expected = nullptr; + if (!instance_.compare_exchange_strong(expected, + this, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + this->actor_draw_address_ = 0; + this->actor_draw_trampoline_ = nullptr; + this->owner_thread_id_ = 0; + VirtualFree(trampoline, 0, MEM_RELEASE); + return false; + } + const auto installed = + write_executable(address, this->actor_draw_patch_, sizeof(this->actor_draw_patch_)); + const bool patchVisible = + compare_bytes(address, this->actor_draw_patch_, sizeof(this->actor_draw_patch_)); + if (installed.bytes_written && installed.cache_flushed && + installed.protection_restored && patchVisible) + return true; + + if (!installed.bytes_written && + compare_bytes(address, this->actor_draw_original_, sizeof(this->actor_draw_original_))) + { + render_runtime* owner = this; + (void)instance_.compare_exchange_strong(owner, + nullptr, + std::memory_order_acq_rel, + std::memory_order_acquire); + this->actor_draw_address_ = 0; + this->actor_draw_trampoline_ = nullptr; + this->owner_thread_id_ = 0; + VirtualFree(trampoline, 0, MEM_RELEASE); + } + else + { + // Bytes may have changed without a coherent instruction-cache view. + // Enter pass-through mode before the adapter retains this runtime. + this->stopping_.store(true, std::memory_order_release); + } + // If executable bytes changed but cache coherency is unproved, retain all + // state. The adapter's failed-load teardown will restore the prologue or + // deliberately pin/leak this runtime rather than free callable code. + return false; +} + +auto render_runtime::owns_actor_draw_hook() const -> bool +{ + return this->actor_draw_address_ == 0 || + compare_bytes(this->actor_draw_address_, + this->actor_draw_patch_, + sizeof(this->actor_draw_patch_)); +} + +auto render_runtime::remove_actor_draw_hook() -> bool +{ + if (this->actor_draw_address_ == 0) + return true; + if (actor_hook_depth_ != 0) + return false; + if (!this->owner_thread()) + return false; + + this->stopping_.store(true, std::memory_order_release); + if (!this->owns_actor_draw_hook()) + return false; + + const auto restored = write_executable(this->actor_draw_address_, + this->actor_draw_original_, + sizeof(this->actor_draw_original_)); + const bool bytes_restored = compare_bytes(this->actor_draw_address_, + this->actor_draw_original_, + sizeof(this->actor_draw_original_)); + // Do not free the trampoline until bytes, instruction-cache coherency, and + // the original page protection are all proved restored. + if (!restored.bytes_written || !restored.cache_flushed || + !restored.protection_restored || !bytes_restored) + return false; + + // Publish the unhook only after the original bytes are verified. Any stale + // entry into the hook is counted before loading instance_ and is therefore + // covered by this drain before the trampoline can be released. + render_runtime* owner = this; + (void)instance_.compare_exchange_strong(owner, + nullptr, + std::memory_order_acq_rel, + std::memory_order_acquire); + while (hook_entries_.load(std::memory_order_acquire) != 0) + SwitchToThread(); + + actor_snapshot_ = { false, 0, 0 }; + this->actor_draw_address_ = 0; + if (this->actor_draw_trampoline_ != nullptr) + { + VirtualFree(this->actor_draw_trampoline_, 0, MEM_RELEASE); + this->actor_draw_trampoline_ = nullptr; + } + return true; +} + +void __fastcall render_runtime::actor_draw_hook(void* actor, void* edx) noexcept +{ + UNREFERENCED_PARAMETER(edx); + + // Count before loading instance_: teardown can unpublish the runtime and + // wait for this entry without racing a stale self pointer. + hook_entries_.fetch_add(1, std::memory_order_acq_rel); + ++actor_hook_depth_; + actor_entry_scope entryScope{ hook_entries_, actor_hook_depth_ }; + auto* self = instance_.load(std::memory_order_acquire); + if (self == nullptr) + return; + + const auto original = reinterpret_cast(self->actor_draw_trampoline_); + bool original_called = false; + try + { + if (original == nullptr || self->stopping_.load(std::memory_order_acquire) || + actor == nullptr) + { + if (original != nullptr) + { + original_called = true; + original(actor); + } + } + else + { + struct snapshot_scope final + { + actor_snapshot& slot; + actor_snapshot previous; + + explicit snapshot_scope(actor_snapshot& value) noexcept + : slot{ value } + , previous{ value } + { + } + + ~snapshot_scope() noexcept + { + slot = previous; + } + } scope{ actor_snapshot_ }; + + raw_actor_snapshot next{}; + (void)read_raw_actor_snapshot(actor, next); + actor_snapshot_ = { next.valid, next.race, next.hair }; + original_called = true; + original(actor); + } + } + catch (...) + { + // Hooks must never let a C++ exception unwind into the game. + if (original != nullptr && !original_called && + !self->stopping_.load(std::memory_order_acquire)) + { + try + { + original(actor); + } + catch (...) + { + } + } + } +} + +auto render_runtime::release_textures() -> void +{ + for (auto*& texture : this->replacement_textures_) + safe_release(texture); +} + +auto render_runtime::release_tail_texture_cache() -> void +{ + for (auto* texture : this->tail_textures_) + { + auto* owned = texture; + safe_release(owned); + } + this->tail_textures_.clear(); + this->rejected_textures_.clear(); + this->detection_frame_ = 0; +} + +auto render_runtime::is_tail_texture(IDirect3DBaseTexture8* texture) -> bool +{ + if (texture == nullptr) + return false; + if (this->tail_textures_.find(texture) != this->tail_textures_.end()) + return true; + if (this->rejected_textures_.find(texture) != this->rejected_textures_.end()) + return false; + + if (has_tail_fingerprint(texture)) + { + texture->AddRef(); + try + { + const auto result = this->tail_textures_.insert(texture); + if (!result.second) + texture->Release(); + return true; + } + catch (...) + { + texture->Release(); + return false; + } + } + + try + { + this->rejected_textures_.insert(texture); + } + catch (...) + { + } + return false; +} + +auto render_runtime::owner_thread() const noexcept -> bool +{ + return this->owner_thread_id_ == 0 || GetCurrentThreadId() == this->owner_thread_id_; +} + +auto render_runtime::replacement_texture(const char* name) const noexcept -> IDirect3DTexture8* +{ + if (name == nullptr) + return nullptr; + for (size_t index = 0; index < this->replacement_textures_.size(); ++index) + { + if (std::strcmp(COLOURS[index].name, name) == 0) + return this->replacement_textures_[index]; + } + return nullptr; +} + +auto render_runtime::initialize_device(IDirect3DDevice8* device) -> bool +{ + if (!this->owner_thread() || device == nullptr) + return false; + if (this->device_identity_ != nullptr && this->device_identity_ != device) + return false; + + this->release_tail_texture_cache(); + this->release_textures(); + this->owner_thread_id_ = GetCurrentThreadId(); + this->device_identity_ = device; + this->device_ = device; + this->reset_pending_ = false; + + for (size_t index = 0; index < this->replacement_textures_.size(); ++index) + { + auto* texture = make_texture(device, COLOURS[index].rgb); + if (texture == nullptr) + { + this->release_textures(); + this->device_ = nullptr; + this->reset_pending_ = true; + return false; + } + this->replacement_textures_[index] = texture; + } + return true; +} + +auto render_runtime::before_device_reset() -> void +{ + if (!this->owner_thread()) + return; + this->release_tail_texture_cache(); + this->release_textures(); + this->device_ = nullptr; + this->reset_pending_ = true; +} + +auto render_runtime::release_device() -> void +{ + if (!this->owner_thread()) + return; + this->before_device_reset(); + this->device_identity_ = nullptr; + this->reset_pending_ = false; + if (this->actor_draw_address_ == 0) + this->owner_thread_id_ = 0; +} + +auto render_runtime::device_ready() const -> bool +{ + if (!this->owner_thread() || this->reset_pending_ || this->device_ == nullptr) + return false; + for (const auto* texture : this->replacement_textures_) + { + if (texture == nullptr) + return false; + } + return true; +} + +auto render_runtime::begin_frame() -> void +{ + if (!this->device_ready()) + return; + constexpr uint32_t REJECTED_TEXTURE_EXPIRY_FRAMES = 600; + if (++this->detection_frame_ >= REJECTED_TEXTURE_EXPIRY_FRAMES) + { + // Negative entries own no COM reference and may otherwise hide an address + // reused for a later model texture. + this->rejected_textures_.clear(); + this->detection_frame_ = 0; + } +} + +auto render_runtime::try_draw_indexed(const indexed_draw_args& args, + indexed_draw_fn downstream) noexcept -> draw_result +{ + return this->try_draw_indexed(this->device_, args, downstream); +} + +auto render_runtime::try_draw_indexed(IDirect3DDevice8* device, + const indexed_draw_args& args, + indexed_draw_fn downstream) noexcept -> draw_result +{ + try + { + if (device != this->device_ || draw_depth_ != 0 || downstream == nullptr || + !this->device_ready() || !actor_snapshot_.valid || + actor_snapshot_.race != RACE_MITHRA) + return { false, D3D_OK }; + + const auto* colour_name = face_colour(actor_snapshot_.hair); + auto* replacement = this->replacement_texture(colour_name); + if (replacement == nullptr) + return { false, D3D_OK }; + + IDirect3DBaseTexture8* bound = nullptr; + HRESULT getResult = D3DERR_DRIVERINTERNALERROR; + try + { + getResult = device->GetTexture(0, &bound); + } + catch (...) + { + safe_release(bound); + return { false, D3D_OK }; + } + if (FAILED(getResult)) + { + safe_release(bound); + return { false, D3D_OK }; + } + if (bound == nullptr) + return { false, D3D_OK }; + + texture_scope textureScope{ bound }; + if (!this->is_tail_texture(bound)) + return { false, D3D_OK }; + + HRESULT swapResult = D3DERR_DRIVERINTERNALERROR; + try + { + swapResult = device->SetTexture(0, replacement); + } + catch (...) + { + (void)restore_texture(device, bound); + // No downstream draw was submitted, so the adapter must retain its + // normal pass-through path even if bind recovery also failed. + return { false, D3D_OK }; + } + if (FAILED(swapResult)) + return { false, D3D_OK }; + + ++draw_depth_; + HRESULT result = D3DERR_DRIVERINTERNALERROR; + try + { + draw_scope drawScope{ draw_depth_ }; + result = downstream(device, + args.primitive_type, + args.min_index, + args.num_vertices, + args.start_index, + args.primitive_count); + } + catch (...) + { + // Preserve the internal-error result and restore the original bind. + } + + const auto restoreResult = restore_texture(device, bound); + // The replacement draw has already been submitted. Keep it consumed to + // prevent a duplicate outer draw, but make a failed restoration visible. + return { true, FAILED(restoreResult) ? restoreResult : result }; + } + catch (...) + { + return { false, D3D_OK }; + } +} + +auto render_runtime::shutdown() -> bool +{ + // Keep all runtime state alive while the code patch is restored. If + // restoration is refused (ownership changed or wrong thread), do not free + // textures or the trampoline behind a still-callable hook. + if (!this->remove_actor_draw_hook()) + return false; + if (!this->owner_thread()) + return false; + this->release_device(); + return true; +} + +} // namespace fluffytail diff --git a/src/render_runtime.hpp b/src/render_runtime.hpp new file mode 100644 index 0000000..2ede47f --- /dev/null +++ b/src/render_runtime.hpp @@ -0,0 +1,117 @@ +/** + * FluffyTail - shared actor and Direct3D8 render runtime. + * + * Copyright (c) 2026 Aeshur. GNU LGPL v3. See LICENSE.md. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace fluffytail +{ + +struct indexed_draw_args +{ + D3DPRIMITIVETYPE primitive_type; + UINT min_index; + UINT num_vertices; + UINT start_index; + UINT primitive_count; +}; + +using indexed_draw_fn = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice8*, + D3DPRIMITIVETYPE, + UINT, + UINT, + UINT, + UINT); + +struct draw_result +{ + bool handled; + HRESULT result; +}; + +class render_runtime final +{ + using actor_draw_fn = void(__thiscall*)(void*); + + struct actor_snapshot + { + bool valid; + uint8_t race; + uint8_t hair; + }; + + static std::atomic instance_; + static std::atomic_uint hook_entries_; + static thread_local uint32_t actor_hook_depth_; + static thread_local uint32_t draw_depth_; + static thread_local actor_snapshot actor_snapshot_; + + uintptr_t actor_draw_address_; + uint8_t actor_draw_original_[6]; + uint8_t actor_draw_patch_[6]; + void* actor_draw_trampoline_; + std::atomic_bool stopping_; + + IDirect3DDevice8* device_; + IDirect3DDevice8* device_identity_; + DWORD owner_thread_id_; + bool reset_pending_; + + static constexpr size_t REPLACEMENT_TEXTURE_COUNT = 6; + std::array replacement_textures_; + uint32_t detection_frame_; + std::unordered_set tail_textures_; + std::unordered_set rejected_textures_; + + auto release_textures() -> void; + auto release_tail_texture_cache() -> void; + auto is_tail_texture(IDirect3DBaseTexture8* texture) -> bool; + auto owner_thread() const noexcept -> bool; + auto replacement_texture(const char* name) const noexcept -> IDirect3DTexture8*; + static void __fastcall actor_draw_hook(void* actor, void* edx) noexcept; + +public: + render_runtime(); + ~render_runtime(); + + render_runtime(const render_runtime&) = delete; + render_runtime(render_runtime&&) = delete; + auto operator=(const render_runtime&) -> render_runtime& = delete; + auto operator=(render_runtime&&) -> render_runtime& = delete; + + auto install_actor_draw_hook(uintptr_t address) -> bool; + // Hook installation, removal, and device lifecycle are serialized on the + // owner thread recorded by the first successful lifecycle operation. + auto remove_actor_draw_hook() -> bool; + auto owns_actor_draw_hook() const -> bool; + + auto initialize_device(IDirect3DDevice8* device) -> bool; + // Device lifecycle and draw calls are render-thread-owned. A reset makes + // the runtime unavailable until initialize_device succeeds again. + auto before_device_reset() -> void; + auto release_device() -> void; + auto device_ready() const -> bool; + auto begin_frame() -> void; + + auto try_draw_indexed(const indexed_draw_args& args, indexed_draw_fn downstream) noexcept + -> draw_result; + // Use this overload when the host supplies its device explicitly; identity + // is checked against the device accepted by initialize_device. + auto try_draw_indexed(IDirect3DDevice8* device, + const indexed_draw_args& args, + indexed_draw_fn downstream) noexcept -> draw_result; + auto shutdown() -> bool; +}; + +} // namespace fluffytail diff --git a/src/tail_policy.hpp b/src/tail_policy.hpp new file mode 100644 index 0000000..c9fa230 --- /dev/null +++ b/src/tail_policy.hpp @@ -0,0 +1,66 @@ +/** + * FluffyTail - host-independent Mithra face and tail colour policy. + * + * Copyright (c) 2026 Aeshur. GNU LGPL v3. See LICENSE.md. + */ + +#pragma once + +#include +#include + +namespace fluffytail +{ + +constexpr uint8_t RACE_MITHRA = 7; +constexpr uint32_t BASELINE_RGB = 0x241C1A; + +struct colour_t +{ + const char* name; + uint32_t rgb; +}; + +inline constexpr const char* FACE_COLOURS[16] = { + "red", + "brunette", + "white", + "silver", + "silver", + "red", + "blonde", + "red", + "rose", + "brunette", + "white", + "red", + "blonde", + "brunette", + "brunette", + "blonde", +}; + +// These values were calibrated manually in game. Do not change one without +// repeating that calibration, because FFXI lighting brightens the texture. +inline constexpr colour_t COLOURS[] = { + { "white", 0xD8D8CA }, + { "silver", 0xCDC2D4 }, + { "blonde", 0xC3A261 }, + { "red", 0x652708 }, + { "rose", 0x814231 }, + { "brunette", 0x3E2412 }, +}; + +inline constexpr auto face_colour(uint8_t hair) -> const char* +{ + return hair < (sizeof(FACE_COLOURS) / sizeof(FACE_COLOURS[0])) ? FACE_COLOURS[hair] + : nullptr; +} + +inline constexpr auto rgb565(uint32_t rgb) -> uint16_t +{ + return static_cast((((rgb >> 19) & 0x1F) << 11) | (((rgb >> 10) & 0x3F) << 5) | + ((rgb >> 3) & 0x1F)); +} + +} // namespace fluffytail diff --git a/src/windower.cpp b/src/windower.cpp new file mode 100644 index 0000000..cf26af8 --- /dev/null +++ b/src/windower.cpp @@ -0,0 +1,830 @@ +/** + * FluffyTail - Windower 4.7.9 native host adapter. + * + * ABI provenance includes Shirk/Nameplate Windozer.cpp and Windozer.h, + * Copyright (c) 2024 BunnyBox Productions, GNU GPL v3. See LICENSE.GPL.txt and + * LICENSES.md. FluffyTail implementation copyright (c) 2026 Aeshur. + */ + +#include "windower.hpp" + +#include "hook_utils.hpp" +#include "render_runtime.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace +{ + +constexpr size_t RESET_VTABLE_INDEX = 14; +constexpr size_t DRAW_VTABLE_INDEX = 71; + +using reset_fn = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice8*, D3DPRESENT_PARAMETERS*); + +thread_local uint32_t callback_depth_ = 0; + +constexpr uint8_t ACTOR_DRAW_SIGNATURE[] = { + 0x81, + 0xEC, + 0x2C, + 0x01, + 0x00, + 0x00, + 0x53, + 0x55, + 0x56, + 0x8B, + 0xF1, + 0x57, + 0x33, + 0xFF, + 0x8B, + 0x46, + 0x70, + 0x89, + 0x7C, + 0x24, + 0x14, + 0x3B, + 0xC7, + 0x89, + 0x7C, + 0x24, + 0x1C, + 0xBB, + 0x01, + 0x00, + 0x00, + 0x00, + 0x74, + 0x1B, + 0x8B, + 0x88, + 0x2C, + 0x01, + 0x00, + 0x00, + 0x8A, + 0x80, + 0x30, + 0x01, + 0x00, + 0x00, + 0xC1, + 0xE9, + 0x11, + 0x23, + 0xCB, + 0x23, + 0xC3, + 0x89, + 0x4C, + 0x24, + 0x14, + 0x89, + 0x44, + 0x24, + 0x1C, + 0x8D, + 0x8E, + 0x74, + 0x06, + 0x00, + 0x00, +}; + +struct callback_scope final +{ + std::atomic_uint& callbacks; + bool acquired; + + explicit callback_scope(std::atomic_uint& value, bool already_acquired = false) + : callbacks{ value } + , acquired{ already_acquired } + { + if (!this->acquired) + callbacks.fetch_add(1, std::memory_order_acq_rel); + ++callback_depth_; + } + + ~callback_scope() + { + --callback_depth_; + callbacks.fetch_sub(1, std::memory_order_acq_rel); + } +}; + +struct draw_call_context final +{ + fluffytail::indexed_draw_fn previous; + bool called; +}; + +} // namespace + +namespace fluffytail +{ + +class windower_plugin final : public windower::PluginBase +{ + static std::atomic instance_; + static std::mutex instance_mutex_; + + windower::PluginManager* manager_; + render_runtime runtime_; + IDirect3DDevice8* device_; + void** device_vtable_; + reset_fn previous_reset_; + indexed_draw_fn previous_draw_; + std::atomic_uint callbacks_; + std::atomic_bool unloading_; + DWORD owner_thread_; + bool owner_thread_set_; + bool device_hooks_installed_; + bool reset_owned_; + bool draw_owned_; + bool ownership_lost_; + bool unload_pinned_; + bool loaded_; + + static thread_local draw_call_context* draw_context_; + + static auto reset_hook_address() -> void* + { + return reinterpret_cast(&windower_plugin::reset_hook); + } + + static auto draw_hook_address() -> void* + { + return reinterpret_cast(&windower_plugin::draw_hook); + } + + auto on_owner_thread() const -> bool + { + return this->owner_thread_set_ && this->owner_thread_ == GetCurrentThreadId(); + } + + auto wait_for_callbacks() -> void + { + while (this->callbacks_.load(std::memory_order_acquire) != 0) + SwitchToThread(); + } + + static auto enter_callback() noexcept -> windower_plugin* + { + try + { + std::lock_guard lock{ instance_mutex_ }; + auto* const self = instance_.load(std::memory_order_acquire); + if (self != nullptr) + self->callbacks_.fetch_add(1, std::memory_order_acq_rel); + return self; + } + catch (...) + { + return nullptr; + } + } + + auto console() const -> windower::Console* + { + return this->manager_ != nullptr ? this->manager_->GetConsole() : nullptr; + } + + auto write(const char* message) const -> void + { + try + { + auto* output = this->console(); + if (output == nullptr) + return; + + char line[512]{}; + std::snprintf(line, sizeof(line), "[FluffyTail] %s", message ? message : ""); + output->Write(line); + } + catch (...) + { + // Host callbacks must not receive a C++ exception. + } + } + + auto owns_device_hooks() const -> bool + { + if (this->ownership_lost_) + return false; + if (!this->device_hooks_installed_ || this->device_vtable_ == nullptr) + return true; + + if (this->reset_owned_ && + this->device_vtable_[RESET_VTABLE_INDEX] != reset_hook_address()) + return false; + if (this->draw_owned_ && + this->device_vtable_[DRAW_VTABLE_INDEX] != draw_hook_address()) + return false; + return true; + } + + auto attach_device() -> bool + { + if (this->device_hooks_installed_) + return this->reset_owned_ && this->draw_owned_ && this->owns_device_hooks(); + if (this->manager_ == nullptr || !this->on_owner_thread() || this->unload_pinned_) + return false; + + auto* device = static_cast(this->manager_->GetDirect3D8Device()); + if (device == nullptr) + return false; + + auto** vtable = *reinterpret_cast(device); + if (vtable == nullptr) + return false; + + const auto previousReset = reinterpret_cast(vtable[RESET_VTABLE_INDEX]); + const auto previousDraw = reinterpret_cast(vtable[DRAW_VTABLE_INDEX]); + if (previousReset == nullptr || previousDraw == nullptr || + !this->runtime_.initialize_device(device)) + return false; + + this->device_ = device; + this->device_vtable_ = vtable; + this->previous_reset_ = previousReset; + this->previous_draw_ = previousDraw; + this->unloading_.store(false, std::memory_order_release); + + const auto reset = replace_vtable_slot(&vtable[RESET_VTABLE_INDEX], + reinterpret_cast(previousReset), + reset_hook_address()); + if (!reset.exchanged) + { + this->runtime_.release_device(); + this->device_ = nullptr; + this->device_vtable_ = nullptr; + this->previous_reset_ = nullptr; + this->previous_draw_ = nullptr; + return false; + } + + this->reset_owned_ = true; + this->device_hooks_installed_ = true; + if (!reset.protection_restored) + { + this->unload_pinned_ = true; + return false; + } + + const auto draw = replace_vtable_slot(&vtable[DRAW_VTABLE_INDEX], + reinterpret_cast(previousDraw), + draw_hook_address()); + if (!draw.exchanged) + { + const auto rollback = restore_vtable_slot(&vtable[RESET_VTABLE_INDEX], + reset_hook_address(), + reinterpret_cast(previousReset)); + if (rollback.exchanged && rollback.protection_restored) + { + this->reset_owned_ = false; + this->device_hooks_installed_ = false; + this->runtime_.release_device(); + this->device_ = nullptr; + this->device_vtable_ = nullptr; + this->previous_reset_ = nullptr; + this->previous_draw_ = nullptr; + } + else + { + // The reset slot may still call us (or have a foreign owner). Keep + // all callable state and pin the instance rather than unloading + // code that remains reachable from the device vtable. + this->unload_pinned_ = true; + if (rollback.exchanged) + this->reset_owned_ = false; + if (rollback.observed != reset_hook_address()) + { + this->reset_owned_ = false; + this->ownership_lost_ = true; + } + } + return false; + } + + this->draw_owned_ = true; + this->device_hooks_installed_ = true; + if (!draw.protection_restored) + { + this->unload_pinned_ = true; + return false; + } + return true; + } + + auto detach_device() -> bool + { + if (!this->device_hooks_installed_) + { + this->runtime_.release_device(); + return true; + } + if (callback_depth_ != 0) + { + this->unload_pinned_ = true; + return false; + } + if (!this->on_owner_thread()) + { + this->unload_pinned_ = true; + return false; + } + + // Close callback admission while publishing the unhook. Existing + // callbacks are counted already; do not hold this mutex while waiting + // for them, since a nested callback may need to reacquire it. + std::unique_lock callback_lock{ instance_mutex_ }; + this->unloading_.store(true, std::memory_order_release); + + bool restored = true; + if (this->draw_owned_) + { + if (this->device_vtable_[DRAW_VTABLE_INDEX] != draw_hook_address()) + { + this->draw_owned_ = false; + this->ownership_lost_ = true; + this->unload_pinned_ = true; + restored = false; + } + else + { + const auto result = restore_vtable_slot( + &this->device_vtable_[DRAW_VTABLE_INDEX], + draw_hook_address(), + reinterpret_cast(this->previous_draw_)); + if (result.exchanged && result.protection_restored) + this->draw_owned_ = false; + else + { + if (result.exchanged) + this->draw_owned_ = false; + if (result.observed != draw_hook_address()) + { + this->draw_owned_ = false; + this->ownership_lost_ = true; + } + this->unload_pinned_ = true; + restored = false; + } + } + } + + if (this->reset_owned_) + { + if (this->device_vtable_[RESET_VTABLE_INDEX] != reset_hook_address()) + { + this->reset_owned_ = false; + this->ownership_lost_ = true; + this->unload_pinned_ = true; + restored = false; + } + else + { + const auto result = restore_vtable_slot( + &this->device_vtable_[RESET_VTABLE_INDEX], + reset_hook_address(), + reinterpret_cast(this->previous_reset_)); + if (result.exchanged && result.protection_restored) + this->reset_owned_ = false; + else + { + if (result.exchanged) + this->reset_owned_ = false; + if (result.observed != reset_hook_address()) + { + this->reset_owned_ = false; + this->ownership_lost_ = true; + } + this->unload_pinned_ = true; + restored = false; + } + } + } + + if (!restored || this->ownership_lost_ || this->unload_pinned_ || + this->reset_owned_ || this->draw_owned_) + { + callback_lock.unlock(); + return false; + } + + instance_.store(nullptr, std::memory_order_release); + callback_lock.unlock(); + this->wait_for_callbacks(); + this->runtime_.release_device(); + this->device_ = nullptr; + this->device_vtable_ = nullptr; + this->previous_reset_ = nullptr; + this->previous_draw_ = nullptr; + this->device_hooks_installed_ = false; + this->unloading_.store(false, std::memory_order_release); + return true; + } + + auto unload() -> bool + { + if (!this->loaded_) + return true; + if (!this->on_owner_thread()) + { + this->unload_pinned_ = true; + return false; + } + if (!this->detach_device()) + { + this->unload_pinned_ = true; + return false; + } + if (!this->runtime_.shutdown()) + { + this->unload_pinned_ = true; + return false; + } + + this->loaded_ = false; + this->manager_ = nullptr; + instance_.store(nullptr, std::memory_order_release); + return true; + } + + static auto invoke_reset(reset_fn previous, + IDirect3DDevice8* device, + D3DPRESENT_PARAMETERS* parameters) -> HRESULT + { + if (previous == nullptr) + return D3DERR_INVALIDCALL; + try + { + return previous(device, parameters); + } + catch (...) + { + return D3DERR_DRIVERINTERNALERROR; + } + } + + static auto STDMETHODCALLTYPE reset_hook(IDirect3DDevice8* device, + D3DPRESENT_PARAMETERS* parameters) -> HRESULT + { + auto* self = enter_callback(); + if (self == nullptr) + return D3DERR_INVALIDCALL; + + callback_scope scope{ self->callbacks_, true }; + if (self->previous_reset_ == nullptr) + return D3DERR_INVALIDCALL; + const auto previous = self->previous_reset_; + bool called = false; + try + { + if (!self->on_owner_thread() || device != self->device_ || + self->unloading_.load(std::memory_order_acquire)) + { + called = true; + return invoke_reset(previous, device, parameters); + } + + self->runtime_.before_device_reset(); + called = true; + const auto result = previous(device, parameters); + if (result == D3D_OK && !self->runtime_.initialize_device(device)) + self->write("texture recreation failed after device reset"); + return result; + } + catch (...) + { + if (!called) + { + called = true; + return invoke_reset(previous, device, parameters); + } + return D3DERR_DRIVERINTERNALERROR; + } + } + + static auto invoke_draw(indexed_draw_fn previous, + IDirect3DDevice8* device, + D3DPRIMITIVETYPE primitive_type, + UINT min_index, + UINT num_vertices, + UINT start_index, + UINT primitive_count) -> HRESULT + { + if (previous == nullptr) + return D3DERR_INVALIDCALL; + try + { + return previous( + device, primitive_type, min_index, num_vertices, start_index, primitive_count); + } + catch (...) + { + return D3DERR_DRIVERINTERNALERROR; + } + } + + static auto STDMETHODCALLTYPE downstream_draw(IDirect3DDevice8* device, + D3DPRIMITIVETYPE primitive_type, + UINT min_index, + UINT num_vertices, + UINT start_index, + UINT primitive_count) -> HRESULT + { + auto* context = draw_context_; + if (context == nullptr || context->previous == nullptr) + return D3DERR_INVALIDCALL; + context->called = true; + return invoke_draw(context->previous, + device, + primitive_type, + min_index, + num_vertices, + start_index, + primitive_count); + } + + static auto STDMETHODCALLTYPE draw_hook(IDirect3DDevice8* device, + D3DPRIMITIVETYPE primitive_type, + UINT min_index, + UINT num_vertices, + UINT start_index, + UINT primitive_count) -> HRESULT + { + auto* self = enter_callback(); + if (self == nullptr) + return D3DERR_INVALIDCALL; + + callback_scope scope{ self->callbacks_, true }; + if (self->previous_draw_ == nullptr) + return D3DERR_INVALIDCALL; + const auto previous = self->previous_draw_; + if (!self->on_owner_thread() || device != self->device_ || + self->unloading_.load(std::memory_order_acquire)) + { + return invoke_draw(previous, + device, + primitive_type, + min_index, + num_vertices, + start_index, + primitive_count); + } + + draw_call_context context{ previous, false }; + auto* const prior_context = draw_context_; + draw_context_ = &context; + try + { + const indexed_draw_args args{ + primitive_type, + min_index, + num_vertices, + start_index, + primitive_count, + }; + const auto draw = self->runtime_.try_draw_indexed( + device, args, &windower_plugin::downstream_draw); + draw_context_ = prior_context; + if (context.called) + return draw.handled ? draw.result : D3DERR_DRIVERINTERNALERROR; + return invoke_draw(previous, + device, + primitive_type, + min_index, + num_vertices, + start_index, + primitive_count); + } + catch (...) + { + draw_context_ = prior_context; + if (context.called) + return D3DERR_DRIVERINTERNALERROR; + return invoke_draw(previous, + device, + primitive_type, + min_index, + num_vertices, + start_index, + primitive_count); + } + } + +public: + windower_plugin() + : manager_{ nullptr } + , device_{ nullptr } + , device_vtable_{ nullptr } + , previous_reset_{ nullptr } + , previous_draw_{ nullptr } + , callbacks_{ 0 } + , unloading_{ false } + , owner_thread_{ 0 } + , owner_thread_set_{ false } + , device_hooks_installed_{ false } + , reset_owned_{ false } + , draw_owned_{ false } + , ownership_lost_{ false } + , unload_pinned_{ false } + , loaded_{ false } + { + } + + ~windower_plugin() + { + this->unload(); + } + + auto __stdcall GetPluginAuthor() -> const char* override + { + return "Aeshur"; + } + + auto __stdcall GetPluginName() -> const char* override + { + return "fluffytail"; + } + + void __stdcall Load(windower::PluginManager* manager) override + { + if (manager == nullptr || !pin_current_module()) + return; + + windower_plugin* expected = nullptr; + if (!instance_.compare_exchange_strong(expected, + this, + std::memory_order_acq_rel, + std::memory_order_acquire)) + return; + + this->manager_ = manager; + this->owner_thread_ = GetCurrentThreadId(); + this->owner_thread_set_ = true; + this->loaded_ = true; + + const auto actorDraw = find_module_signature( + "FFXiMain.dll", ACTOR_DRAW_SIGNATURE, sizeof(ACTOR_DRAW_SIGNATURE)); + if (actorDraw == 0 || !this->runtime_.install_actor_draw_hook(actorDraw)) + { + this->write("load failed: actor draw signature or hook unavailable"); + this->unload(); + return; + } + if (!this->attach_device()) + this->write("device not ready; will retry during rendering"); + } + + void __stdcall Dealloc() override + { + if (this->unload()) + this->Dtor(1); + } + + auto __stdcall IgnoreUnload() -> bool override + { + return this->unload_pinned_ || !this->runtime_.owns_actor_draw_hook() || + !this->owns_device_hooks(); + } + + void __stdcall PreRender() override + { + try + { + if (!this->device_hooks_installed_) + this->attach_device(); + if (this->runtime_.device_ready()) + this->runtime_.begin_frame(); + } + catch (...) + { + this->write("render setup failed"); + } + } + + void __stdcall PostRender() override + { + } + + void __stdcall PluginCommand(const char* command) override + { + if (command != nullptr && _stricmp(command, "inspect") == 0) + { + this->write("inspect is available through the Ashita adapter only"); + return; + } + this->write("usage: //fluffytail inspect"); + } + + auto __stdcall UnhandledCommand(const char* command) -> bool override + { + UNREFERENCED_PARAMETER(command); + return false; + } + + void __stdcall IncomingText(void* arg0, void* arg1, void* arg2) override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + } + + void __stdcall OutgoingText(void* arg0, void* arg1, void* arg2) override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + } + + auto __stdcall IncomingChunk(void* arg0, void* arg1, void* arg2, bool modified) + -> bool override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + return modified; + } + + auto __stdcall OutgoingChunk(void* arg0, void* arg1, void* arg2, bool modified) + -> bool override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + return modified; + } + + auto __stdcall Mouse(void* arg0, void* arg1, void* arg2, void* arg3, bool modified) + -> bool override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + UNREFERENCED_PARAMETER(arg3); + return modified; + } + + auto __stdcall Keyboard(void* arg0, void* arg1, bool modified) -> bool override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + return modified; + } + + void __stdcall AddItem(void* arg0, void* arg1, void* arg2, void* arg3) override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + UNREFERENCED_PARAMETER(arg3); + } + + void __stdcall RemoveItem(void* arg0, void* arg1, void* arg2, void* arg3) override + { + UNREFERENCED_PARAMETER(arg0); + UNREFERENCED_PARAMETER(arg1); + UNREFERENCED_PARAMETER(arg2); + UNREFERENCED_PARAMETER(arg3); + } + + auto __thiscall Dtor(uint8_t flags) -> windower::PluginBase* override + { + auto* result = this; + // Both scalar-destructor forms must honor the teardown gate. If a + // foreign hook still chains through this DLL, preserve the live object. + if (this->loaded_ && !this->unload()) + return result; + if ((flags & 1) != 0) + delete this; + else + this->~windower_plugin(); + return result; + } +}; + +std::atomic windower_plugin::instance_{ nullptr }; +std::mutex windower_plugin::instance_mutex_{}; +thread_local draw_call_context* windower_plugin::draw_context_ = nullptr; + +} // namespace fluffytail + +extern "C" auto GetInterfaceVersion() -> uint32_t +{ + return fluffytail::windower::INTERFACE_VERSION; +} + +extern "C" auto CreateInstance() -> fluffytail::windower::PluginBase* +{ + return new fluffytail::windower_plugin(); +} diff --git a/src/windower.hpp b/src/windower.hpp new file mode 100644 index 0000000..0cc2c46 --- /dev/null +++ b/src/windower.hpp @@ -0,0 +1,100 @@ +/** + * FluffyTail - Windower 4.7.9 native plugin ABI declarations. + * + * The interface layout was derived from Windower's current first-party plugin + * binaries; exact artifact hashes, vtable RVAs, and stack cleanup widths are in + * tests/windower_abi_fixture.json. Earlier declarations were informed by + * Shirk/Nameplate Windozer.h, Copyright (c) 2024 BunnyBox Productions, GNU GPL + * v3. See LICENSE.GPL.txt and LICENSES.md. + */ + +#pragma once + +#include + +#include + +namespace fluffytail::windower +{ + +inline constexpr uint32_t INTERFACE_VERSION = 0x04070300; + +class TextHandler; +class PrimitiveHandler; +class PacketStreamHandler; +class FFXI; +class Settings; + +class Console +{ +public: + virtual void __stdcall OpenConsole(bool open) = 0; + virtual auto __stdcall IsVisible() -> bool = 0; + virtual void __stdcall SetPosition(float x, float y) = 0; + virtual void __stdcall Write(const char* text) = 0; + virtual void __stdcall Clear() = 0; + virtual void __stdcall SendCommand(const char* command, bool delay) = 0; +}; + +class PluginManager +{ +public: + virtual auto __stdcall GetMMFSettingsHandler() -> Settings* = 0; + virtual auto __stdcall GetHWND() -> HWND = 0; + virtual auto __stdcall GetDirect3D8Device() -> void* = 0; + virtual auto __stdcall GetConsole() -> Console* = 0; + virtual auto __stdcall GetTextHandler() -> TextHandler* = 0; + virtual auto __stdcall GetPrimitiveHandler() -> PrimitiveHandler* = 0; + virtual auto __stdcall GetPacketStreamHandler() -> PacketStreamHandler* = 0; + virtual auto __stdcall GetFFXI() -> FFXI* = 0; + virtual auto __thiscall Dtor(uint8_t flags) -> PluginManager* = 0; +}; + +// This is the 18-slot IPlugin host interface. Windower's first-party +// WindowerPlugin helper derives from IPlugin and appends 16 private virtuals; +// those helper slots are not part of the object contract returned to Hook. +// Hook 4.7.9 uses Dealloc at host slot 3 where the archived 0x04070000 interface +// exposed Unload. Keep this declaration in the exact verified order. +class PluginBase +{ +public: + virtual auto __stdcall GetPluginAuthor() -> const char* = 0; + virtual auto __stdcall GetPluginName() -> const char* = 0; + virtual void __stdcall Load(PluginManager* manager) = 0; + virtual void __stdcall Dealloc() = 0; + virtual auto __stdcall IgnoreUnload() -> bool = 0; + virtual void __stdcall PreRender() = 0; + virtual void __stdcall PostRender() = 0; + virtual void __stdcall PluginCommand(const char* command) = 0; + virtual auto __stdcall UnhandledCommand(const char* command) -> bool = 0; + virtual void __stdcall IncomingText(void* arg0, void* arg1, void* arg2) = 0; + virtual void __stdcall OutgoingText(void* arg0, void* arg1, void* arg2) = 0; + virtual auto __stdcall IncomingChunk(void* arg0, + void* arg1, + void* arg2, + bool modified) -> bool = 0; + virtual auto __stdcall OutgoingChunk(void* arg0, + void* arg1, + void* arg2, + bool modified) -> bool = 0; + virtual auto __stdcall Mouse(void* arg0, + void* arg1, + void* arg2, + void* arg3, + bool modified) -> bool = 0; + virtual auto __stdcall Keyboard(void* arg0, void* arg1, bool modified) -> bool = 0; + virtual void __stdcall AddItem(void* arg0, void* arg1, void* arg2, void* arg3) = 0; + virtual void __stdcall RemoveItem(void* arg0, void* arg1, void* arg2, void* arg3) = 0; + virtual auto __thiscall Dtor(uint8_t flags) -> PluginBase* = 0; + +protected: + ~PluginBase() = default; +}; + +static_assert(sizeof(void*) == 4, "Windower 4 plugins must be built for x86"); +static_assert(sizeof(PluginBase) == 4, "Windower PluginBase ABI changed"); + +} // namespace fluffytail::windower + +extern "C" auto GetInterfaceVersion() -> uint32_t; +extern "C" auto CreateInstance() -> fluffytail::windower::PluginBase*; diff --git a/tests/native_tests.cpp b/tests/native_tests.cpp new file mode 100644 index 0000000..adc2bd2 --- /dev/null +++ b/tests/native_tests.cpp @@ -0,0 +1,189 @@ +/** + * FluffyTail - deterministic native policy and hook-helper tests. + * + * Copyright (c) 2026 Aeshur. See LICENSES.md. + */ + +#include "hook_utils.hpp" +#include "tail_policy.hpp" +#include "windower.hpp" + +#include + +#include +#include +#include +#include + +namespace +{ + +auto fail(const char* message) -> int +{ + std::cerr << message << '\n'; + return 1; +} + +} // namespace + +auto main(int argc, char** argv) -> int +{ + constexpr std::array EXPECTED = { + "red", + "brunette", + "white", + "silver", + "silver", + "red", + "blonde", + "red", + "rose", + "brunette", + "white", + "red", + "blonde", + "brunette", + "brunette", + "blonde", + }; + for (uint8_t hair = 0; hair < EXPECTED.size(); ++hair) + { + if (std::strcmp(fluffytail::face_colour(hair), EXPECTED[hair]) != 0) + return fail("face colour map changed"); + } + if (fluffytail::face_colour(16) != nullptr) + return fail("out-of-range hair mapped to a colour"); + if (fluffytail::rgb565(fluffytail::BASELINE_RGB) != 0x20E3) + return fail("neutral RGB565 endpoint changed"); + + constexpr uint8_t UNIQUE_DATA[] = { 1, 2, 3, 4, 5, 6, 7 }; + constexpr uint8_t UNIQUE_PATTERN[] = { 3, 4, 5 }; + constexpr uint8_t DUPLICATE_DATA[] = { 3, 4, 5, 3, 4, 5 }; + if (fluffytail::find_unique_bytes(UNIQUE_DATA, + sizeof(UNIQUE_DATA), + UNIQUE_PATTERN, + sizeof(UNIQUE_PATTERN)) != UNIQUE_DATA + 2) + return fail("unique signature was not found"); + if (fluffytail::find_unique_bytes(DUPLICATE_DATA, + sizeof(DUPLICATE_DATA), + UNIQUE_PATTERN, + sizeof(UNIQUE_PATTERN)) != nullptr) + return fail("duplicate signature was accepted"); + + void* slots[2] = { reinterpret_cast(1), reinterpret_cast(2) }; + const auto installed = fluffytail::replace_vtable_slot( + &slots[1], reinterpret_cast(2), reinterpret_cast(3)); + if (!installed.exchanged || !installed.protection_restored || + installed.observed != reinterpret_cast(2)) + return fail("owned vtable replacement failed"); + const auto conflicted = fluffytail::replace_vtable_slot( + &slots[1], reinterpret_cast(2), reinterpret_cast(4)); + if (conflicted.exchanged || conflicted.observed != reinterpret_cast(3)) + return fail("vtable replacement overwrote a foreign owner"); + const auto restored = fluffytail::restore_vtable_slot( + &slots[1], reinterpret_cast(3), reinterpret_cast(2)); + if (!restored.exchanged || !restored.protection_restored) + return fail("owned vtable restoration failed"); + + constexpr std::array ORIGINAL_CODE = { 0x90, 0x90, 0x90, 0xC3 }; + constexpr std::array PATCHED_CODE = { 0xCC, 0x90, 0x90, 0xC3 }; + auto* executable = static_cast(VirtualAlloc( + nullptr, ORIGINAL_CODE.size(), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); + if (executable == nullptr) + return fail("executable patch fixture allocation failed"); + std::memcpy(executable, ORIGINAL_CODE.data(), ORIGINAL_CODE.size()); + DWORD ignored{}; + if (VirtualProtect(executable, ORIGINAL_CODE.size(), PAGE_EXECUTE_READ, &ignored) == FALSE) + { + VirtualFree(executable, 0, MEM_RELEASE); + return fail("executable patch fixture protection failed"); + } + const auto patched = fluffytail::write_executable( + reinterpret_cast(executable), PATCHED_CODE.data(), PATCHED_CODE.size()); + if (!patched.bytes_written || !patched.cache_flushed || !patched.protection_restored || + std::memcmp(executable, PATCHED_CODE.data(), PATCHED_CODE.size()) != 0) + { + VirtualFree(executable, 0, MEM_RELEASE); + return fail("executable patch result was incomplete"); + } + const auto codeRestored = fluffytail::write_executable( + reinterpret_cast(executable), ORIGINAL_CODE.data(), ORIGINAL_CODE.size()); + if (!codeRestored.bytes_written || !codeRestored.cache_flushed || + !codeRestored.protection_restored || + std::memcmp(executable, ORIGINAL_CODE.data(), ORIGINAL_CODE.size()) != 0) + { + VirtualFree(executable, 0, MEM_RELEASE); + return fail("executable patch restoration was incomplete"); + } + VirtualFree(executable, 0, MEM_RELEASE); + + static_assert(fluffytail::windower::INTERFACE_VERSION == 0x04070300, + "Windower interface version changed"); + static_assert(sizeof(fluffytail::windower::PluginBase) == sizeof(void*), + "Windower PluginBase layout changed"); + + if (argc != 2) + return fail("native test requires the dual-host DLL path"); + + const auto module = LoadLibraryA(argv[1]); + if (module == nullptr) + return fail("dual-host DLL did not load"); + + using get_windower_version_fn = uint32_t(__cdecl*)(); + using create_windower_fn = fluffytail::windower::PluginBase*(__cdecl*)(); + using create_ashita_fn = void*(__stdcall*)(const char*); + using destroy_ashita_fn = void(__stdcall*)(void*); + using get_ashita_version_fn = double(__stdcall*)(); + + const auto getWindowerVersion = reinterpret_cast( + GetProcAddress(module, "GetInterfaceVersion")); + const auto createWindower = reinterpret_cast( + GetProcAddress(module, "CreateInstance")); + const auto createAshita = + reinterpret_cast(GetProcAddress(module, "expCreatePlugin")); + const auto destroyAshita = + reinterpret_cast(GetProcAddress(module, "expDestroyPlugin")); + const auto getAshitaVersion = reinterpret_cast( + GetProcAddress(module, "expGetInterfaceVersion")); + if (getWindowerVersion == nullptr || createWindower == nullptr || createAshita == nullptr || + destroyAshita == nullptr || getAshitaVersion == nullptr) + { + FreeLibrary(module); + return fail("dual-host export set is incomplete"); + } + if (getWindowerVersion() != fluffytail::windower::INTERFACE_VERSION) + { + FreeLibrary(module); + return fail("Windower export returned the wrong interface version"); + } + + auto* windowerPlugin = createWindower(); + auto* ashitaPlugin = createAshita(""); + if (windowerPlugin == nullptr || ashitaPlugin == nullptr) + { + if (windowerPlugin != nullptr) + windowerPlugin->Dtor(1); + if (ashitaPlugin != nullptr) + destroyAshita(ashitaPlugin); + FreeLibrary(module); + return fail("host adapter construction failed"); + } + windowerPlugin->Dtor(1); + auto* nonDeletingPlugin = createWindower(); + if (nonDeletingPlugin == nullptr || nonDeletingPlugin->Dtor(0) != nonDeletingPlugin) + { + destroyAshita(ashitaPlugin); + FreeLibrary(module); + return fail("Windower non-deleting destructor contract failed"); + } + destroyAshita(ashitaPlugin); + if (getAshitaVersion() <= 0.0) + { + FreeLibrary(module); + return fail("Ashita export returned an invalid interface version"); + } + FreeLibrary(module); + + std::cout << "native policy, hook helpers, exports, and adapter construction checks passed\n"; + return 0; +} diff --git a/tests/test_python_tools.py b/tests/test_python_tools.py index 11934eb..53aea80 100644 --- a/tests/test_python_tools.py +++ b/tests/test_python_tools.py @@ -1,10 +1,20 @@ from __future__ import annotations +import json +import re import struct -from pathlib import Path +import zipfile +from pathlib import Path, PurePosixPath import pytest -from tools import build_colorset, build_runtime_overlay, colorize_tail, dat_chunks, splice_tail +from tools import ( + build_colorset, + build_packages, + build_runtime_overlay, + colorize_tail, + dat_chunks, + splice_tail, +) def make_chunk(tag: bytes, chunk_type: int, payload: bytes = b"") -> bytes: @@ -118,7 +128,7 @@ def test_palette_and_fixed_model_tables_preserve_calibrated_values() -> None: def test_cpp_face_map_preserves_all_sixteen_hair_values() -> None: - source = (Path(__file__).parents[1] / "src" / "fluffytail.cpp").read_text(encoding="ascii") + source = (Path(__file__).parents[1] / "src" / "tail_policy.hpp").read_text(encoding="ascii") table = source.split("FACE_COLOURS[16] = {", 1)[1].split("};", 1)[0] values = [value.strip().strip('"') for value in table.split(",") if value.strip()] assert values == [ @@ -141,6 +151,178 @@ def test_cpp_face_map_preserves_all_sixteen_hair_values() -> None: ] +def make_test_dll() -> bytes: + """Build the smallest PE32 DLL header accepted by the package validator.""" + + data = bytearray(0x200) + data[:2] = b"MZ" + struct.pack_into(" Path: + overlay = root / "overlay" + rom = overlay / "ROM" + for index in range(build_packages.EXPECTED_DAT_COUNT): + folder = rom / str(index // 100) + folder.mkdir(parents=True, exist_ok=True) + (folder / f"{index}.DAT").write_bytes(make_dat(make_chunk(b"info", 5))) + return overlay + + +def test_package_builder_is_deterministic_and_host_specific(tmp_path: Path) -> None: + dll = tmp_path / "fluffytail.dll" + dll.write_bytes(make_test_dll()) + overlay = make_test_overlay(tmp_path) + + first = build_packages.build_packages(dll, overlay, tmp_path / "first") + second = build_packages.build_packages(dll, overlay, tmp_path / "second") + assert [result.file_count for result in first] == [413, 413] + assert [result.path.name for result in first] == ["fluffytail.zip", "fluffytail-windower.zip"] + assert [result.path.read_bytes() for result in first] == [ + result.path.read_bytes() for result in second + ] + + expected_prefixes = { + "ashita": "polplugins/DATs/FluffyTail/ROM/", + "windower": "addons/XIPivot/data/DATs/FluffyTail/ROM/", + } + for result in first: + with zipfile.ZipFile(result.path) as archive: + names = archive.namelist() + assert sum(name.endswith(".DAT") for name in names) == 408 + assert any(name.startswith(expected_prefixes[result.host]) for name in names) + assert { + "FluffyTail/README.md", + "FluffyTail/LICENSES.md", + "FluffyTail/LICENSE.GPL.txt", + "FluffyTail/LICENSE.md", + }.issubset(names) + root = Path(__file__).parents[1] + for document in build_packages.PACKAGE_DOCUMENTS: + assert archive.read(f"FluffyTail/{document}") == (root / document).read_bytes() + assert "pivot.ini" not in names + assert all( + info.date_time == build_packages.ZIP_TIMESTAMP for info in archive.infolist() + ) + + +def test_package_builder_rejects_unsafe_or_duplicate_archive_paths() -> None: + with pytest.raises(ValueError, match="escapes package root"): + build_packages.validate_archive_entries([(Path("../escape"), b"")]) + with pytest.raises(ValueError, match="duplicate archive path"): + build_packages.validate_archive_entries( + [(Path("FluffyTail/README.md"), b"a"), (Path("fluffytail/readme.md"), b"b")] + ) + with pytest.raises(ValueError, match="unsafe character"): + build_packages.validate_archive_entries([(PurePosixPath("C:/escape"), b"")]) + + +def test_exports_def_preserves_both_host_contracts() -> None: + source = (Path(__file__).parents[1] / "src" / "exports.def").read_text(encoding="ascii") + assert "expCreatePlugin @1" in source + assert "expDestroyPlugin @2" in source + assert "expGetInterfaceVersion @3" in source + assert " CreateInstance\n" in source + assert " GetInterfaceVersion\n" in source + + +def test_windower_source_contract_preserves_abi_slots() -> None: + root = Path(__file__).parents[1] + header = (root / "src" / "windower.hpp").read_text(encoding="ascii") + implementation = (root / "src" / "windower.cpp").read_text(encoding="ascii") + assert "INTERFACE_VERSION = 0x04070300" in header + compact_header = " ".join(header.split()) + assert "virtual auto __stdcall GetMMFSettingsHandler() -> Settings* = 0;" in compact_header + assert "constexpr size_t RESET_VTABLE_INDEX = 14;" in implementation + assert "constexpr size_t DRAW_VTABLE_INDEX = 71;" in implementation + + def virtual_names(class_name: str) -> list[str]: + body = header.split(f"class {class_name}", 1)[1].split("};", 1)[0] + return re.findall(r"virtual\s+(?:auto|void)\s+(?:__stdcall|__thiscall)\s+(\w+)\s*\(", body) + + manager_names = [ + "GetMMFSettingsHandler", + "GetHWND", + "GetDirect3D8Device", + "GetConsole", + "GetTextHandler", + "GetPrimitiveHandler", + "GetPacketStreamHandler", + "GetFFXI", + "Dtor", + ] + plugin_names = [ + "GetPluginAuthor", + "GetPluginName", + "Load", + "Dealloc", + "IgnoreUnload", + "PreRender", + "PostRender", + "PluginCommand", + "UnhandledCommand", + "IncomingText", + "OutgoingText", + "IncomingChunk", + "OutgoingChunk", + "Mouse", + "Keyboard", + "AddItem", + "RemoveItem", + "Dtor", + ] + console_names = [ + "OpenConsole", + "IsVisible", + "SetPosition", + "Write", + "Clear", + "SendCommand", + ] + assert virtual_names("Console") == console_names + assert virtual_names("PluginManager") == manager_names + assert virtual_names("PluginBase") == plugin_names + + fixture = json.loads((root / "tests" / "windower_abi_fixture.json").read_text(encoding="ascii")) + assert fixture["interface_version"]["value"] == "0x04070300" + assert [slot["name"] for slot in fixture["console"]["slots"]] == console_names + assert [slot["stack_bytes"] for slot in fixture["console"]["slots"]] == [8, 4, 12, 8, 4, 12] + assert [slot["name"] for slot in fixture["plugin_manager"]["slots"]] == manager_names + assert [slot["stack_bytes"] for slot in fixture["plugin_manager"]["slots"]] == [4] * 9 + assert fixture["plugin_base"]["rtti_bases"] == [ + ".?AVWindowerPlugin@@", + ".?AUIPlugin@@", + ] + assert fixture["plugin_base"]["host_slot_count"] == 18 + assert fixture["plugin_base"]["concrete_slot_count"] == 34 + assert [slot["name"] for slot in fixture["plugin_base"]["slots"]] == plugin_names + assert len(fixture["plugin_base"]["helper_slots"]) == 16 + assert [slot["stack_bytes"] for slot in fixture["plugin_base"]["slots"]] == [ + 4, + 4, + 8, + 4, + 4, + 4, + 4, + 8, + 8, + 16, + 16, + 20, + 20, + 24, + 16, + 20, + 20, + 4, + ] + + def test_cli_contracts_reject_bad_inputs(tmp_path: Path) -> None: with pytest.raises(SystemExit): colorize_tail.main([str(tmp_path / "missing"), str(tmp_path / "out"), "xyz"]) diff --git a/tests/windower_abi_fixture.json b/tests/windower_abi_fixture.json new file mode 100644 index 0000000..eca65db --- /dev/null +++ b/tests/windower_abi_fixture.json @@ -0,0 +1,101 @@ +{ + "schema": 1, + "retrieved_utc": "2026-08-13", + "manifest": { + "url": "https://files.windower.net/4/live/manifest.xml", + "sha256": "D69392F6601DF5B504E1640CF8CF88AB09CEF693D550C773448BB8D46D4D6FCA", + "hook_version": "4.7.9.0", + "config_version": "2.0.1.13" + }, + "artifacts": { + "hook": { + "url": "https://files.windower.net/4/live/Hook.dll", + "sha256": "6EEDEC7B7602324B8187158738E9EDBCE44DD3F6EEAD67E21CFC7F2DC833530D" + }, + "config": { + "url": "https://files.windower.net/4/live/plugins/Config.dll", + "sha256": "863506E00F79AE2398A9FA295736EC56C2C07E9B1E518D3A763C2B96D4C18109" + } + }, + "interface_version": { + "artifact": "config", + "function_rva": "0x0004B7A0", + "bytes": "B800030704C3", + "value": "0x04070300" + }, + "console": { + "artifact": "hook", + "rtti_name": ".?AVConsole@@", + "vtable_rva": "0x00182A0C", + "slots": [ + {"name": "OpenConsole", "function_rva": "0x00046730", "return_rva": "0x00046758", "stack_bytes": 8}, + {"name": "IsVisible", "function_rva": "0x00046760", "return_rva": "0x0004676D", "stack_bytes": 4}, + {"name": "SetPosition", "function_rva": "0x000401B0", "return_rva": "0x000401E9", "stack_bytes": 12}, + {"name": "Write", "function_rva": "0x0003ECA0", "return_rva": "0x0003ED4E", "stack_bytes": 8}, + {"name": "Clear", "function_rva": "0x0003FA60", "return_rva": "0x0003FB92", "stack_bytes": 4}, + {"name": "SendCommand", "function_rva": "0x00042000", "return_rva": "0x000420AF", "stack_bytes": 12} + ] + }, + "plugin_manager": { + "artifact": "hook", + "rtti_name": ".?AVPluginManager@@", + "vtable_rva": "0x00183BDC", + "slots": [ + {"name": "GetMMFSettingsHandler", "function_rva": "0x00072DA0", "return_rva": "0x00072DA8", "stack_bytes": 4}, + {"name": "GetHWND", "function_rva": "0x00072D90", "return_rva": "0x00072D98", "stack_bytes": 4}, + {"name": "GetDirect3D8Device", "function_rva": "0x00072DB0", "return_rva": "0x00072DBD", "stack_bytes": 4}, + {"name": "GetConsole", "function_rva": "0x00072DC0", "return_rva": "0x00072DC8", "stack_bytes": 4}, + {"name": "GetTextHandler", "function_rva": "0x00072DD0", "return_rva": "0x00072DDB", "stack_bytes": 4}, + {"name": "GetPrimitiveHandler", "function_rva": "0x00072DE0", "return_rva": "0x00072DEB", "stack_bytes": 4}, + {"name": "GetPacketStreamHandler", "function_rva": "0x00072DF0", "return_rva": "0x00072DFA", "stack_bytes": 4}, + {"name": "GetFFXI", "function_rva": "0x00072E00", "return_rva": "0x00072E4E", "stack_bytes": 4}, + {"name": "Dtor", "function_rva": "0x0006ED80", "return_rva": "0x0006EE06", "stack_bytes": 4} + ] + }, + "plugin_base": { + "artifact": "config", + "rtti_name": ".?AVWindowerPlugin@@", + "rtti_bases": [".?AVWindowerPlugin@@", ".?AUIPlugin@@"], + "vtable_rva": "0x00082C84", + "host_slot_count": 18, + "concrete_slot_count": 34, + "slots": [ + {"name": "GetPluginAuthor", "function_rva": "0x0004D270", "return_rva": "0x0004D27A", "stack_bytes": 4}, + {"name": "GetPluginName", "function_rva": "0x0004D260", "return_rva": "0x0004D26A", "stack_bytes": 4}, + {"name": "Load", "function_rva": "0x0004D150", "return_rva": "0x0004D245", "stack_bytes": 8}, + {"name": "Dealloc", "function_rva": "0x0004D130", "return_rva": "0x0004D14D", "stack_bytes": 4}, + {"name": "IgnoreUnload", "function_rva": "0x0004D120", "return_rva": "0x0004D12B", "stack_bytes": 4}, + {"name": "PreRender", "function_rva": "0x0004D110", "return_rva": "0x0004D119", "stack_bytes": 4}, + {"name": "PostRender", "function_rva": "0x0004D100", "return_rva": "0x0004D109", "stack_bytes": 4}, + {"name": "PluginCommand", "function_rva": "0x0004D0E0", "return_rva": "0x0004D0FD", "stack_bytes": 8}, + {"name": "UnhandledCommand", "function_rva": "0x0004D0C0", "return_rva": "0x0004D0CF", "stack_bytes": 8}, + {"name": "IncomingText", "function_rva": "0x0004D0A0", "return_rva": "0x0004D0B5", "stack_bytes": 16}, + {"name": "OutgoingText", "function_rva": "0x0004D080", "return_rva": "0x0004D095", "stack_bytes": 16}, + {"name": "IncomingChunk", "function_rva": "0x0004CE70", "return_rva": "0x0004D065", "stack_bytes": 20}, + {"name": "OutgoingChunk", "function_rva": "0x0004CE40", "return_rva": "0x0004CE5F", "stack_bytes": 20}, + {"name": "Mouse", "function_rva": "0x0004CE10", "return_rva": "0x0004CE33", "stack_bytes": 24}, + {"name": "Keyboard", "function_rva": "0x0004CDF0", "return_rva": "0x0004CE0B", "stack_bytes": 16}, + {"name": "AddItem", "function_rva": "0x0004CDD0", "return_rva": "0x0004CDE9", "stack_bytes": 20}, + {"name": "RemoveItem", "function_rva": "0x0004CDB0", "return_rva": "0x0004CDCC", "stack_bytes": 20}, + {"name": "Dtor", "function_rva": "0x00020400", "return_rva": "0x00020420", "stack_bytes": 4} + ], + "helper_slots": [ + {"name": "WindowerPluginHelper18", "function_rva": "0x0004BFD0", "return_rva": "0x0004BFD0", "return_bytes": "C20400"}, + {"name": "WindowerPluginHelper19", "function_rva": "0x0004C1B0", "return_rva": "0x0004C1B0", "return_bytes": "C20000"}, + {"name": "WindowerPluginHelper20", "function_rva": "0x0004C1A0", "return_rva": "0x0004C1A2", "return_bytes": "C3"}, + {"name": "WindowerPluginHelper21", "function_rva": "0x0004C190", "return_rva": "0x0004C190", "return_bytes": "C20000"}, + {"name": "WindowerPluginHelper22", "function_rva": "0x0004BFC0", "return_rva": "0x0004BFC0", "return_bytes": "C20000"}, + {"name": "WindowerPluginHelper23", "function_rva": "0x0004BFB0", "return_rva": "0x0004BFB0", "return_bytes": "C20400"}, + {"name": "WindowerPluginHelper24", "function_rva": "0x0004C180", "return_rva": "0x0004C182", "return_bytes": "C20400"}, + {"name": "WindowerPluginHelper25", "function_rva": "0x0004C170", "return_rva": "0x0004C170", "return_bytes": "C20C00"}, + {"name": "WindowerPluginHelper26", "function_rva": "0x0004C160", "return_rva": "0x0004C160", "return_bytes": "C20C00"}, + {"name": "WindowerPluginHelper27", "function_rva": "0x0004C150", "return_rva": "0x0004C154", "return_bytes": "C21000"}, + {"name": "WindowerPluginHelper28", "function_rva": "0x0004C140", "return_rva": "0x0004C144", "return_bytes": "C21000"}, + {"name": "WindowerPluginHelper29", "function_rva": "0x0004C130", "return_rva": "0x0004C132", "return_bytes": "C21400"}, + {"name": "WindowerPluginHelper30", "function_rva": "0x0004C120", "return_rva": "0x0004C122", "return_bytes": "C20C00"}, + {"name": "WindowerPluginHelper31", "function_rva": "0x0004C110", "return_rva": "0x0004C110", "return_bytes": "C21000"}, + {"name": "WindowerPluginHelper32", "function_rva": "0x0004C100", "return_rva": "0x0004C100", "return_bytes": "C21000"}, + {"name": "WindowerPluginHelper33", "function_rva": "0x0004BFA0", "return_rva": "0x0004BFA0", "return_bytes": "C20000"} + ] + } +} diff --git a/tools/build_packages.py b/tools/build_packages.py new file mode 100644 index 0000000..0e6352a --- /dev/null +++ b/tools/build_packages.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Build deterministic Ashita and Windower release archives.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import struct +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +try: + from .dat_chunks import validate_chunks + from .tool_utils import add_force, ensure_output +except ImportError: # Direct ``python tools/build_packages.py`` execution. + from dat_chunks import validate_chunks + from tool_utils import add_force, ensure_output + + +EXPECTED_DAT_COUNT = 408 +PROJECT_ROOT = Path(__file__).resolve().parent.parent +PACKAGE_DOCUMENTS = ("README.md", "LICENSES.md", "LICENSE.GPL.txt", "LICENSE.md") +DOCUMENT_PREFIX = PurePosixPath("FluffyTail") +ARCHIVES = { + "ashita": ( + "fluffytail.zip", + PurePosixPath("polplugins/DATs/FluffyTail/ROM"), + ), + "windower": ( + "fluffytail-windower.zip", + PurePosixPath("addons/XIPivot/data/DATs/FluffyTail/ROM"), + ), +} +ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) +ZIP_MODE = 0o100644 << 16 + + +@dataclass(frozen=True) +class PackageResult: + """One completed host package.""" + + host: str + path: Path + sha256: str + file_count: int + + +def validate_dll(path: Path) -> bytes: + """Read and minimally validate the required 32-bit Windows plugin DLL.""" + + try: + data = path.read_bytes() + except OSError as error: + raise ValueError(f"cannot read plugin DLL {path}: {error}") from error + + if len(data) < 0x40 or data[:2] != b"MZ": + raise ValueError(f"plugin is not a PE DLL: {path}") + pe_offset = struct.unpack_from(" len(data) - 26 or data[pe_offset : pe_offset + 4] != b"PE\x00\x00": + raise ValueError(f"plugin has an invalid PE header: {path}") + + machine, _, _, _, _, optional_size, characteristics = struct.unpack_from( + " len(data): + raise ValueError(f"plugin has an invalid PE optional header: {path}") + optional_magic = struct.unpack_from(" list[tuple[PurePosixPath, bytes]]: + """Return the exact validated ``ROM//.DAT`` payload.""" + + if path.is_symlink() or not path.is_dir(): + raise ValueError(f"overlay directory does not exist: {path}") + + entries = list(path.iterdir()) + if ( + len(entries) != 1 + or entries[0].name != "ROM" + or entries[0].is_symlink() + or not entries[0].is_dir() + ): + raise ValueError(f"overlay must contain only one ROM directory: {path}") + + rom = entries[0] + # Inspect every descendant before collecting files. In particular, do not + # allow a symlinked directory to make files outside the overlay part of the + # package (or let a consumer follow one after extraction). + descendants = list(rom.rglob("*")) + for entry in descendants: + if entry.is_symlink(): + raise ValueError(f"overlay must not contain symbolic links: {entry}") + + source_files = sorted((entry for entry in descendants if entry.is_file()), key=str) + if len(source_files) != EXPECTED_DAT_COUNT: + raise ValueError( + f"overlay must contain exactly {EXPECTED_DAT_COUNT} DAT files; " + f"found {len(source_files)}: {rom}" + ) + + payload: list[tuple[PurePosixPath, bytes]] = [] + casefolded: set[str] = set() + for source in source_files: + relative = source.relative_to(rom) + if ( + len(relative.parts) != 2 + or not relative.parts[0].isdecimal() + or not relative.stem.isdecimal() + or relative.suffix != ".DAT" + ): + raise ValueError(f"invalid overlay path; expected /.DAT: {relative}") + + archive_path = PurePosixPath(*relative.parts) + folded = archive_path.as_posix().casefold() + if folded in casefolded: + raise ValueError(f"case-insensitive duplicate overlay path: {archive_path}") + casefolded.add(folded) + + try: + data = source.read_bytes() + except OSError as error: + raise ValueError(f"cannot read overlay DAT {source}: {error}") from error + validation = validate_chunks(data) + if not validation.ok: + raise ValueError(f"invalid overlay DAT {relative}: {validation.error}") + payload.append((archive_path, data)) + + return payload + + +def validate_archive_entries(entries: list[tuple[PurePosixPath, bytes]]) -> None: + """Reject unsafe or duplicate paths before writing a ZIP archive.""" + + seen: set[str] = set() + for archive_path, _ in entries: + # ZIP names use POSIX separators regardless of the host running this + # tool. Reject absolute and traversal names, including names that a + # Windows extractor could reinterpret through a backslash. + if not archive_path.parts or archive_path.is_absolute(): + raise ValueError(f"archive path must be relative: {archive_path!s}") + if any(part in {"", ".", ".."} for part in archive_path.parts): + raise ValueError(f"archive path escapes package root: {archive_path!s}") + if any("\\" in part or "\x00" in part or ":" in part for part in archive_path.parts): + raise ValueError(f"archive path contains an unsafe character: {archive_path!s}") + + folded = archive_path.as_posix().casefold() + if folded in seen: + raise ValueError(f"duplicate archive path: {archive_path!s}") + seen.add(folded) + + +def zip_info(path: PurePosixPath) -> zipfile.ZipInfo: + """Create stable metadata for one regular archive file.""" + + info = zipfile.ZipInfo(path.as_posix(), ZIP_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = ZIP_MODE + return info + + +def write_archive(path: Path, entries: list[tuple[PurePosixPath, bytes]]) -> None: + """Write an ordered archive atomically.""" + + validate_archive_entries(entries) + + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + with zipfile.ZipFile(temporary, "w", allowZip64=False) as archive: + for archive_path, data in sorted(entries, key=lambda item: item[0].as_posix()): + archive.writestr(zip_info(archive_path), data, compresslevel=9) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def build_packages( + dll: Path, + overlay: Path, + output: Path, + *, + force: bool = False, +) -> list[PackageResult]: + """Validate inputs and create both host-specific archives from one payload.""" + + dll_data = validate_dll(dll) + dat_payload = validate_overlay(overlay) + documents = [] + for name in PACKAGE_DOCUMENTS: + path = PROJECT_ROOT / name + try: + documents.append((DOCUMENT_PREFIX / name, path.read_bytes())) + except OSError as error: + raise ValueError(f"cannot read required package notice {path}: {error}") from error + ensure_output(output, force=force) + + results = [] + for host, (filename, overlay_prefix) in ARCHIVES.items(): + entries = [*documents, (PurePosixPath("plugins/fluffytail.dll"), dll_data)] + entries.extend((overlay_prefix / relative, data) for relative, data in dat_payload) + archive_path = output / filename + write_archive(archive_path, entries) + results.append( + PackageResult( + host=host, + path=archive_path, + sha256=hashlib.sha256(archive_path.read_bytes()).hexdigest().upper(), + file_count=len(entries), + ) + ) + return results + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Build deterministic Ashita and Windower FluffyTail release archives." + ) + parser.add_argument( + "dll", + nargs="?", + type=Path, + default=Path("bin/fluffytail.dll"), + help="dual-host x86 plugin DLL (default: bin/fluffytail.dll)", + ) + parser.add_argument( + "overlay", + nargs="?", + type=Path, + default=Path("work/runtime-overlay"), + help="runtime overlay directory containing ROM/ (default: work/runtime-overlay)", + ) + parser.add_argument( + "output", + nargs="?", + type=Path, + default=Path("work/packages"), + help="archive output directory (default: work/packages)", + ) + add_force(parser) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + results = build_packages(args.dll, args.overlay, args.output, force=args.force) + except (OSError, ValueError, zipfile.BadZipFile) as error: + print(f"error: {error}") + return 2 + + for result in results: + print( + f"host={result.host} files={result.file_count} sha256={result.sha256} " + f"output={result.path}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_windower_abi.py b/tools/verify_windower_abi.py new file mode 100644 index 0000000..3bc6fc2 --- /dev/null +++ b/tools/verify_windower_abi.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Verify the checked-in Windower ABI fixture against official PE binaries.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_FIXTURE = ROOT / "tests" / "windower_abi_fixture.json" +EXPECTED_TABLE_COUNTS = { + ".?AVConsole@@": (6, 6), + ".?AVPluginManager@@": (9, 9), + # WindowerPlugin derives from the 18-slot IPlugin host interface, then adds + # 16 helper virtuals used by first-party plugin implementations. + ".?AVWindowerPlugin@@": (18, 34), +} + + +def parse_int(value: str) -> int: + return int(value, 0) + + +class PeImage: + def __init__(self, data: bytes) -> None: + self.data = data + if len(data) < 0x40 or data[:2] != b"MZ": + raise ValueError("not a PE image") + pe_offset = struct.unpack_from(" int: + for virtual_address, mapped_size, raw_address, raw_size, _ in self.sections: + relative = rva - virtual_address + if 0 <= relative < mapped_size and relative < raw_size: + return raw_address + relative + raise ValueError(f"RVA 0x{rva:08X} is not file-backed") + + def bytes_at(self, rva: int, size: int) -> bytes: + offset = self.offset(rva) + return self.data[offset : offset + size] + + def pointer_at(self, rva: int) -> int: + return struct.unpack(" str: + offset = self.offset(rva) + end = self.data.index(b"\0", offset) + return self.data[offset:end].decode("ascii") + + def is_executable_pointer(self, pointer: int) -> bool: + rva = pointer - self.image_base + return any( + virtual_address <= rva < virtual_address + mapped_size + and (characteristics & 0x20000000) != 0 + for virtual_address, mapped_size, _, _, characteristics in self.sections + ) + + def rtti_bases(self, vtable_rva: int) -> list[str]: + locator_rva = self.pointer_at(vtable_rva - 4) - self.image_base + hierarchy_rva = self.pointer_at(locator_rva + 16) - self.image_base + base_count = self.pointer_at(hierarchy_rva + 8) + base_array_rva = self.pointer_at(hierarchy_rva + 12) - self.image_base + names = [] + for index in range(base_count): + descriptor_rva = self.pointer_at(base_array_rva + index * 4) - self.image_base + type_rva = self.pointer_at(descriptor_rva) - self.image_base + names.append(self.c_string_at(type_rva + 8)) + return names + + def first_return(self, function_rva: int, limit: int = 0x400) -> tuple[int, bytes]: + code = self.bytes_at(function_rva, limit) + position = 0 + while position < len(code): + opcode = code[position] + if opcode == 0xC3: + return function_rva + position, b"\xc3" + if opcode == 0xC2 and position + 3 <= len(code): + return function_rva + position, code[position : position + 3] + position += instruction_length(code, position) + raise ValueError(f"function at 0x{function_rva:08X} has no return within 0x{limit:X} bytes") + + +def instruction_length(code: bytes, position: int) -> int: + """Return a conservative x86 instruction length for fixture return scanning.""" + + start = position + while position < len(code) and code[position] in { + 0x26, + 0x2E, + 0x36, + 0x3E, + 0x64, + 0x65, + 0x66, + 0x67, + 0xF0, + 0xF2, + 0xF3, + }: + position += 1 + if position >= len(code): + return max(1, position - start) + + opcode = code[position] + position += 1 + if opcode == 0x0F and position < len(code): + opcode = 0x100 | code[position] + position += 1 + + relative_sizes = { + 0x68: 4, + 0x6A: 1, + 0xA0: 4, + 0xA1: 4, + 0xA2: 4, + 0xA3: 4, + 0xB8: 4, + 0xB9: 4, + 0xBA: 4, + 0xBB: 4, + 0xBC: 4, + 0xBD: 4, + 0xBE: 4, + 0xBF: 4, + 0xC2: 2, + 0xC6: 1, + 0xC7: 4, + 0xE8: 4, + 0xE9: 4, + 0xEB: 1, + } + if 0x70 <= opcode <= 0x7F: + return position - start + 1 + if 0x180 <= opcode <= 0x18F: + return position - start + 4 + if opcode in relative_sizes and opcode not in {0xC6, 0xC7}: + return position - start + relative_sizes[opcode] + + has_modrm = ( + opcode + in { + 0x01, + 0x03, + 0x0B, + 0x21, + 0x23, + 0x29, + 0x2B, + 0x31, + 0x32, + 0x33, + 0x39, + 0x3B, + 0x63, + 0x69, + 0x6B, + 0x80, + 0x81, + 0x83, + 0x84, + 0x85, + 0x88, + 0x89, + 0x8A, + 0x8B, + 0x8D, + 0x8F, + 0xC0, + 0xC1, + 0xC6, + 0xC7, + 0xD0, + 0xD1, + 0xD2, + 0xD3, + 0xF6, + 0xF7, + 0xFE, + 0xFF, + } + or 0x110 <= opcode <= 0x117 + or 0x128 <= opcode <= 0x12F + ) + if not has_modrm or position >= len(code): + return max(1, position - start) + + modrm = code[position] + position += 1 + mod = modrm >> 6 + rm = modrm & 7 + if mod != 3 and rm == 4 and position < len(code): + sib = code[position] + position += 1 + if mod == 0 and (sib & 7) == 5: + position += 4 + if mod == 0 and rm == 5: + position += 4 + elif mod == 1: + position += 1 + elif mod == 2: + position += 4 + if opcode in {0x69, 0x81, 0xC7}: + position += 4 + elif opcode in {0x6B, 0x80, 0x83, 0xC0, 0xC1, 0xC6}: + position += 1 + return max(1, position - start) + + +def return_bytes(stack_bytes: int) -> bytes: + if stack_bytes == 0: + return b"\xc3" + return b"\xc2" + struct.pack(" None: + vtable_rva = parse_int(table["vtable_rva"]) + locator_rva = image.pointer_at(vtable_rva - 4) - image.image_base + type_descriptor_rva = image.pointer_at(locator_rva + 12) - image.image_base + rtti_name = image.c_string_at(type_descriptor_rva + 8) + if rtti_name != table["rtti_name"]: + raise ValueError(f"vtable RTTI is {rtti_name}, expected {table['rtti_name']}") + if "rtti_bases" in table and image.rtti_bases(vtable_rva) != table["rtti_bases"]: + raise ValueError(f"{table['rtti_name']} RTTI inheritance changed") + + host_slots = table["slots"] + host_slot_count = table.get("host_slot_count", len(host_slots)) + if len(host_slots) != host_slot_count: + raise ValueError(f"{table['rtti_name']} host slot fixture is truncated") + helper_slots = table.get("helper_slots", []) + all_slots = host_slots + helper_slots + concrete_slot_count = table.get("concrete_slot_count", len(all_slots)) + expected_counts = EXPECTED_TABLE_COUNTS.get(table["rtti_name"]) + if expected_counts != (host_slot_count, concrete_slot_count): + raise ValueError(f"{table['rtti_name']} declared slot counts changed") + if len(all_slots) != concrete_slot_count: + raise ValueError(f"{table['rtti_name']} concrete slot fixture is truncated") + + for index, slot in enumerate(all_slots): + function_rva = parse_int(slot["function_rva"]) + observed_pointer = image.pointer_at(vtable_rva + index * 4) + observed = observed_pointer - image.image_base + if observed != function_rva: + raise ValueError( + f"{table['rtti_name']} slot {index} {slot['name']} points to " + f"0x{observed:08X}, expected 0x{function_rva:08X}" + ) + return_rva = parse_int(slot["return_rva"]) + if not image.is_executable_pointer(observed_pointer): + raise ValueError(f"{table['rtti_name']} slot {index} is not executable") + expected_return = ( + bytes.fromhex(slot["return_bytes"]) + if "return_bytes" in slot + else return_bytes(slot["stack_bytes"]) + ) + observed_return_rva, observed_return = image.first_return(function_rva) + if observed_return_rva != return_rva or observed_return != expected_return: + raise ValueError(f"{table['rtti_name']} slot {index} has different stack cleanup") + + next_pointer = image.pointer_at(vtable_rva + concrete_slot_count * 4) + if image.is_executable_pointer(next_pointer): + raise ValueError(f"{table['rtti_name']} concrete vtable has undeclared executable slots") + + +def download(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "FluffyTail-ABI-Verifier/1.0"}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + + +def load_artifact(name: str, artifact: dict[str, str], directory: Path | None) -> bytes: + if directory is None: + data = download(artifact["url"]) + else: + data = (directory / f"{name.capitalize()}-live.dll").read_bytes() + digest = hashlib.sha256(data).hexdigest().upper() + if digest != artifact["sha256"]: + raise ValueError(f"{name} SHA-256 is {digest}, expected {artifact['sha256']}") + return data + + +def verify(fixture_path: Path, directory: Path | None = None) -> None: + fixture = json.loads(fixture_path.read_text(encoding="ascii")) + manifest = fixture["manifest"] + manifest_data = ( + download(manifest["url"]) + if directory is None + else (directory / "manifest-live.xml").read_bytes() + ) + manifest_digest = hashlib.sha256(manifest_data).hexdigest().upper() + if manifest_digest != manifest["sha256"]: + raise ValueError(f"manifest SHA-256 is {manifest_digest}, expected {manifest['sha256']}") + manifest_root = ET.fromstring(manifest_data) + if manifest_root.findtext("hook/version") != manifest["hook_version"]: + raise ValueError("manifest Hook version changed") + config_version = next( + ( + plugin.findtext("version") + for plugin in manifest_root.findall("plugins/plugin") + if plugin.findtext("name") == "Config" + ), + None, + ) + if config_version != manifest["config_version"]: + raise ValueError("manifest Config version changed") + + images = { + name: PeImage(load_artifact(name, artifact, directory)) + for name, artifact in fixture["artifacts"].items() + } + version = fixture["interface_version"] + version_image = images[version["artifact"]] + expected = bytes.fromhex(version["bytes"]) + if version_image.bytes_at(parse_int(version["function_rva"]), len(expected)) != expected: + raise ValueError("GetInterfaceVersion implementation changed") + for key in ("console", "plugin_manager", "plugin_base"): + table = fixture[key] + verify_table(images[table["artifact"]], table) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE) + parser.add_argument( + "--directory", + type=Path, + help="directory containing Hook-live.dll and Config-live.dll; downloads otherwise", + ) + args = parser.parse_args(argv) + verify(args.fixture, args.directory) + print("Windower 4.7.9 ABI fixture verified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())