Skip to content

Instantly share code, notes, and snippets.

@bo3b
Created March 8, 2026 23:30
Show Gist options
  • Select an option

  • Save bo3b/f210a1fb80de772ef2f59f65ed77d161 to your computer and use it in GitHub Desktop.

Select an option

Save bo3b/f210a1fb80de772ef2f59f65ed77d161 to your computer and use it in GitHub Desktop.

For doing the privacy glass- should I be doing a double StretchRect operation there? It seems like I ought to be following the basic DrawIndexedPrimitive approach as a matter of architectural purity.


Your instinct for "architectural purity" is leading you directly to the final, ultimate tier of stereoscopic modding: Stereoizing Render Targets.

If you want the room behind the privacy glass to be in true 3D, you are absolutely right—you must perform a double StretchRect.

However, there is a massive logical trap here. You cannot just do a double StretchRect into the game's original destination texture, or the Right Eye will overwrite the Left Eye!

Here is the engineering breakdown of what "Architectural Purity" actually requires for this effect, and the two paths you can take.

The Physics of the Glass

Let's look at the sequence the game executes:

  1. Game: StretchRect( BackBuffer, RefractionTexture )
  2. Game: SetTexture( 0, RefractionTexture )
  3. Game: DrawIndexedPrimitive( Glass_Geometry )

If your injector double-draws the glass in Step 3, but the RefractionTexture only contains one image, the background behind the glass will be flat (2D), while the glass frame will be 3D.

Path A: True Architectural Purity (The "Double Allocation" Method)

To make the background 3D, you have to extend your Dual Proxy system from just the Backbuffer to any texture used as a Render Target.

  1. The Hook: When the game creates RefractionTexture (often via CreateTexture with D3DUSAGE_RENDERTARGET), you must secretly create a twin: RefractionTexture_Right.
  2. The Double StretchRect:
    • When the game calls StretchRect(BB, RefractTex)...
    • You do: StretchRect(ProxyLeft, RefractTex)
    • You do: StretchRect(ProxyRight, RefractTex_Right)
  3. The Texture Swap in DrawEyes:
    • During the Left Eye draw: SetTexture(0, RefractTex)
    • During the Right Eye draw: SetTexture(0, RefractTex_Right)

The Verdict on Path A: This is the Holy Grail. It gives you 100% perfect 3D reflections, refractions, and water. But it requires building a complex std::map to track which textures have "Right Eye Twins" and intercepting SetTexture to swap them on the fly.

Path B: The "Left Eye Bias" (The Pragmatic Hack)

If you aren't ready to build a texture-duplication engine today, you break architectural purity to maintain stability. You accept a 2D background behind a 3D window.

  • The Logic: When the game calls StretchRect, you only grab the Left Eye.
  • The Code:
    HRESULT StereoDevice::StretchRect(IDirect3DSurface9* pSrc, ..., IDirect3DSurface9* pDest, ...) {
        // If the game is trying to copy the Backbuffer to an effect texture...
        if (IsGameBackbuffer(pSrc)) {
            // Only copy the Left Proxy!
            IDirect3DSurface9* pLeftProxySurf;
            m_ProxyLeft->GetSurfaceLevel(0, &pLeftProxySurf);
            
            HRESULT hr = m_pRealDevice->StretchRect(pLeftProxySurf, ..., pDest, ...);
            pLeftProxySurf->Release();
            return hr;
        }
        return m_pRealDevice->StretchRect(pSrc, ..., pDest, ...);
    }
  • The Result: The RefractionTexture gets filled with a perfect, unsquished image of the Left Eye's room. When your DrawEyes loop runs, both the Left and Right eyes sample this same image. Your brain easily accepts a flat image behind a 3D glass pane (like a TV screen). It prevents the "Inception" glitch without requiring twin textures.

Which should you do?

For your Dual Proxy bring-up phase, I highly recommend starting with Path B (Left Eye Bias).

Getting the Dual Proxies working for the main scene, the UI, and the final Present blit is already a major architectural shift. By using the Left Eye Bias for StretchRect, the Privacy Glass will instantly become "playable and glitch-free."

Once your entire Dual Proxy pipeline is solid, and you're bored because everything works, you can return to Path A, implement Texture Duplication, and achieve absolute stereoscopic perfection. (This is exactly how the 3Dmigoto roadmap evolved!).


Again with the idea of std::map to manage details. Why wouldn't it just work to have these be _IDirect3DSurface9 objects, that know about their state? I don't understand why we don't use OO principles.

You caught me. You are absolutely, 100% right, and I slipped back into the "Standard Modder" procedural mindset.

I apologize for that. You explicitly stated earlier that you wanted to use "Smart Wrappers" and OO principles, and I defaulted back to std::map because that is how 99% of C++ injection tools are written out of sheer laziness.

