Custom Client UI with StageManager Input

From JFTSE Wiki
Jump to navigation Jump to search

Overview

This page shows a small proof of concept for loading a separate custom XML GUI through the Fantasy Tennis client GUI system.

The goal is simple:

  • construct a small custom GUI owner;
  • attach it to the currently active GameDialogStage;
  • load a custom Gui_Test.xml;
  • receive XML callback events;
  • forward input through the normal StageManager input path.

This does not replace an existing popup. The custom GUI is attached as an additional child owner of the current stage owner.

The example intentionally avoids custom control type registration. It only uses normal XML controls such as Static, Button, and EditBox.

StageManager Input Hook

The Fantasy Tennis client already routes keyboard and mouse input through the StageManager. A manually attached custom GUI owner can render and receive callbacks, but it still needs to receive input messages.

The hook below gives the custom GUI a chance to consume input before the original StageManager::HandleInput continues.

using StageManagerHandleInputFn = bool(FTSDK_FASTCALL*)(
    FTSDK::StageManager* self,
    int edx0,
    uint32_t msg,
    int32_t wParam,
    int32_t lParam
);

static StageManagerHandleInputFn o_StageManagerHandleInput = nullptr;

bool FTSDK_FASTCALL hk_StageManagerHandleInput(
    FTSDK::StageManager* self,
    int edx0,
    uint32_t msg,
    int32_t wParam,
    int32_t lParam
) {
    FUNCTION_GUARD;

    if (CustomGuiTest::HandleInput(msg, wParam, lParam))
        return true;

    return o_StageManagerHandleInput(self, edx0, msg, wParam, lParam);
}

Install the hook on the current StageManager instance. In the currently analyzed client build, HandleInput is vtable index 38.

static std::unique_ptr<ShadowVTManager> g_stageManagerHook;

static void* GetObjectVTable(const void* object) {
    return object ? *reinterpret_cast<void* const*>(object) : nullptr;
}

void InstallStageManagerInputHook() {
    auto* manager = FTSDK::GetStageManager();
    if (!manager)
        return;

    g_stageManagerHook = std::make_unique<ShadowVTManager>();
    g_stageManagerHook->Setup(manager);
    g_stageManagerHook->Hook(38, hk_StageManagerHandleInput);

    o_StageManagerHandleInput =
        g_stageManagerHook->GetOriginal<StageManagerHandleInputFn>(38);

    std::printf(
        "[StageManagerHook] hooked manager=%p vt=%p index=38 original=%p hook=%p\n",
        manager,
        GetObjectVTable(manager),
        o_StageManagerHandleInput,
        hk_StageManagerHandleInput
    );
}

Custom GUI Test Code

The test creates one custom GameDialogStage owner and attaches it to the current stage.

It uses GuiBind to bind XML controls to command ids. The callback handles button clicks and edit box changes.

namespace CustomGuiTest {

    enum CommandId : int32_t {
        Cmd_TestButton   = 1000,
        Cmd_CloseButton  = 1001,
        Cmd_WindowStatic = 1002,
        Cmd_TitleStatic  = 1003,
        Cmd_InfoStatic   = 1004,
        Cmd_InputEditBox = 1005,
    };

    struct CustomGuiContext {
        FTSDK::GameDialogStage* owner = nullptr;
        FTSDK::GameDialogStage* parentOwner = nullptr;
        bool loaded = false;
        bool opened = false;
    };

    static CustomGuiContext g_ctx{};

    // The XML/dialog/control objects are allocated by the client.
    // This buffer only stores the custom owner object itself.
    alignas(16) static std::uint8_t g_customOwnerStorage[0x800]{};

    static constexpr FTSDK::Structs::GuiBind g_customBinds[] = {
        { Cmd_TestButton,   "btTest",         FTSDK::GuiControlType::Button },
        { Cmd_CloseButton,  "btClose",        FTSDK::GuiControlType::Button },
        { Cmd_WindowStatic, "stCustomWindow", FTSDK::GuiControlType::Static },
        { Cmd_TitleStatic,  "stCustomTop",    FTSDK::GuiControlType::Static },
        { Cmd_InfoStatic,   "stCustomInfo",   FTSDK::GuiControlType::Static },
        { Cmd_InputEditBox, "ebCustomInput",  FTSDK::GuiControlType::EditBox },
    };

