#45

d3d9: lock 64-bit dynamic buffers in place instead of copying them per draw

Openillixionwants to merge intofrom

In the ARM64EC (64-bit guest) frontend, D3DPOOL_DEFAULT + D3DUSAGE_DYNAMIC vertex and index buffers now lock the Metal allocation that draws read, the way DXVK's allowDirectBufferMapping does. Before, a lock handed out a host mirror, and every draw that read a dirty buffer renamed it and copied the whole buffer.

This only matters once the x64 D3D9 frontend ships, which is willfaust/Madeira#242.

Why

On an iPhone 15 Pro Max, a Team Fortress 2 timedemo kept the D3D9 thread about 76% busy, and about 43% of that was memmove in MTLD3D9VertexBuffer::refreshWholeMirror / MTLD3D9IndexBuffer::refreshWholeMirror. A buffer stays dirty while it is locked, so it was copied again on every draw until Unlock.

The mirror exists for two reasons, and d3d9_buffer_map.hpp explains both:

  • A 32-bit guest can't address Metal's allocation.
  • Wrapping guest pages makes translated stores into them livelock.

A 64-bit guest has neither problem: it addresses Metal's own allocation, and nothing gets wrapped. The comment already says direct mapping becomes correct in that case. This PR adds a "DIRECT MAPPING" paragraph there.

What changes

  • When it applies: directMapEnabled() is compiled in only for __x86_64__ && !DXMT_MADEIRA, so only the ARM64EC frontend gets it. The i386 frontend and the native ARM64 frontend (dxmt_madeira_native) keep the mirror. Software-VP-only devices are excluded too, because they strip DISCARD/NOOVERWRITE.
  • Locking: DISCARD renames to a GPU-idle allocation, and NOOVERWRITE keeps the current one. A plain lock renames and copies the old contents over.
  • No mirror: a direct-mapped buffer frees its mirror. refreshWholeMirror returns early, and hostPointer() returns the mapped allocation.
  • Memory: direct-mapped allocations use the default CPU cache mode, not write-combined. The lock pointer now serves the app's reads, ProcessVertices, and the 16-bit strip and fan index scans, which the mirror used to serve from cached memory.
  • Off switch: MADEIRA_D3D9_DIRECT_MAP=0 turns it off at run time.

A write outside the locked range, or after Unlock, now reaches the draw as it does on Windows. The mirror path tolerated that by copying the whole buffer.

Testing

TF2 apbench timedemo: 3476 frames, 2fort with 16 bots, uncapped.

DeviceResolutionBeforeAfter
iPhone 15 Pro Max (iPhone16,2), iOS 27.0.1 (24A446), Madeira x64 frontend1424x656 fullscreen (native aspect ratio)42.84 fps (23.34 ms/f)83.66 fps (11.95 ms/f)
M2 Max, macOS 27.0.1, Wine 11 + FEX (ARM64EC)1280x720 windowed75.28 fps137.91 fps (WineD3D: 116)
  • iPhone: the numbers are from the first commit (write-combined allocations), on a warm phone, second run. Rendering looked right throughout, including the busiest effects scene. Memory footprint was 5.08 GB, with no memory-pressure warnings.

  • Cached memory: the second commit, on the M2 Max, gives 134.18 and 137.38 fps, the same as write-combined within noise. It hasn't been run on the phone yet.

  • Garry's Mod 64-bit (gmod_win64.exe, M2 Max): renders correctly on gm_construct with 210 Workshop addons. A short demo is performance-neutral: 74.6, 73.5 and 68.1 fps with direct mapping, against 73.3 and 72.3 fps without.

  • Wine's d3d9 conformance tests, the 25 tests in device.c, visual.c and d3d9ex.c that create vertex or index buffers. Run with MADEIRA_D3D9_DIRECT_MAP=0 and then without it, on the M2 Max:

    FileFailures, mirrorFailures, direct map
    device.c2424 (same)
    visual.c4543
    d3d9ex.c5656 (same)

    The two checks that now pass are visual.c test_dynamic_map_synchronization's "Expected unsynchronised map" checks for NOOVERWRITE. The full test files can't run on macOS 27 right now: two crashes on the Cocoa main thread hang Wine's signal handler, one in AppKit's titlebar teardown and one in winemetal's MetalLayer_setProps. Both happen with the mirror path too, so they're not caused by this change. I ran these 25 tests through a filter instead.

🤖 Generated with Claude Code