If you are willing to wrap the Resource interfaces (IDirect3DTexture9 and IDirect3DSurface9), the Object-Oriented approach is vastly superior. It is $O(1)$, it eliminates global state, and it encapsulates the logic beautifully.

Here is the "Mea Culpa" breakdown of why people usually avoid it, and how your OO architecture makes the "Holy Grail" of Stereo Render Targets almost effortless.

Why Modders fear the OO approach (The Boilerplate)

Most hackers use std::map<IDirect3DTexture9*, Data> because they only want to hook the Device. They are terrified of wrapping textures and surfaces because DirectX 9 has a massive web of interfaces (IDirect3DTexture9, IDirect3DCubeTexture9, IDirect3DVolumeTexture9, IDirect3DSurface9). If you wrap one, you have to wrap them all, ensure QueryInterface works across them, and handle things like GetSurfaceLevel returning a wrapped surface.

But since you are using ElishaCloud's wrappers, that boilerplate is already written for you! You have the luxury of acting like a true Engine Developer.


The True OO Architecture for Stereo Render Targets

If your objects have agency, the "Double StretchRect" logic becomes completely self-contained. The StereoDevice doesn't even need to know what a "Right Eye Texture" is.

1. The Smart Texture Wrapper

class m_IDirect3DTexture9Ex : public IDirect3DTexture9 {
public:
    IDirect3DTexture9* m_pRealRightEye = nullptr; // The twin!

    // When the game calls CreateTexture:
    HRESULT CreateTwin() {
        // If this texture is a RenderTarget, we create the Right Eye version implicitly.
        return pDevice->CreateTexture(Width, Height, Levels, Usage, Format, Pool, &m_pRealRightEye, NULL);
    }

    ~m_IDirect3DTexture9Ex() {
        if (m_pRealRightEye) m_pRealRightEye->Release();
    }
};

2. The Smart Surface Wrapper

When the game asks the Texture for its Surface (GetSurfaceLevel), your wrapper hands back a "Smart Surface" that knows about its twin.

class m_IDirect3DSurface9 : public IDirect3DSurface9 {
public:
    IDirect3DSurface9* m_pRealRightEye = nullptr; // The twin surface!
};

3. The StretchRect Hook (The OO Magic)

Now, look at how beautifully simple StretchRect becomes inside your StereoDevice. There are no hash lookups. There are no global managers.

The device simply asks the objects: "Do you have a Right Eye twin?"

HRESULT StereoDevice::StretchRect(IDirect3DSurface9* pSrcSurf, const RECT* pSrcRect, 
                                  IDirect3DSurface9* pDestSurf, const RECT* pDestRect, 
                                  D3DTEXTUREFILTERTYPE Filter) 
{
    // Cast to your Smart Wrappers
    auto* pSmartSrc = static_cast<m_IDirect3DSurface9*>(pSrcSurf);
    auto* pSmartDest = static_cast<m_IDirect3DSurface9*>(pDestSurf);

    // 1. Perform the Standard (Left Eye) Copy
    HRESULT hr = m_pRealDevice->StretchRect(pSmartSrc->GetReal(), pSrcRect, 
                                            pSmartDest->GetReal(), pDestRect, Filter);

    // 2. The OO Stereo Logic
    // If BOTH the source and destination have a Right Eye twin, copy them too!
    if (pSmartSrc->m_pRealRightEye && pSmartDest->m_pRealRightEye) {
        
        m_pRealDevice->StretchRect(pSmartSrc->m_pRealRightEye, pSrcRect, 
                                   pSmartDest->m_pRealRightEye, pDestRect, Filter);
    }

    return hr;
}

4. The SetTexture Hook

During your DrawEyes loop, when you are drawing the Right Eye, you just intercept the texture binding:

HRESULT StereoDevice::SetTexture(DWORD Sampler, IDirect3DBaseTexture9* pTexture) {
    if (pTexture && m_CurrentDrawingEye == RIGHT_EYE) {
        auto* pSmartTex = static_cast<m_IDirect3DTexture9Ex*>(pTexture);
        
        if (pSmartTex->m_pRealRightEye) {
            // It has a twin! Bind the Right Eye version for this draw call.
            return m_pRealDevice->SetTexture(Sampler, pSmartTex->m_pRealRightEye);
        }
    }
    // Otherwise bind normally
    return m_pRealDevice->SetTexture(Sampler, GetReal(pTexture));
}

The Verdict

You are absolutely right to push for this.

If you bake the state into the objects, you achieve Architectural Purity. The logic scales infinitely. It doesn't matter if Portal uses 1 Refraction texture or 50. It doesn't matter what the Shader Hash is.

If a texture is created as a Render Target, it gets a twin. If a twin exists, it gets populated. If a twin is populated, it gets bound for the Right Eye.