    static constexpr int32_t CustomBindCount =
        static_cast<int32_t>(sizeof(g_customBinds) / sizeof(g_customBinds[0]));

    void CloseCustomGui();

    static FTSDK::GameDialogStage* CurrentParentOwner() {
        auto* manager = FTSDK::GetStageManager();
        if (!manager)
            return nullptr;

        return manager->CurrentStage();
    }

    static FTSDK::AduGuiControl* FindControl(
        FTSDK::GameDialogStage* owner,
        int32_t commandId,
        const char* fallbackName = nullptr
    ) {
        if (!owner)
            return nullptr;

        if (auto* bound = owner->FindBoundControl(commandId))
            return bound;

        auto* dialog = owner->XmlDialog();
        if (!dialog)
            return nullptr;

        if (auto* byCommand = dialog->FindControlByCommandId(commandId))
            return byCommand;

        if (fallbackName)
            return dialog->FindControlByName(fallbackName);

        return nullptr;
    }

    static FTSDK::AduGuiEditBox* FindInputEditBox(FTSDK::GameDialogStage* owner) {
        auto* control = FindControl(owner, Cmd_InputEditBox, "ebCustomInput");
        if (!control || control->ControlTypeId() != FTSDK::GuiControlType::EditBox)
            return nullptr;

        return reinterpret_cast<FTSDK::AduGuiEditBox*>(control);
    }

    static FTSDK::AduGuiStatic* FindStatic(
        FTSDK::GameDialogStage* owner,
        int32_t commandId,
        const char* fallbackName
    ) {
        auto* control = FindControl(owner, commandId, fallbackName);
        if (!control || control->ControlTypeId() != FTSDK::GuiControlType::Static)
            return nullptr;

        return reinterpret_cast<FTSDK::AduGuiStatic*>(control);
    }

    static void SetInfoText(const wchar_t* text) {
        auto* stInfo = FindStatic(g_ctx.owner, Cmd_InfoStatic, "stCustomInfo");
        if (!stInfo)
            return;

        FTSDK::SetStaticText(stInfo, text ? text : L"");
    }

    void FTSDK_STDCALL CustomGuiCallback(
        FTSDK::GuiEventType eventType,
        int commandId,
        int param,
        void* userData
    ) {
        auto* ctx = reinterpret_cast<CustomGuiContext*>(userData);
        auto* owner = ctx ? ctx->owner : nullptr;

        std::printf(
            "[CustomGui] callback event=%d commandId=%d param=%p ctx=%p owner=%p\n",
            static_cast<int32_t>(eventType),
            commandId,
            reinterpret_cast<void*>(param),
            userData,
            owner
        );

        if (!ctx || !owner)
            return;

        switch (commandId) {
        case Cmd_TestButton:
            if (eventType == FTSDK::GuiEventType::ButtonClick) {
                auto* input = FindInputEditBox(owner);
                if (!input) {
                    std::printf("[CustomGui] input editbox not found\n");
                    return;
                }

                const wchar_t* text = input->TextW();

                std::printf("[CustomGui] btTest clicked, input=\"%ls\"\n", text);
                SetInfoText(text && text[0] ? text : L"Input was empty");
            }
            return;

        case Cmd_CloseButton:
            if (eventType == FTSDK::GuiEventType::ButtonClick) {
                std::printf("[CustomGui] btClose clicked\n");
                CloseCustomGui();
            }
            return;

        case Cmd_InputEditBox:
            if (eventType == FTSDK::GuiEventType::EditBoxChange) {
                auto* input = reinterpret_cast<FTSDK::AduGuiEditBox*>(param);
                if (input) {
                    std::printf("[CustomGui] input changed: \"%ls\"\n", input->TextW());
                }
            }
            return;

        default:
            return;
        }
    }

    static bool IsReady() {
        return g_ctx.owner && g_ctx.loaded && g_ctx.owner->XmlDialog();
    }

    static bool IsAttachedTo(FTSDK::GameDialogStage* parentOwner) {
        return g_ctx.owner
            && parentOwner
            && g_ctx.parentOwner == parentOwner
            && g_ctx.owner->parent == parentOwner;
    }

    static FTSDK::GameDialogStage* CreateCustomGuiOwner(
        FTSDK::GameDialogStage* parentOwner
    ) {
        if (!parentOwner) {
            std::printf("[CustomGui] create failed: parentOwner=null\n");
            return nullptr;
        }

        std::memset(g_customOwnerStorage, 0, sizeof(g_customOwnerStorage));

        auto* owner = FTSDK::ConstructGameDialogStage(
            reinterpret_cast<FTSDK::GameDialogStage*>(g_customOwnerStorage)
        );

        if (!owner) {
            std::printf("[CustomGui] ConstructGameDialogStage failed\n");
            return nullptr;
        }

        g_ctx.owner = owner;
        g_ctx.parentOwner = parentOwner;
        g_ctx.loaded = false;
        g_ctx.opened = false;

        std::printf(
            "[CustomGui] constructed owner=%p vt=%p parentOwner=%p\n",
            owner,
            GetObjectVTable(owner),
            parentOwner
        );

        owner->InitDialogWithParentOwner(parentOwner);

        const bool loaded = owner->LoadXml(
            "Gui_Test.xml",
            g_customBinds,
            CustomBindCount,
            nullptr,
            false
        );

        std::printf(
            "[CustomGui] LoadXml returned=%d owner=%p xml=%p\n",
            loaded ? 1 : 0,
            owner,
            owner->XmlDialog()
        );

        if (!loaded || !owner->XmlDialog())
            return owner;

        owner->XmlDialog()->SetCallback(CustomGuiCallback, &g_ctx);

        owner->DeactivateProcessing();
        owner->ClearOpenFlag();
        owner->parentChainEnabled = 0;

        owner->CenterRectInParent();

        g_ctx.loaded = true;

        return owner;
    }

    static FTSDK::GameDialogStage* GetOrCreateCustomGuiOwner() {
        auto* parentOwner = CurrentParentOwner();

        if (!parentOwner) {
            std::printf("[CustomGui] no current parent owner\n");
            return nullptr;
        }

        if (g_ctx.owner) {
            if (!IsAttachedTo(parentOwner)) {
                std::printf(
                    "[CustomGui] parent owner changed. oldParent=%p newParent=%p\n",
                    g_ctx.parentOwner,
                    parentOwner
                );
                return nullptr;
            }

            return g_ctx.owner;
        }

        return CreateCustomGuiOwner(parentOwner);
    }

    void OpenOrShowCustomGui() {
        auto* parentOwner = CurrentParentOwner();

        std::printf("========== OpenOrShowCustomGui ==========\n");

        if (!parentOwner) {
            std::printf("[CustomGui] parent owner missing\n");
            std::printf("=========================================\n");
            return;
        }

        auto* owner = GetOrCreateCustomGuiOwner();
        if (!owner) {
            std::printf("[CustomGui] owner unavailable\n");
            std::printf("=========================================\n");
            return;
        }

        if (!IsReady()) {
            std::printf("[CustomGui] owner exists but XML is not ready\n");
            std::printf("=========================================\n");
            return;
        }

        owner->SetOpenFlag();
        owner->ActivateProcessing();
        owner->parentChainEnabled = 1;

        parentOwner->BringChildOwnerToFront(owner);

        g_ctx.opened = true;

        std::printf(
            "[CustomGui] opened parent=%p owner=%p open=%d process=%d reachable=%d xml=%p\n",
            parentOwner,
            owner,
            owner->openFlag,
            owner->processEnabled,
            owner->IsReachableThroughParentChain(),
            owner->XmlDialog()
        );

        std::printf("=========================================\n");
    }

    void CloseCustomGui() {
        auto* owner = g_ctx.owner;
        auto* parentOwner = g_ctx.parentOwner;

        if (!owner)
            return;

        if (parentOwner)
            parentOwner->ClearPriorityInputChildOwnerIf(owner);

        owner->ClearOpenFlag();
        owner->DeactivateProcessing();
        owner->parentChainEnabled = 0;

        g_ctx.opened = false;

        std::printf(
            "[CustomGui] closed parent=%p owner=%p open=%d process=%d reachable=%d\n",
            parentOwner,
            owner,
            owner->openFlag,
            owner->processEnabled,
            owner->IsReachableThroughParentChain()
        );
    }

    bool HandleInput(uint32_t msg, int32_t wParam, int32_t lParam) {
        auto* owner = g_ctx.owner;

        if (!g_ctx.opened || !owner || !owner->XmlDialog())
            return false;

        if (!owner->processEnabled || !owner->openFlag)
            return false;

        if (!owner->IsReachableThroughParentChain())
            return false;

        return owner->HandleInput(msg, wParam, lParam);
    }

    bool IsOpen() {
        return g_ctx.opened;
    }

} // namespace CustomGuiTest

XML Dialog Sample

The XML file must be available through the same GUI resource path that the client normally uses for XML dialogs.

Save this as Gui_Test.xml.

<?xml version="1.0" encoding="UTF-8"?>
<Dialog x="0" y="0" w="1024" h="768">
    <Image Name="DEFAULT" du="0" dv="0"/>

    <Control Name="DEFAULT"
             Type="Static"
             Enable="Yes"
             Font=""
             x="0"
             y="0"
             w="0"
             h="0"
             dx="0"
             dy="0"
             Text=""
             TextID=""
             TextFx=""
             TextAlignX="CENTER"
             TextAlignY="CENTER"
             TextMargin=""
             Debug="No"/>

    <Control Name="stCustomWindow"
             Type="Static"
             x="18"
             y="42"
             w="330"
             h="190"
             Text="JFTSE Custom GUI"
             TextID=""
             TextFx="Shadow"
             TextAlignX="CENTER"
             TextAlignY="TOP"
             TextMargin="0,8,0,0"
             Debug="No">
        <State Image="cmnWindow"/>
    </Control>

    <Control Name="stCustomLineTop"
             Type="Static"
             x="42"
             y="80"
             w="282"
             h="1">
        <State Image="cmnWindowLine"/>
    </Control>

    <Control Name="stCustomTop"
             Type="Static"
             x="40"
             y="96"
             w="286"
             h="24"
             Text="Enter text and press Test"
             TextID=""
             TextAlignX="CENTER"
             TextAlignY="CENTER"
             Debug="No">
        <State TextColor="255,0,166,165"/>
    </Control>

    <Control Name="stCustomInfo"
             Type="Static"
             x="40"
             y="124"
             w="286"
             h="22"
             Text="Gui_Test.xml"
             TextID=""
             TextAlignX="CENTER"
             TextAlignY="CENTER"
             Debug="No">
        <State TextColor="255,30,30,30"/>
    </Control>

    <Control Name="ebCustomInput"
             Type="EditBox"
             x="58"
             y="154"
             w="250"
             h="20"
             Text=""
             TextID=""
             Debug="No">
        <State Image="cmnBlank2"/>
    </Control>

    <Control Name="stCustomLineBottom"
             Type="Static"
             x="42"
             y="184"
             w="282"
             h="1">
        <State Image="cmnWindowLine"/>
    </Control>

    <Control Name="DEFAULT" Type="Button" TextFx="Shadow">
        <State Name="All" Image="cmnWindowBtn"/>
        <State Name="Over" Image="cmnShopBuyBtn"/>
        <State Name="Pressed" Color="255,155,155,155"/>
    </Control>

    <Control Name="btTest"
             Type="Button"
             x="58"
             y="194"
             w="120"
             h="22"
             Text="Test"
             TextID=""/>

    <Control Name="btClose"
             Type="Button"
             x="188"
             y="194"
             w="120"
             h="22"
             Text="Close"
             TextID=""/>
</Dialog>

Opening the Test UI

Call CustomGuiTest::OpenOrShowCustomGui() from your own test trigger, for example a debug hotkey, injected menu command or temporary packet/debug handler.

if (pressedDebugKey) {
    CustomGuiTest::OpenOrShowCustomGui();
}

The input hook must already be installed, otherwise the window may render but not receive mouse or keyboard input.

Notes

This is proof of concept code for the currently analyzed client build.

The important parts are:

  • the custom owner is constructed with the client constructor;
  • it is attached with InitDialogWithParentOwner;
  • XML is loaded through StageBase::LoadXml;
  • callbacks are installed through AduGuiDialog::SetCallback;
  • the owner is opened with SetOpenFlag and ActivateProcessing;
  • input is forwarded through the hooked StageManager::HandleInput path.

Do not add a new global stage for this. The client stage list is fixed. For custom UI, attach to an existing reachable owner instead.

This example intentionally avoids custom XML control type registration. For Type="CustomName" / GuiCtrl_*.xml controls, additional custom control factory and embedded dialog behavior has to be handled separately.