-
Notifications
You must be signed in to change notification settings - Fork 0
Findings
What the nine titles have in common: the results that are not about any one of them, the mechanism the fixes share, and what was tried and did not work.
This page is addressed to whoever maintains Wine, D3DMetal, DXMT or CrossOver's packaging. Every result is stated as a defect or a gap in the translation stack rather than as a property of a game, and each one carries what was observed, how to reproduce it, and what would close it. Start at Defects, by the component that would fix them; the sections after it are the mechanism and the background.
What this project got wrong on the way to these results is deliberately not here. It is in docs/what-we-got-wrong.md, because it is of no use to anyone implementing a fix.
The per-title pages carry the findings for a title. This page carries what is common to all of them, and it is where a claim lives when it belongs to an engine, to CrossOver, or to the toolkit rather than to a game. Where a title page already tells a story in full, this page links to it rather than telling it again.
To get a game playing, start at the README.
Everything here was measured on an M4 Max, macOS 27, GPTK 4.0b2, against
CrossOver 26.3 and crossover-preview-arm64-20260821. "Preview" on this page
means that build and no other.
Everything below is a gap or a defect in the translation stack, not in a game. Each one carries what was observed, how to reproduce it, and what would close it. The games are only where it surfaced.
Four words are used precisely: measured (observed directly here), controlled (observed with and without the thing under test), inferred (reasoned from something measured, not itself observed), and not measured (recorded so it is not mistaken for a result).
d3d9 reports success and returns a null share handle.
-
Observed.
IDirect3DDevice9::CreateRenderTargetwith a share handle requested returnsS_OKand writes zero into the handle. Three titles, same result:CreateRenderTarget(1920x1080, SHARED requested) -> S_OK, handle 0. -
Consequence. A caller that believes the return value carries the null
forward. Nioh reaches
mov rdx, [rdx+0x10]withrdxat zero on its video thread and the process dies. No crash report names the cause. -
Reproduce. Nioh, Nioh 2 or Persona 5 Strikers under DXMT; patch slot 28 of
IDirect3DDevice9and log the handle written. - What would fix it. Either implement D3D9 resource sharing, or fail the creation when a share handle is asked for and cannot be produced. The second is much smaller and removes the crash: a caller told no can fall back, and every one of these titles has a non-shared path. Success-with-null is the harmful answer, not the missing feature.
- Standing. Measured, three titles.
No D3D11 resource sharing in d3d11.
-
Observed.
GetSharedHandleappears 17 times in DXMT'sd3d11.dlland not once in Wine's, by symbol scan of both. - Consequence. The bridges here work only on DXMT. On Wine's own D3D11 there is nothing to build a shared surface on.
- Standing. Measured.
No VP9 decoder MFT is registered.
-
Observed.
MFTEnumEx(MFT_CATEGORY_VIDEO_DECODER, 0x3F, {Video, VP90})returns zero entries, and the registry contains no mention of VP9 anywhere. -
Consequence. A title that asks before decoding never proceeds. Ninja Gaiden
4 stops here and calls
exit(-1), leaving a black screen and no report. -
Note. The capability exists — CrossOver Preview decodes VP9 profile 0 and 2
through
vp9parse → vtdec_hw. What is missing is the declaration, not the decoder. - What would fix it. Registering a VP9 decoder MFT. Proton carries one and winevideo ports it under Microsoft's public VP9 Video Extensions CLSID.
- Standing. Measured.
No .webm byte-stream handler is registered.
-
Observed.
MFCreateSourceReaderFromURLresolves by file extension through the registry and fails on.webm.MFCreateSourceReaderFromByteStreamresolves by content and succeeds on the same data. - Consequence. Two titles with the same container behave differently depending only on which entry point their player uses.
- What would fix it. Registering the handler. Content-based resolution is already working, so this is a registration gap rather than a capability one.
- Standing. Measured.
The WMV3 path fails to construct without an ffmpeg plugin present.
-
Observed.
qasf→wmvdecod.dll→ winegstreamer'swg_wmv_decoder, which probes a fixed WMV3-to-I420 1920x1080 transform before constructing anything. With nothing able to serve it,IDMOWrapperFilter::Initreturns0xD0000001—HRESULT_FROM_NT(0xC0000001)— and the DirectShow graph never builds. Withlibgstlibavreachable, the same call returnsS_OK. - Standing. Measured, on Nioh, either side of the change.
ID3DDestructionNotifier is not implemented.
-
Observed.
QueryInterfacereturnsE_NOINTERFACE. -
Consequence. Unreal Engine 5 does not check the result —
WindowsElectraDecoderGPUBufferHelpers.h:276, where thecheck()is compiled out of shipping builds — and the next line dereferences a null vtable. Every UE5 title with VP9 cutscenes on the D3D12 RHI crashes on its first cutscene, with the same stack at different offsets. -
Reproduce. Any such title; the stack names
FElectraMediaDecoderOutputBufferPoolBlock_DX12::AllocateBuffer. - What would fix it. Implementing the interface. It is small, and winevideo ships a standalone shim DLL that does nothing else — evidence that the surface needed is narrow.
- Standing. Measured.
IDXGIAdapter3::QueryVideoMemoryInfo succeeds for every node index.
-
Observed. The call returns
S_OKfor indices past the number of memory nodes. On Windows it fails there. - Consequence. Unreal's D3D12 RHI walks nodes accumulating totals and ends the walk when the call fails. Against an answer that never fails, the walk does not end. Two titles freeze.
-
Reproduce. Patch slot 14 of
IDXGIAdapter3and refuse index 1 withDXGI_ERROR_INVALID_CALL; the freeze stops. -
What would fix it. Returning
DXGI_ERROR_INVALID_CALLpast the node count. This is the smallest fix on this page and it removes a hard freeze. - Standing. Measured.
No ID3D11VideoDevice or ID3D11VideoContext.
-
Observed.
QueryInterfacereturns0x80004002for both, on a device that was created successfully. -
Consequence. Two distinct failures. A title that sets
MF_SOURCE_READER_D3D_MANAGERandMF_READWRITE_ENABLE_HARDWARE_TRANSFORMSgets no D3D-backed decode; and a title that drives the video processor itself —CreateVideoProcessorEnumerator,CreateVideoProcessor, the input and output views, and elevenVideoProcessorSet*calls — has nothing to drive. - What would fix it. Implementing the video device and processor. Short of that, the software path works: stripping those two attributes from the source reader's attribute store is sufficient for playback, which suggests a fallback inside Media Foundation would serve titles that ask for acceleration and can live without it.
- Standing. Measured.
Removing the D3D manager moves the frames somewhere the game does not look.
-
Observed. Stripping
MF_SOURCE_READER_D3D_MANAGERis what makes the software path available and stops a title crashing, and it changes where the sample data lives: system memory rather than a DXGI buffer. A player whose upload step expects a DXGI buffer then never fills the surface it hands the video processor. That surface was read directly and measured flat -- average 0, range 0..0 -- while the reader was delivering three hundred good samples. - Why this belongs here. The workaround is only complete for a title that can take its frames from system memory. Anything relying on the D3D-backed buffer needs the frame delivered for it by hand, which is what this project ends up doing. A fallback inside Media Foundation would not need to.
-
Reproduce. NieR Replicant with the two attributes stripped; read back the
texture passed to
VideoProcessorBlt. - Standing. Measured.
IDXGIResource::GetSharedHandle is E_NOTIMPL.
- Consequence. Decoding video on a D3D11 device and presenting it with a D3D12 renderer cannot work at all, which is a common shape.
- Standing. Measured.
D3DMetal 3.0 freezes two titles that 4.0b2 runs.
- Observed. CrossOver 26.3 carries 3.0; the Preview build carries 4.0b2 and ships 3.0 beside it unused. The two copies of 3.0 are byte-identical. Everything else is held constant: same game files, same bottle, same backend.
- Standing. Measured that this is the only difference. Not measured why. This is the shape a bug report would take rather than a diagnosis.
A per-title override table decides feature availability by executable name.
-
Observed.
__ZL20ApplicationOverrides, 49 records of0x48bytes, matched against the executable's basename. Byte+0x24gatesEnumerateMetaCommands. - Consequence. A title's DirectStorage support depends on whether its name is in that table, which is invisible from outside and cannot be configured.
- Standing. Measured, by binary analysis.
d3d12.dll refuses to initialise under another module name.
-
Observed. Renamed, it returns
ERROR_DLL_INIT_FAILED.dxgi.dlltolerates the same rename. - Consequence. A CrossOver-level proxy is possible for DXGI and not for D3D12, which is what keeps most fixes here per-game.
-
Standing. Measured for both. Whether a proxy that exports
D3D12CreateDeviceitself is also refused is not measured.
Stable 26.3 ships no WebM demuxer.
-
Observed. 26.3 carries 17 GStreamer plugins and
crossover-preview-arm64-20260821carries 19; the two Preview has to itself arematroskaandosxaudio. -
Consequence. A
.webmcutscene cannot be opened on stable, so nothing gets as far as a decoder. Both builds decode VP9 identically throughapplemedia, so this is a container gap, not a codec one. -
What would fix it. Shipping
libgstmatroskain the stable line. - Standing. Measured for the plugin sets. Inferred for the effect on stable — the affected title has never been launched there.
No VC-1 or WMV3 decoder is shipped in either line.
-
Observed. Neither build carries
libgstlibavorlibgstvpx. - Consequence. Three titles here need a decoder staged from the user's own GStreamer install to play at all.
- Standing. Measured.
The launcher leaves GST_PLUGIN_PATH free.
-
Observed. CrossOver's launcher sets only
GST_PLUGIN_SYSTEM_PATH, around line 690 ofbin/wine, and the bottle's own environment is applied first. - Consequence. A plugin can be supplied per bottle without modifying the installation. This is the property the staging here depends on; it is recorded so that a change to it is known to break something.
- Standing. Measured on Preview. Inferred for other builds — not checked on any.
A black screen with correct audio has two causes, and they are separable. A frame that never arrives and a frame written somewhere that is never displayed look identical from outside. Painting the target a colour the game could not produce tells them apart in a single run: if it appears, the write path is right and the fault is upstream of it. That test is what turned NieR Replicant from a guess into an implementation, and it had settled the same question for the DYNASTY WARRIORS bridge earlier.
The corollary is the reason frames are read back rather than trusted: a bridge that hands over a valid but empty surface produces a game that runs and a screen that is black, and nothing distinguishes that from success without looking at the pixels.
Why D3DMetal 3.0 freezes those two titles. The fault arrives before any guard can act: on 3.0 the adapter node walk never happens, so whatever stops them is upstream of the walk that explains the same freeze on other builds.
What decodes Persona 5 Strikers' VC-1. The title plays on a system where the staged ffmpeg plugin is demonstrably not loading, so something else is serving it. Not chased down.
The most cross-cutting result here, and the one that took longest to arrive at. This is the canonical account; Games and DYNASTY WARRIORS: ORIGINS state the conclusion and point here.
Established by comparing the two installs plugin by plugin on this machine
rather than by running anything: CrossOver 26.3 carries 17 GStreamer plugins and
crossover-preview-arm64-20260821 carries 19, and the two Preview has to itself
are matroska and osxaudio.
matroska is a demuxer, not a decoder, and it is the whole difference. Both
builds carry applemedia and decode VP9 through VideoToolbox; neither ships
libgstvpx or libgstlibav. What only Preview can do is open a WebM container.
DYNASTY WARRIORS' 355 cutscenes are .webm, so on stable nothing gets as far as
a decoder.
Mortal Shell 2 is not the control it looks like, and it is worth saying why. Its
61 cutscenes are the same codec in a different box, and it plays on stable — but
its VP9 never reaches CrossOver's media stack at all. Electra opens the .mp4
and decodes it in-process with its own libvpx, which is the finding recorded
under why only VP9, and only D3D12 below. So it
is evidence that VP9 is not what stops DYNASTY WARRIORS, and no evidence at all
about which containers CrossOver can open. The plugin-set comparison is the
whole of that.
It also closes a question this project carried open for a while. The cutscene
was measured playing on Preview in a bottle winevideo had never touched, with
the .webm byte-stream handler registered — a run that was expected to fail and
did not, with no account of how the WebM was being opened. The matroska plugin
is the account. Whether that handler mapping was needed in that run is a
separate question and still open;
the title's page says what was and was not tried.
It narrows what the fix requires. The bridge presents frames and decodes
nothing, so the open has to succeed first: on a build with no WebM demuxer,
MFCreateSourceReaderFromByteStream on a .webm fails outright and there is
never a frame to carry.
The limit on all of this. DYNASTY WARRIORS has never been launched on stable 26.3, with or without anything added. Everything said here about stable is read from the plugin sets rather than from a run.
What would close the gap, and has not been built.
winevideo supplies a WebM demuxer today,
by patching the CrossOver installation. Staging libgstmatroska.dylib would
supply one without patching anything — 756 KB, in the official
GStreamer.framework, the same re-homing move that stages VC-1 for Persona 5
Strikers. Neither has been measured with this title, and the staging has not
been written. It is recorded here as a plugin to stage rather than an engine to
patch, not as something that works.
Both Life is Strange titles freeze on stable CrossOver 26.3 and run on
crossover-preview-arm64-20260821. This is the canonical account; both title
pages state the observation and point here.
Nothing installed beside the game is involved. Established by a control run: the fix removed entirely, the game restored to what Steam delivers, relaunched on 26.3 — and it freezes exactly as before.
The guard never runs. The policy table below arms
the DXGI node guard for Iris and Chronos and nothing else. On Preview it arms
and, in three sessions out of nine, reports refusing a node that does not exist.
On 26.3 it arms and then reports nothing at all: five lines of log against
twenty-nine, and no adapter node is ever walked. Whatever stops the game arrives
before the code written for it can act, which is also why the guard's own
evidence cannot explain this.
What differs is D3DMetal. CrossOver 26.3 carries 3.0; that Preview carries
4.0b2 and ships 3.0 beside it, unused and unreferenced. The two copies of 3.0 are
byte-identical, so "stable" and "GPTK 3" are interchangeable in any account of
this. Nothing else about the two installs distinguishes these titles: the proxy
DLL is the same file, the bottle the same bottle, the backend d3dmetal in both.
Still unknown is why GPTK 3 freezes here at all. That is a question for the engine rather than for this project, and it is the shape a report upstream would take: a title that runs on 4.0b2 and freezes on 3.0, with nothing of ours in the process.
One thing this leaves worth asking. The guard fires in a minority of sessions even where it works, and not at all where the freeze happens. How much of the improvement on Preview is actually its doing has never been controlled, and the same experiment would answer it.
The Unreal titles are served by one proxy DLL carrying three unrelated repairs, and each is asked for by executable name rather than applied wherever its pattern happens to match:
- the Electra buffer path that crashes Mortal Shell 2,
- the adapter-node walk that freezes both Life is Strange titles,
- the H.264 output negotiation that leaves Beast of Reincarnation silent-but-blank.
They share one file because they share one carrier DLL. Separate files would
mean a title could only ever have one of the three. The merged DLL logs to
C:\ue5-media-fix.log; releases from before the merge wrote
C:\ue5-runtime-fix.log, which is the name to look for in an old log.
Which of the three a title gets is a table in runtime/ue5-media-fix.c, keyed
by the shipping executable's name:
| Executable | Title | Armed |
|---|---|---|
MortalShell2-Win64-Shipping.exe |
Mortal Shell 2 | Electra VPx |
Iris-Win64-Shipping.exe |
Life is Strange: Reunion | node guard |
Chronos-Win64-Shipping.exe |
Life is Strange: Double Exposure | node guard |
BeastOfReincarnation-Win64-Shipping.exe |
Beast of Reincarnation | H.264 / NV12 |
| anything else | an untried Unreal title | all three |
Two things follow from that table. Neither Life is Strange title runs the H.264 half: it is present in the file they install and inert in their process. And an executable the build does not recognise arms all three rather than a chosen subset, which the log says on startup, so an unexpected result is traceable to the table rather than mistaken for a measurement. That last row is what the app's Another Unreal Engine 5 title entry reaches, and it is why an untried title is best tried on Preview: the 26.3 defect is open and unexplained, and a title the table does not recognise arms more of the DLL than any title that has been measured, so Preview is where it carries the least unknown.
The Media Foundation hooks are the exception to the gating. They are installed for every title, armed or not, because they are how a new one is surveyed: what the gates decide is whether those hooks change anything, not whether they are there. That unconditional instrumentation is the standing suspicion in the freeze on 26.3 above.
The Mortal Shell 2 crash is engine code rather than title code, which is why it is recorded here as well as on that title's page.
Apple's D3DMetal does not implement ID3DDestructionNotifier. Unreal asks for
it and uses the result without checking the HRESULT —
WindowsElectraDecoderGPUBufferHelpers.h:276 is:
TRefCountPtr<ID3DDestructionNotifier> Notifier;
Res = Resource->QueryInterface(__uuidof(ID3DDestructionNotifier), ...);
check(SUCCEEDED(Res)); // compiled out in Shipping
Res = Notifier->RegisterDestructionCallback(...); // null vtable derefQueryInterface returns E_NOINTERFACE, Notifier stays null, the check()
does not exist in a shipping build, and the next line reads address 0. The
stack, for recognising it in a crash log of your own:
Unhandled Exception: EXCEPTION_ACCESS_VIOLATION reading address 0x0000000000000000
...!FElectraMediaDecoderOutputBufferPoolBlock_DX12::AllocateBuffer()
[Engine\Plugins\Media\ElectraCodecs\...\WindowsElectraDecoderGPUBufferHelpers.h:276]
...!FVideoDecoderVPxElectra::ConvertDecodedImageToNV12orP010()
Crash in runnable thread ElectraPlayer::Video decoder
Every Electra decoder gates the D3D12 output buffer pool on the same condition. The difference is who guards it:
| Decoder | Guard | On D3D12 |
|---|---|---|
| H.264 / H.265 | if (… && CVarElectraWindowsH264UseOldOutputPath == 0) |
set the CVar to 1 and it never touches the pool |
| VPx (VP8/VP9) | none — bUseGPUBuffers = (PlatformDevice && PlatformDeviceVersion >= 12000)
|
always enters it → crash |
So VP9 always hits it on D3D12. Titles that run on D3D11 — Persona 5 Strikers, for one — never do: the same bug is there and unreachable.
There is no VPx CVar. Extracting every string from the shipped executable
turns up only Electra.Win.H264UseOldOutputPath and
Electra.Win.H265UseOldOutputPath. VP9 on D3D12 has no configuration escape.
-dx11 also dodges the crash, but Unreal does not precompile PSOs on the D3D11
RHI, which means permanent shader-compilation stutter. That is the same trade
that makes -dx11 a poor answer to the Life is Strange freeze.
VP9 here never goes through Media Foundation, which is why this fault needs
nothing from CrossOver's media stack. It was measured rather than argued: the
game was played through on CrossOver 26.3 carrying no winevideo, and again on
CrossOver-winevideo 26.3, the same version differing only in the GStreamer
plugins. The fix worked either way. That paired run is the only controlled
comparison in the project, and several claims elsewhere lean on it — it was made
on Mortal Shell 2 and no other title.
diagnostics/launch-with.sh is what makes that comparison cheap: bottles with a
build present or absent, without reinstalling anything.
The Life is Strange fix is independent of winevideo for a reason rather than by measurement: the node walk is in DXGI and has nothing to do with video decoding, so the guard depends on no media plugin at all.
Unreal walks the adapter's memory nodes through
IDXGIAdapter3::QueryVideoMemoryInfo, accumulating across them, and ends the
walk when the call fails. On Windows it fails once the index passes the number
of nodes. D3DMetal answers S_OK for every index, so the counter climbs
forever. Refusing node 1 with DXGI_ERROR_INVALID_CALL ends it, once per
session — so the guard writes one line the first time it refuses, and that line
in the log is what says the fix took effect. A log carrying the Electra lines
but not that one says the game never made the walk, which rules this fault out
rather than the fix. The disassembly and the spindump are on
Reunion's page.
This one is not about these games. The walk is in Unreal's D3D12 RHI rather than in anything either title added, so the guard is worth trying on any Unreal game that freezes this way. Two executables have been scanned and both carry the loop; that is the whole sample.
Because the fault is not in either game, the guard can live in two places, and the choice is a trade rather than a conclusion.
The per-game fix is the default, because patching one game's process has a much smaller blast radius than replacing a DLL every bottle loads.
A CrossOver-wide override also exists: crossover/install-node-guard.sh
replaces Apple's dxgi.dll inside a CrossOver build with a proxy handling all
seven exports — three implemented here, which is how the adapter gets wrapped at
all, and four PE forwarders to the renamed original. It reaches every game in
every bottle using that CrossOver without anybody selecting a folder, at the
cost of an invalidated code signature and a much larger blast radius.
That this guard is possible at all rests on Apple's dxgi.dll tolerating being
renamed to dxgi_real.dll. d3d12.dll is not known to tolerate the same, which
is what limits the move — see
things that do not work.
A separate result came out of the same work: serving repeat queries from a 100 ms cache was reported as feeling smoother. No frame time was captured before or after, so that is a report rather than a measurement, and the cache was not retained. The shipping guard holds nothing between calls.
The reasoning is written down anyway, because it is not title-specific: thousands of crossings a second into Wine's unix side cost more than their wall time, which is contention rather than cycles. Anyone bringing it back should capture a frame time first, since that is exactly what the original report lacked. The fuller note is on Reunion's page.
The DYNASTY WARRIORS fault is what happens to any game that decodes video on a
D3D11 device and presents it with a D3D12 renderer, because
IDXGIResource::GetSharedHandle is E_NOTIMPL under D3DMetal for all of them.
The five faults in sequence, the bridge design and the copy-queue reasoning are
on that title's page.
Three things from it generalise.
A handle of ours is a new invention, not a port. winevideo's D3D9 bridge
does not invent a handle: it creates a texture with
D3D11_RESOURCE_MISC_SHARED, calls the real GetSharedHandle, and fails when
that does not work. What it substitutes is Wine's unimplemented D3D9 sharing
with D3D11 sharing that DXMT does implement. The one call their design rests on
is exactly the one that is E_NOTIMPL under D3DMetal, so the parts of this with
no reference implementation are the ones most likely to be fragile.
The frame converter is not title-specific. The NV12-to-BGRA converter came across from the DYNASTY WARRIORS bridge to the Persona 5 Strikers bridge unchanged. That is the clearest evidence so far that the logic in these fixes is general and only the carrier is not.
Backend is a requirement, not a preference. DXMT implements sharing where
D3DMetal has none to build on: GetSharedHandle appears 17 times in DXMT's
d3d11.dll and not once in Wine's. That is why Persona 5 Strikers is fixable on
DXMT and on nothing else, and why a game in DYNASTY WARRIORS' position may work
under a different backend instead — untested here.
By the ninth title the pattern is not one bridge but two, and the split does not follow the names on the boxes.
The D3D9 bridge serves Persona 5 Strikers, Nioh and Nioh 2. They ask D3D9
for a shared surface, Wine returns S_OK with a handle of zero, and the bridge
supplies a real one from the D3D11 side. It needs DXMT, because
GetSharedHandle is what the whole design rests on and D3DMetal has none.
The D3D11-to-D3D12 bridge serves DYNASTY WARRIORS and Nioh 3. They ask
Media Foundation to decode into D3D video textures, which D3DMetal cannot
provide -- QueryInterface(ID3D11VideoDevice) is E_NOINTERFACE -- so the
bridge strips the request down to software decode and stubs the entire video
processor the game then drives by hand. It runs on D3DMetal.
Nioh 3 belongs to the second group despite sharing a name with the first two, which is worth stating plainly: the series a game is in predicts nothing. What predicts the fix is which API it reaches its frames through.
Nioh and Nioh 2 were both fixed by the Persona 5 Strikers bridge with no change to what the bridge does. That is the strongest evidence so far that these repairs are general and only the carrier is not.
The three do not even reach the video the same way. Strikers and Nioh 2 go through Media Foundation; Nioh builds a DirectShow graph. What they share is one line further down, where a shared D3D9 surface is asked for and the handle comes back null -- and that is the line the bridge watches, which is why the player above it can differ without mattering.
The gap, stated exactly. The game asks D3D9 for a shared render target.
Wine's d3d9 creates it, returns S_OK, and hands back a share handle of zero.
Nothing obliges a caller to survive that, and Nioh does not: it carries the null
through to mov rdx, [rdx+0x10] on a worker thread and dies there. The repair
is not to make video work. It is to hand back a handle that exists.
CreateRenderTarget(1920x1080 fmt=21, SHARED requested) -> S_OK, handle 0
sidecar: 1920x1080 texture, GetSharedHandle -> S_OK, handle 40000082
OpenSharedResource: our handle -- made a texture on the game's own device
StretchRect INTO it
source luma [420]: average 11, range 0..166 << has picture
The luma line is why this is recorded as working rather than as reported working: a bridge that hands over a valid but empty surface produces a game that runs and a screen that is black, and the two are indistinguishable without reading the pixels.
The Ex variants are a second door. Persona 5 Strikers creates its device
through IDirect3D9::CreateDevice, slot 16. Nioh reaches the same object
through Direct3DCreate9Ex and CreateDeviceEx, slot 20, and never calls slot
16 at all. Anything watching one of those interfaces has to watch both, and slot
20 may only be written on an object that came from Direct3DCreate9Ex — on a
plain IDirect3D9 that index is past the end of the vtable.
And it held for a third. Nioh 2 needed no code change at all -- same
carrier, exporting the identical 16 symbols, so even the built proxy was reused.
It exercised more of the bridge than Nioh had: four of six import hooks landed
rather than two, MFCreateSourceReaderFromByteStream accepted the ASF, and the
frames arrived as NV12 with a 1920 pitch where Nioh's had been a four-byte
format at 7680. The NV12-to-BGRA converter that came across from DYNASTY
WARRIORS absorbed that difference without being told.
What is not measured. Neither Nioh title has run anywhere but Preview and
DXMT. Whether
the bridge works under D3DMetal is not open in the same way -- the sidecar needs
GetSharedHandle, which is E_NOTIMPL there -- but it has not been tried, and
26.3 has not been tried at all.
Three of these fixes hook a function by naming the DLL it belongs to. That assumption held for eight titles and broke on the ninth.
Nioh 3 ships NVIDIA Streamline. It imports D3D11CreateDevice and
D3D12CreateDevice from sl.interposer.dll, a drop-in replacement exporting
the same names, and never mentions d3d11.dll or d3d12.dll anywhere in its
import table. A hook placed against the real module's name finds nothing, says
not imported, and the game proceeds unwatched.
There are two ways a game reaches these entry points and both need covering: a
static import from whatever module it names, and a runtime GetProcAddress.
DYNASTY WARRIORS uses the second, Nioh 3 the first, and the probe written for
Ninja Gaiden 4 had only ever needed the second.
A substituted handle needs both ends. A bridge that answers a failing
GetSharedHandle with a handle of its own invention is only correct if the
place that handle comes back to — ID3D12Device::OpenSharedHandle — is also
intercepted. Reaching one and not the other sends an invented value into a real
device, and the result is an access violation rather than a clean failure. Both
ends have to be gated on each other.
This is worth stating for the same reason as the defect it works around: a component that answers no to sharing lets a caller fall back, and a caller handed something that is not a handle has nowhere to go.
winevideo repairs several of these titles by replacing binaries inside the
CrossOver installation: its payload carries a patched Wine d3d9.dll,
mfplat.dll and winegstreamer, and its two D3D9 patches add the bridge handle
and the sidecar upload to d3d9 itself. This project reaches several of the
same places from inside the game process. That is a trade, not an improvement,
and anyone who finds both will ask about it.
What being outside buys.
The fixes survive a CrossOver update. A patched binary has to be rebuilt for every build it is applied to. winevideo declares stable 26.2 or 26.3 and states that Preview is not supported; that follows from what it ships rather than from a preference. The patches here survived a CrossOver version change without recompiling, which is the property that makes targeting a Preview build possible at all.
Nothing is redistributed. No modified Wine binaries leave this repository, and a fix is removed by deleting one file from the game folder.
The findings stay reportable. Every conclusion here is stated at the level of
an interface or an instruction -- D3DMetal's per-title override table, the
Disable* fields in DSTORAGE_CONFIGURATION1, the share handle of zero. That
form is directly actionable for whoever maintains the runtime. A diff against a
Wine tree is not the same offer.
Old games hold still. These titles will not ship another patch, so teaching the bridge which entry point one of them uses is a cost paid once rather than maintenance.
What being outside costs. A hook in the game process reaches only what the
game calls. A repair living in d3d9 sees every surface presented no matter
which entry point created it; this one saw nothing at all until it was told
about slot 20, and that is the general shape of the weakness rather than a
single oversight. The tamper-protection limit noted under The staged codecs
applies here too.
How a repair reaches the code it has to change: the patch itself, the DLL it rides in on, and the two ways a call is intercepted once it is there.
Electra decides whether to use the D3D12 buffer pool by comparing the D3D
version against 12000 — bUseGPUBuffers = (PlatformDevice && PlatformDeviceVersion >= 12000).
The compiler turns that into:
cmp dword [rbp+disp], 12000 ; 81 7D xx E0 2E 00 00
jl <cpu path> ; 7C xx or 0F 8C xx xx xx xxThe 12000 is raised to INT_MAX, so the comparison always takes the CPU branch.
Four bytes per site, four sites in the Mortal Shell 2 build, each confirmed to
be reached at runtime rather than assumed from the disassembly. Nothing else is
touched: the decoder still decodes VP9 with its own libvpx, and Unreal presents
the frames the way it does on any D3D11 machine.
The compare has to be against a stack slot (81 7D / 81 BD). The H.264
and H.265 decoders compare a register instead, and they already have a way out
through Electra.Win.H264UseOldOutputPath — leaving them alone keeps this
change to the one decoder that has no other option.
It is a pattern scan, not a table of offsets, because a game update moves
everything: between two builds of Mortal Shell 2 the crash site alone shifted by
0x2C70. If the pattern does not match, nothing is written and the log says so.
No static scanner for that pattern ships here. The code that knows it is the runtime patch itself, so the count of sites arrives once the fix is installed rather than before.
The game has no plugin hook, so the patch rides in on a DLL the engine already
loads. libogg_64.dll is a good carrier: every Unreal title ships it, it loads
before any cutscene, its ABI has been frozen for years, and it has nothing to do
with rendering — so a proxy in front of it cannot disturb the renderer.
libogg_64.dll <- our proxy
libogg_real.dll <- the game's original, renamed, untouched
The proxy is 90,624 bytes as it ships today. That figure moves whenever another repair is merged into the one carrier, so read it as a scale rather than a constant.
The proxy re-exports all 64 symbols as PE forwarders straight to
libogg_real, so the Windows loader resolves them on demand and no thunk code
of ours ever runs. The only thing we get is DllMain, which starts a thread and
applies the patch.
The installer refuses to run if the game's libogg exports anything the shipped
proxy does not forward — a missing entry point would stop the game from starting
at all, so it is better to fail early and ask for a rebuild.
The same shape carries the other two fixes on different carriers: libxess.dll
for DYNASTY WARRIORS, 27 exports, with the game's own renamed to
libxess_real.dll; amd_ags_x64.dll for Persona 5 Strikers, 38. Both are
vendor libraries the game imports directly and barely uses under CrossOver —
libxess is Intel's XeSS upscaler, which is why a proxy in front of it cannot
disturb video.
Two ways in, used for different things.
Import-table hooks replace the address the game calls for a named function in a named DLL. The IAT is a documented structure, so nothing depends on which compiler built the game or on code moving between updates. Two traps come with it, and both have cost runs here:
-
Delay-loaded imports are resolved through
GetProcAddress, so a hook on the import table alone never fires.GetProcAddressis hooked as well, which also catches a call made through some other module — DYNASTY WARRIORS asks NVIDIA Streamline'ssl.interposer.dllforD3D12CreateDeviceby name. -
Imports by ordinal carry no name, and a hook that walks names skips them.
The hook then installs, reports itself installed, and is never called.
DYNASTY WARRIORS imports
D3D12CreateDevicefromd3d12.dllby ordinal 101.
Both traps also explain an empty probe log, which is why Diagnosing a new game sets them out again in that context.
Vtable-slot patches replace one entry of one COM object's function table, which is how a call is intercepted on an interface nobody exports a function for. The slot is counted from the start of the interface's inherited layout, so the number has to be derived rather than guessed:
| Interface | Slot | Method |
|---|---|---|
IDXGIAdapter3 |
14 |
QueryVideoMemoryInfo — three IUnknown, four IDXGIObject, three IDXGIAdapter, GetDesc1, GetDesc2, two content-protection entries |
IDXGIResource |
8 | GetSharedHandle |
IDXGIFactory1 |
7 / 12 |
EnumAdapters / EnumAdapters1
|
ID3D11Device |
5 | CreateTexture2D |
ID3D11DeviceContext |
48 | UpdateSubresource |
ID3D12Device |
32 | OpenSharedHandle |
IMFSourceReader |
6 / 7 / 9 |
GetCurrentMediaType / SetCurrentMediaType / ReadSample
|
IMFMediaType |
10 |
GetGUID — an IMFAttributes, so 3 + 7 |
Two things about these are worth knowing before writing one. A vtable is shared
by every instance of a class and read by every thread, so the moment the slot is
written the replacement is live everywhere at once — the original pointer has to
be stored before the slot goes live, not after. And a NULL slot means the
object is not the class it was taken for; the patch refuses rather than writing
into whatever is there. IDXGIFactory's vtable ends at 11, which is what makes
the IDXGIFactory1 query before slot 12 a correctness requirement rather than
tidiness.
Persona 5 Strikers needs a VC-1 decoder no CrossOver ships — that is a
property of CrossOver rather than a difference between the two lines, and it is
what makes this title independent of which build it runs on. Nothing is
distributed here: runtime/stage-codecs.sh borrows the decoder from the user's
own official GStreamer install. The staging, the dyld crash that made re-homing
necessary, and the layout trap that cost a first attempt are on
that title's page.
Four things about the staging are not title-specific.
What else is in that plugin. libgstlibav carries ffmpeg. VC-1 is what this
title needs and what was measured, by the game playing. WMV3 and WMA are in the
same plugin and are expected to work; neither has been exercised here, because
ffmpeg ships no encoder for either and no test bitstream could be made.
Why 1.24.14. It is the version this was verified with. Others may well be
fine — winevideo names 1.24.13 for the same titles — and the requirement is
likely to be the 1.24 series rather than that exact release, since the plugin
only has to be ABI-compatible with the CrossOver GStreamer core it is re-homed
onto. No other release has been tried, so that last part is an inference from
ABI policy and not a measurement. stage-codecs.sh prints the version it finds
and says so when it is outside 1.24, reporting rather than refusing: turning
away something that might work is as unhelpful as staying quiet about something
that might not.
Re-homing has to follow the whole @rpath chain, not one level. A staged
plugin names its own dependencies, but those name siblings of their own —
libgstpbutils names libgsttag — and dyld resolves those against the staging
directory rather than against CrossOver's. Linking only what the plugin itself
names leaves the second level unresolved, and the plugin then fails to load
outright and silently: no error reaches the application, and the only trace is a
GStreamer warning on a channel nobody is reading. A staging that appears to do
nothing should be checked for this before anything else is concluded from it.
Following the chain is what takes Nioh's DMO Init from 0xD0000001 to S_OK
and lets the DirectShow graph build.
Per-engine staging. Every installed CrossOver gets its own staging
directory, keyed by the engine's CFBundleVersion — the same string a bottle
records as its Version, so a bottle can be matched to the staging it needs
without guessing, and an engine whose .app filename does not say what it is
still resolves. The bottle setting survives because the launcher sets only
GST_PLUGIN_SYSTEM_PATH and never touches GST_PLUGIN_PATH, and the bottle's
environment is applied first. That was read out of Preview's bin/wine and has
not been checked on another build, so the staging assumes it holds across
CrossOver releases rather than knowing it does.
The idea is not ours. winevideo does this too — its README describes importing WMV/VC-1 codecs from the user's official GStreamer install, and it requires GStreamer 1.24.13 for exactly the titles that need them. What differs is only the mechanism: winevideo patches the CrossOver installation to make those plugins loadable, and this reaches the same end with one staged folder and one line of bottle configuration.
That difference runs through the whole comparison, and it is a trade rather than an improvement. This project reaches several of the same places from inside the game process instead of by patching Wine, which is only possible because winevideo had already worked out what was wrong. Where the two differ most: winevideo works outside the game, so it reaches titles protected against tampering, which nothing here can.
None of these faults is specific to the title it was found on.
The Unreal crash is in ElectraMediaVPxDecoder, which is engine code, so any
UE5 title with VP9 cutscenes on D3D12 hits it — same stack, same address,
different offsets. The DXGI node walk is in Unreal's D3D12 RHI, so any UE5 title
on that RHI makes it. The DYNASTY WARRIORS fault is what happens to any game
that decodes video on a D3D11 device and presents it with a D3D12 renderer. The
H.264/NV12 negotiation is CrossOver's behaviour on macOS and not any game's.
Persona 5 Strikers' D3D9-to-D3D11 bridge is the narrowest of the five, and even
there the frame converter came from another title's fix unchanged.
What is specific is the carrier — the DLL the fix rides in on:
-
libogg_64.dllfor Unreal titles -
libxess.dllfor DYNASTY WARRIORS: ORIGINS -
amd_ags_x64.dllfor Persona 5 Strikers
Adding a game means finding a DLL it loads directly that has nothing to do with rendering, and building a proxy for it:
runtime/build-proxy.sh "/path/to/game/<carrier>.dll" dwo-video-bridge.cAn Unreal title that ships no libogg cannot take the runtime patch at all,
since that is what it rides in on. Another carrier may exist for such a title —
the two non-Unreal games here are reached through vendor libraries — but none
has been found for an Unreal title without one, and
Diagnosing a new game describes how a carrier is
chosen.
diagnostics/survey-games.sh reports what a game ships and which media API it
uses. Read both halves of that row: the codec says whether anything can decode
it, and the container says whether anything can open it — which, on stable
CrossOver, is where WebM stops.
Do not use any of this on a game with anti-cheat. It patches a running process, which is exactly the behaviour anti-cheat exists to stop.
Documented so nobody spends an evening rediscovering them.
-
Registry keys or environment variables for D3DMetal. Read from the D3DMetal shipped with GPTK 4.0 beta 2: no registry keys at all, and 27
D3DM_*environment variables, none relevant. The count will change with the next GPTK; the conclusion does not depend on it. TheIID_ID3DDestructionNotifierGUID does not appear in the framework binary at all — control GUIDs such asIID_ID3D12Devicedo, so the test is sound. There is no switch because the code is not there. -
GPTK 4.0 beta 2. Does not fix it. Its
d3d12.dlldoes contain the stringIID_ID3DDestructionNotifier, which looks promising — but that DLL carries 698IID_*strings (IID_IAdviseSink,IID_IBindCtx, …). It is a generic COM name table used for diagnostic logging, not an implementation. Tested in-game: identical crash, same address. -
A proxy
d3d12.dll. On GPTK 3 it is structurally impossible: every export is a trampoline (mov gGFXTDispatch+N, rax ; jmp rax) into a table the D3DMetal core fills at runtime, and only in the module it recognises asd3d12.dll. GPTK 4 turned those into real functions (.textgrew from 854 to 7238 bytes) so a proxy becomes possible in principle — except D3DMetal refuses to initialise under any other module name, givingERROR_DLL_INIT_FAILED. The same refusal is expected to block moving the D3D11 and D3D12 hooks to the CrossOver level the way the DXGI node guard already is: that guard works because Apple'sdxgi.dlltolerates being renamed todxgi_real.dll, andd3d12.dllis not known to tolerate the same. Expected rather than tried — nobody has built that proxy and watched it fail. docs/upstreaming.md proposes the move and carries the same caveat. -
WebMMedia. Unreal ships a second VP8/VP9 player, wholly independent of Electra, and it is compiled into the game. Its factory accepts only the
.webmextension while Unreal asks for.mp4, so it never gets a look. Remuxing every video to WebM losslessly: game boots, no crash, no video. -
Patching vkd3d. vkd3d-proton added
ID3DDestructionNotifierin v2.14 precisely for games that expect it, and backporting it onto CrossOver's vkd3d 1.18 builds and loads cleanly. It is not on the path — D3D12 is served by D3DMetal here. Useful only if your D3D12 goes through vkd3d. -
DXMT for D3D12. Its D3D12 exists (
-Denable_d3d12=true, off by default) but is early: 8787 lines against 18621 for its mature D3D11. Built against CrossOver, the game does not launch. That is a measured failure at the version tested, not a judgement about the project.
Wine distinguishes builtin from native DLLs by a 32-byte signature at offset
0x40 of the DOS stub — the string Wine builtin DLL, checked in
dlls/ntdll/loader.c. winebuild writes it; llvm-mingw does not. Without
it Wine will not load your DLL as a builtin, and you will chase phantom errors.
DXMT's meson handles it for you.
Earlier releases could transcode the cutscenes to H.264 and drop the VP9
originals from the .pak index. The runtime patch replaces it completely and is
better on every axis, so that mode has been removed rather than left as a trap:
it took twenty minutes, needed ffmpeg and a gigabyte, softened the picture, and
edited files the game shipped.
If you applied it with an older release, the app still detects it and offers to
undo it, because a patched pak index and a Movies_VP9_backup folder cannot be
unwound any other way short of letting Steam re-download the game. Undo it, then
apply the runtime patch. The commands are in the
README.
pak-hide-videos.py is pure Python 3 with no dependencies and runs on the
interpreter macOS already ships. The forward direction of both scripts is no
longer offered by the app.
Kept because the undo path still relies on it, and because the format notes are the only public write-up of this that we know of.
It never touches file data. It rewrites the FullDirectoryIndex without the
.mp4 entries, sets bReaderHasPathHashIndex = 0 so the engine consults only
the directory index — which avoids having to reimplement UE's path hash —
recomputes the SHA-1s, and appends the new index at the end of the file with
an updated footer. Undoing it is a plain truncate back to the original size,
recorded in a small JSON file beside the pak.
It validates the index hash before writing anything, refuses to run twice, and rejects encrypted indexes and pak versions other than 11.
Once an entry is gone the engine falls through to disk, because
FPakPlatformFile::IsNonPakFilenameAllowed does not exclude .mp4.
In the repository rather than on the wiki:
- docs/winevideo-on-preview.md — what a current CrossOver decodes on its own, measured codec by codec, and what winevideo is still for.
- docs/what-we-got-wrong.md — the mistakes made producing these results: claims that reached the wiki and were withdrawn, the reading errors that caused them, and the hypotheses that did not survive.
- docs/upstreaming.md — what of this could stop being per-game, and what should not be attempted.
Back to the games table · Diagnosing a new game