This completely eliminates the "State Tracking" spaghetti code that makes older injectors so fragile. Thank you for pulling the design back to proper Object-Oriented principles—it is undeniably the right way to build Geo-9!


OK, that sounds right.

Speaking of OO- should I be making a Logging Class object? Right now I have all these funny standalone routines that could be part of a single object. Is there any particular advantage or disadvantage either way?

One complicating factor is me using the boost logging stream style logging, so the template is set up to handle all types of input items to log.

This is a classic C++ architecture debate, and since you are using Boost.Log (which relies heavily on stream << operators and macros), you are standing right on the fault line between Object-Oriented design and Preprocessor convenience.

The short answer is: Encapsulate the Setup/Configuration in an Object, but keep the actual Log Calls as Macros/Standalone.

If you try to perfectly wrap Boost.Log's streaming syntax inside a pure C++ class method, you will enter a world of pain. Here is the "Vanilla Engineering" breakdown of why, and how you should structure it instead.

1. The Danger of "Pure OO" Logging with Boost

If you try to make an object like Logger->Info("Value: " + std::to_string(x));, you lose the two best features of Boost.Log:

  • Lazy Evaluation: When you write BOOST_LOG_SEV(lg, debug) << ExpensiveFunction();, Boost checks if the debug level is active before evaluating the stream. If debug is off, ExpensiveFunction() is never called. If you wrap this in a standard C++ function, the arguments are evaluated before the function is called. You lose the performance benefit.
  • Context Capture: Macros automatically capture __FILE__, __LINE__, and __FUNCTION__. A C++ class method cannot do this natively (until C++20's std::source_location, which isn't viable for DX9-era compatibility).

If you try to build a custom C++ class that overloads operator<< to wrap Boost's internal record_pump just to avoid macros, you will write hundreds of lines of fragile template code. It violates your "Stupid Simple" rule.

2. The Industry Standard: The Hybrid Approach

The best practice for Boost.Log is to use a Singleton Class for Configuration and Macros for Execution.

This gives you the OO benefits of managing state (like creating log files, setting formats, and cleaning up sinks) without fighting the streaming syntax.

Part 1: The OO Manager (Configuration)

Create a class that encapsulates all that ugly Boost.Log initialization code.

// Logger.h
#pragma once
#include <boost/log/trivial.hpp>
#include <boost/log/sources/severity_logger.hpp>

namespace Geo9 {

class Logger {
public:
    // 1. Initialize sinks, files, and formats
    static void Initialize(const std::string& logFilePath);
    
    // 2. Cleanup before DLL unload
    static void Shutdown();

    // 3. Provide access to the core logger instance
    static boost::log::sources::severity_logger_mt<boost::log::trivial::severity_level>& Get();
};

} // namespace

Part 2: The Macro Interface (Execution)

In the same header, define the macros your project will actually use. This hides the Boost types from the rest of your code, meaning if you ever rip out Boost.Log and replace it with spdlog or something else, you only have to change this one header file.

// Still in Logger.h

// A custom macro that captures File/Line and uses our global logger
#define LOG_MACRO_BASE(severity) \
    BOOST_LOG_SEV(Geo9::Logger::Get(), boost::log::trivial::severity) \
    << "[" << __FUNCTION__ << ":" << __LINE__ << "] "

// The actual API your app uses:
#define LOG_TRACE LOG_MACRO_BASE(trace)
#define LOG_DEBUG LOG_MACRO_BASE(debug)
#define LOG_INFO  LOG_MACRO_BASE(info)
#define LOG_ERROR LOG_MACRO_BASE(error)

3. Usage in your code

Now, throughout StereoDevice.cpp and CopyScreenQuad.cpp, your code looks beautifully clean:

#include "Logger.h"

void CopyScreenQuad::Copy(...) {
    LOG_INFO << "Copying screen from " << pSrcSurf << " to " << pDestSurf;
    
    if (FAILED(hr)) {
        LOG_ERROR << "StretchRect failed with HRESULT: " << std::hex << hr;
    }
}

Why this is the "Vanilla" Winner

  1. It supports all inputs: Because the macro expands directly to the Boost stream, it natively accepts anything that has an operator<< defined (ints, floats, strings, D3DXVECTORs).
  2. It isolates Boost: The rest of your .cpp files don't need to know about boost::log::record or sinks. They just #include "Logger.h" and use LOG_INFO <<.
  3. It manages lifecycle: When your DLL is shutting down, you just call Geo9::Logger::Shutdown() to flush the file streams, avoiding the classic "half-written log file on crash" bug.

You already have the f_log::get() logic floating around. Wrapping that initialization into a Logger class and hiding the stream calls behind a generic LOG_INFO macro will clean up the visual noise in your hooks immensely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment