Mortal Shell 2

Unreal Engine 5 titles that crash on the first cutscene. The carrier sits under a compiler-named subfolder, which the script discovers rather than assumes.

GPL-3.0-or-laterThis fix in MacGameFix

What you see

EXCEPTION_ACCESS_VIOLATION reading 0x0 in FElectraMediaDecoderOutputBufferPoolBlock_DX12::AllocateBuffer

Why it happens

On a D3D12 device Electra takes its GPU output buffer path, which asks every D3D12 resource for ID3DDestructionNotifier and uses the answer without checking it; under D3DMetal there is none, and it reads address 0.

What the fix changes

Runtime patch, 4 sites, all four confirmed at runtime

The code

Sets Electra.Win.H264UseOldOutputPath, found by its own name rather than by an address, so Electra takes its CPU buffer path and never the D3D one.

fixes/ue5-media-fix.c ยท force_electra_software, lines 1284โ€“1376
c
static void force_electra_software(void)
{
    BYTE *base = (BYTE *)GetModuleHandleA(NULL);
    int done = 0;
    if (electra_sw_forced || !base) return;
    electra_sw_forced = TRUE;

    read_switches();
    logf_("putting Electra on its old output path");

    /* The two IsSoftware call sites used to be patched here, by address.
     *
     * They are gone, and nothing is read off a disassembly any more. The
     * software path comes from the engine instead: winegstreamer patch 0006,
     * carried from winevideo, stops advertising MF_SA_D3D_AWARE on macOS, so
     * Electra never builds a D3D11 device and answers its own IsSoftware()
     * honestly. That is in the engine this project ships and it does not move
     * when the game does.
     *
     * Those addresses broke three times in sixteen days -- 2026-08-24,
     * 08-31 and 09-08 -- and each time they took the console variable down
     * with them, because the check refused before reaching it. The variable is
     * found by name and never needed the addresses at all; that coupling was
     * the actual defect, and this is where it ends.
     *
     * Measured 2026-09-09 on UE5-CL-408709: with the variable set and no byte
     * patched anywhere, the video plays. Without the variable the title
     * crashes in the D3D path, EXCEPTION_ACCESS_VIOLATION reading 0x0 -- so
     * patch 0006 alone is not enough and the variable below is load-bearing. */

    /* The console variable is a pointer read from a fixed address and written
     * through -- a wild write on any build but the one it was read from, and
     * that build is gone. Left alone until it is found the same way the calls
     * were. */
    /* The values live at object + 0x50, and that was read off the vtable
     * rather than guessed.
     *
     * What the fixed address holds in this build is an IConsoleVariable, not
     * the two plain ints the old code wrote -- its first qword is a pointer
     * into .rdata, so writing 1 at offset zero would destroy the vtable and
     * produce exactly the null dereference this afternoon was spent chasing.
     *
     * Its vtable says where the data is. Slot 12 is `lea rax,[rcx+0x50] ; ret`
     * -- AsVariableInt returning the embedded TConsoleVariableData<int32> --
     * and slot 8 is `mov al,1 ; ret`, which is the "this is an int variable"
     * answer. So the two int32s the old build had at offset zero are at 0x50
     * here, and the old write is right about what to set and wrong only about
     * where.
     *
     * Read before writing anyway: the default is 0, so anything else means
     * this is not what it is thought to be, and then nothing is touched. */
    {
        void **slot = find_cvar_slot(base);
        void *obj;
        if (!slot)
        {
            /* There is no falling back to a remembered address any more. What
             * made that safe was the call-site check proving this was the build
             * the address came from, and that check is gone. A remembered
             * address on a build it was not read from is a wild write. */
            logf_("  the console variable was not found by name -- doing nothing");
            return;
        }
        obj = *slot;
        if (!obj)
        {
            logf_("  console variable not registered yet");
        }
        else
        {
            int *values = (int *)((BYTE *)obj + CVAR_VALUES_OFFSET);
            logf_("  console variable at %p, values at +0x%X currently [%d, %d]",
                  obj, (unsigned)CVAR_VALUES_OFFSET, values[0], values[1]);
            if (!opt_cvar)
                logf_("    left at [%d, %d], by switch -- Electra keeps whichever "
                      "output path it chooses for itself", values[0], values[1]);
            else if (values[0] == 0 && values[1] == 0)
            {
                values[0] = 1;
                values[1] = 1;
                cvar_ready = TRUE;
                logf_("    set to 1 -- Electra takes the old CPU buffer path, "
                      "which stores the sample instead of querying for a D3D "
                      "resource that is not there");
            }
            else
                logf_("    left alone: the default for this variable is 0, so "
                      "this is not the field it is thought to be");
        }
    }

    if (!done) logf_("nothing was patched");
}

Details

Installer
install-runtime-fix.sh
Executable
MortalShell2-Win64-Shipping.exe
Built from
ue5-media-fix.c
Verified on
Stable 26.3, played through both with winevideo and without

The full story, with the measurements and the wrong turns