Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions DEVNOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,58 @@ managed code so this diminishes the utility of moving this code to rust, since t
be hot reloaded. The ability to reload the managed code is very useful during the process of adding support for a new game, since often tiny tweaks need to be made to formats and such and its very
tedious to restart the whole game just for those.

### DX11 dynamic buffer snapshots (the `snapshot-dynamic-buffers` feature)

MM assumes that each mesh has its own vertex/index buffer and these are largely static - i.e the game isn't updating them every frame to do, for instance, software animation - which isn't supported.

At least one game takes this approach for most of its character meshes, so is mostly moddable, but will draw some parts out of a large buffer that is dynamically updated (and animated on the CPU).

This was more common in older game engines, which had limits on the
number of bone transfers they could squeeze into DX9 shader constants (~256) - so things like a cape might be split out into a separate draw, because the game couldn't fit that into a single draw call with the rest of the character (which can have many bones for limbs, facial and finger animations).

Normally these parts will be lost in snapshot, and if software-animated (which is likely), they could not be modded anyway. However _if_ the part was snapshotted, in some cases it is possible in practice to "weld" it back to a GPU animated part that is moddable, by hand in blender, and it looks ok despite the host part not having all the weights needed to render it like the original.
The original can then be hidden with a deletion mod, which has the host part as a parent to reduce the chance of misfire. This isn't theoretical as I've done it at least once. 🙃

`snapshot-dynamic-buffers` was added to support this case. When built with this (in DX11), the game will track those buffers in an attempt provide the ability to snapshot pieces like this. But since it typically introduces a performance hit when enabled, especially when snapshotting, possibly resulting in missing static parts if the snap window is too short, it is off by default. So normally uou want to turn it on, snapshot what you need, then turn it off.

Now some details provided by Claude (I skimmed these at least once):

Some DX11 games pack many meshes into a few large buffers (a "megabuffer") that are created empty
and filled later via `Map`/`Unmap` or `UpdateSubresource`, which `hook_CreateBuffer` never sees.
The `snapshot-dynamic-buffers` Cargo feature captures those buffers and slices the drawn
sub-region out of them at snapshot time. It lives in `hook_core` and transitively enables the
same-named feature in `hook_snapshot` and `shared_dx`. Build with
`--features=snapshot-dynamic-buffers` from `Native/hook_core`; `rb.sh` and `r2026g1.sh` take a
feature name as their first argument.

The code calls these **dynamic buffers**, as shorthand for "an index or vertex buffer whose
contents ModelMod obtains by watching the game write it, rather than from the `pInitialData` it
was created with". Anything described that way, and anything named `dyn_*`, only does something
when this feature is enabled; a default build sees only the static creation-time path.

It is off by default because it is still slower at snapshotting than the plain path, and the
whole-buffer path it replaces has strict size checks that are a useful sanity check on games that
do use one buffer per mesh.

Two things about it are worth knowing at this level; the code comments cover the rest.

Capture only runs while a snapshot is in progress, not for the whole session, because the cost is
one copy of an entire buffer per *update* of that buffer and a game may refill one many times per
frame. `SnapPreCopyAlways=1` in the registry restores always-on capture, needed only if a game
refills its mesh buffers less often than one snap window.

Holding **shift** with the clear-texture-lists key additionally drops everything captured from a
dynamic buffer, so the next snapshot uses freshly captured data or fails cleanly rather than
possibly serving bytes from a buffer the game has since destroyed. The key on its own leaves
captured data alone. Worth doing on entering a new scene; the cost is that a dropped buffer has
to be captured again before it can be snapshotted, which a game that writes its mesh buffers
rarely may not do within a snap window. Buffers filled at creation time are never dropped, since
DX11 cannot read a buffer back and they are not affected by the problem.

Precopy itself can be turned on either by the `SnapPreCopyData` registry value at startup or in
game by the clear-texture-lists key. The in-game route works for dynamically updated buffers, but
a buffer filled once at creation time before precopy was on is unrecoverable without recreating
it, since DX11 will not read a buffer back.

`process_metrics` reports capture count, MB copied, time spent and largest buffer; that is the
first thing to look at when snapshotting is slower than expected.
15 changes: 14 additions & 1 deletion Native/global_state/src/global_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ pub struct ClrState {

pub struct RunConf {
pub precopy_data: bool,
/// This only has an effect with the `snapshot-dynamic-buffers` feature: without it the registry
/// value is not even read, and nothing reads this field, so a default build ignores it.
///
/// When true (the default), DX11 dynamic buffer capture only copies buffers while a snapshot
/// is actually in progress (`is_snapping`), rather than on every write for the rest of the
/// session. Copying a large dynamic buffer on every one of its (often very many) writes per
/// frame is what makes precopy unplayably slow; the snapshot only needs the bytes written
/// during the frames it is capturing.
///
/// Set the `SnapPreCopyAlways` registry dword to 1 to get the old always-on behavior, which
/// is the fallback if a game writes its mesh buffers less often than once per snap window.
/// Note the framerate reduction from doing that may well be severe.
pub precopy_only_when_snapping: bool,
pub force_tex_cpu_read: bool,
/// Game profile data loaded from the profile found for this registry key
/// (example: `Software\ModelMod\Profiles\Profile0000`), or empty if none was found.
Expand Down Expand Up @@ -219,6 +232,7 @@ lazy_static! {
pub static mut GLOBAL_STATE: HookState = HookState {
run_conf: RunConf {
precopy_data: false,
precopy_only_when_snapping: true,
force_tex_cpu_read: false,
profile: EMPTY_GAME_PROFILE,
},
Expand Down Expand Up @@ -273,7 +287,6 @@ pub static mut ANIM_SNAP_STATE:UnsafeCell<Option<AnimSnapState>> = UnsafeCell::n
/// finishes loading. Callers should keep the lock for as short a span as
/// possible — particularly on the DIP path.
pub static LOADED_MODS: Mutex<Option<LoadedModState>> = Mutex::new(None);

const TRACK_GS_PTR:bool = true;

/// Container structure providing access to the global state pointer.
Expand Down
7 changes: 6 additions & 1 deletion Native/hook_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@ ProductVersion = "1.2.0.0"
default = []
profile = []
mmdisable = []
frequent-updates = []
frequent-updates = []
# Experimental: capture dynamically-updated (Map/UpdateSubresource) DX11 index/vertex buffers and
# slice the drawn sub-region so meshes stored in large shared "megabuffers" can be snapshotted.
# Off by default: capture only runs during a snapshot, but it still makes snapshotting slower than
# the plain whole-buffer path, which also has stricter size checks.
snapshot-dynamic-buffers = ["hook_snapshot/snapshot-dynamic-buffers", "shared_dx/snapshot-dynamic-buffers"]
137 changes: 120 additions & 17 deletions Native/hook_core/src/hook_device_d3d11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,27 @@ pub unsafe fn apply_context_hooks(context:*mut ID3D11DeviceContext, first_hook:b
(*vtbl).PSSetShaderResources = hook_PSSetShaderResources;
func_hooked += 1;
}
// The dynamic buffer hooks (only with the `snapshot-dynamic-buffers` feature), so that buffers
// the game fills *after* creation -- e.g. a "megabuffer" written via Map/WRITE_NO_OVERWRITE or
// UpdateSubresource -- can be copied for snapshotting. Installed unconditionally within that
// feature, like the draw hooks; the hook bodies do nothing unless a snapshot is running, so
// the runtime precopy toggle works without rehooking the (already-copied) context vtable.
#[cfg(feature = "snapshot-dynamic-buffers")]
{
use crate::hook_dynamic_buffers::{hook_Map, hook_Unmap, hook_UpdateSubresource};
if (*vtbl).Map as usize != hook_Map as *const () as usize {
(*vtbl).Map = hook_Map;
func_hooked += 1;
}
if (*vtbl).Unmap as usize != hook_Unmap as *const () as usize {
(*vtbl).Unmap = hook_Unmap;
func_hooked += 1;
}
if (*vtbl).UpdateSubresource as usize != hook_UpdateSubresource as *const () as usize {
(*vtbl).UpdateSubresource = hook_UpdateSubresource;
func_hooked += 1;
}
}

if TRACK_REHOOK_TIME {
let now = SystemTime::now();
Expand Down Expand Up @@ -636,6 +657,12 @@ unsafe fn hook_d3d11(device:*mut ID3D11Device,_swapchain:*mut IDXGISwapChain, co
let real_ia_set_input_layout = (*vtbl).IASetInputLayout;
let real_ia_set_primitive_topology = (*vtbl).IASetPrimitiveTopology;
let real_ps_set_shader_resources = (*vtbl).PSSetShaderResources;
#[cfg(feature = "snapshot-dynamic-buffers")]
let real_map = (*vtbl).Map;
#[cfg(feature = "snapshot-dynamic-buffers")]
let real_unmap = (*vtbl).Unmap;
#[cfg(feature = "snapshot-dynamic-buffers")]
let real_update_subresource = (*vtbl).UpdateSubresource;

// since we always make a copy of the vtable in the context at the moment, we don't search
// for the real functions as we do in the device case, since a new context should always have
Expand Down Expand Up @@ -672,6 +699,12 @@ unsafe fn hook_d3d11(device:*mut ID3D11Device,_swapchain:*mut IDXGISwapChain, co
real_ia_set_input_layout,
real_ia_set_primitive_topology,
real_ps_set_shader_resources,
#[cfg(feature = "snapshot-dynamic-buffers")]
real_map,
#[cfg(feature = "snapshot-dynamic-buffers")]
real_unmap,
#[cfg(feature = "snapshot-dynamic-buffers")]
real_update_subresource,
};

Ok(HookDirect3D11 { context: hook_context })
Expand Down Expand Up @@ -713,6 +746,16 @@ pub unsafe fn query_and_set_runconf_in_globalstate(check_precopy:bool) -> bool {
}
}

// Opt back in to copying mesh buffers on every update rather than only while a snapshot is
// running. Only useful for a game that updates its buffers less often than once per snap
// window; it is otherwise the difference between a playable framerate and a slideshow.
// Only the dynamic buffer capture reads this, so don't bother querying it without that.
#[cfg(feature = "snapshot-dynamic-buffers")]
{
GLOBAL_STATE.run_conf.precopy_only_when_snapping =
!matches!(util::reg_query_root_dword("SnapPreCopyAlways"), Ok(v) if v > 0);
}

let force_tex_cpu_read = util::reg_query_root_dword("SnapForceTexCpuRead");
let old_force_tex_cpu_read = GLOBAL_STATE.run_conf.force_tex_cpu_read;
if let Ok(force_tex_cpu_read) = force_tex_cpu_read {
Expand All @@ -723,8 +766,15 @@ pub unsafe fn query_and_set_runconf_in_globalstate(check_precopy:bool) -> bool {
if old_force_tex_cpu_read != GLOBAL_STATE.run_conf.force_tex_cpu_read {
changed = true;
}
write_log_file(&format!("runconf: precopy data: {}, force tex cpu read: {} (setting changed: {})",
GLOBAL_STATE.run_conf.precopy_data, GLOBAL_STATE.run_conf.force_tex_cpu_read,
#[cfg(feature = "snapshot-dynamic-buffers")]
let dynbuf = format!(" (dynamic buffer capture only while snapping: {})",
GLOBAL_STATE.run_conf.precopy_only_when_snapping);
#[cfg(not(feature = "snapshot-dynamic-buffers"))]
let dynbuf = "";
write_log_file(&format!("runconf: precopy data: {}{}, force tex cpu read: {} (setting changed: {})",
GLOBAL_STATE.run_conf.precopy_data,
dynbuf,
GLOBAL_STATE.run_conf.force_tex_cpu_read,
changed,
));
write_log_file(&format!("runconf: game profile: {:?}", GLOBAL_STATE.run_conf.profile));
Expand Down Expand Up @@ -1017,28 +1067,81 @@ unsafe extern "system" fn hook_CreateBuffer(
ppBuffer
);

if res == 0 && ppBuffer != null_mut() && (*ppBuffer) != null_mut() {
// if its an index buffer with data, we need to copy it out
if res == 0 && ppBuffer != null_mut() && (*ppBuffer) != null_mut() && !pDesc.is_null() {
// For index/vertex buffers, record metadata (type + size) so the dynamic buffer hooks can
// identify this buffer later -- even if it is created empty and filled afterwards (the
// "megabuffer" case). If initial data was supplied we also copy it out now, since DX11
// offers no way to read the buffer back from the CPU later; that copy is the ordinary
// static path and happens with or without the feature.
let is_ib = (*pDesc).BindFlags & D3D11_BIND_INDEX_BUFFER != 0;
let is_vb = (*pDesc).BindFlags & D3D11_BIND_VERTEX_BUFFER != 0;
if !pDesc.is_null() && (is_ib || is_vb)
&& !pInitialData.is_null() && !(*pInitialData).pSysMem.is_null() {
if (*pInitialData).SysMemPitch != 0 || (*pInitialData).SysMemSlicePitch != 0 {
write_log_file(&format!("WARNING: hook_CreateBuffer: index or vertex buffer created with pitch or slice pitch, copy unimplemented"));
} else {
let vlen = (*pDesc).ByteWidth as usize;
let mut dest_v:Vec<u8> = Vec::with_capacity(vlen);
std::ptr::copy_nonoverlapping::<u8>((*pInitialData).pSysMem as *const u8, dest_v.as_mut_ptr(), vlen);
dest_v.set_len(vlen);
if !(is_ib || is_vb) {
// Not a mesh buffer. The meta map is keyed on the raw pointer and D3D reuses freed
// addresses, so a constant buffer landing on a former VB/IB address would otherwise
// inherit that buffer's stale entry. Only correct that when an entry actually
// exists: constant buffer creation can be frequent and shouldn't pay for a write
// lock just to record an absence the dynamic buffer hooks would fill in themselves.
#[cfg(feature = "snapshot-dynamic-buffers")]
{
let stale = dev_state_d3d11_read()
.map(|(_lck, state)| state.rs.device_buffer_meta.contains_key(&(*ppBuffer as usize)))
.unwrap_or(false);
if stale {
dev_state_d3d11_write().map(|(_lock,ds)| {
ds.rs.device_buffer_meta.insert(*ppBuffer as usize,
shared_dx::dx11rs::BufferMeta::Other);
});
}
}
} else {
let buf_ptr = *ppBuffer as usize;
let byte_width = (*pDesc).ByteWidth;

let has_pitch = !pInitialData.is_null()
&& ((*pInitialData).SysMemPitch != 0 || (*pInitialData).SysMemSlicePitch != 0);
if has_pitch {
write_log_file("WARNING: hook_CreateBuffer: index or vertex buffer created with pitch or slice pitch, copy unimplemented");
}
let initial_copy: Option<Vec<u8>> =
if !pInitialData.is_null() && !(*pInitialData).pSysMem.is_null() && !has_pitch {
let vlen = byte_width as usize;
let mut dest_v:Vec<u8> = Vec::with_capacity(vlen);
std::ptr::copy_nonoverlapping::<u8>((*pInitialData).pSysMem as *const u8, dest_v.as_mut_ptr(), vlen);
dest_v.set_len(vlen);
Some(dest_v)
} else {
None
};

#[cfg(feature = "snapshot-dynamic-buffers")]
dev_state_d3d11_write().map(|(_lock,ds)| {
ds.rs.device_buffer_meta.insert(buf_ptr,
shared_dx::dx11rs::BufferMeta::Mesh { is_index: is_ib, byte_width });
});

// take the write lock only when there's data to store (matches original behavior).
if let Some(dest_v) = initial_copy {
dev_state_d3d11_write()
.map(|(_lock,ds)| {
#[cfg(feature = "snapshot-dynamic-buffers")]
{
use shared_dx::dx11rs::{BufferCaptureInfo, BufferWriteKind,
BUFFER_CAPTURE_SEQ};
let seq = BUFFER_CAPTURE_SEQ.fetch_add(1, Ordering::Relaxed);
let count = ds.rs.buffer_capture_info.get(&buf_ptr)
.map(|i| i.count).unwrap_or(0) + 1;
ds.rs.buffer_capture_info.insert(buf_ptr, BufferCaptureInfo {
kind: BufferWriteKind::InitialData,
seq, count, bytes: dest_v.len(),
});
}
if is_ib {
ds.rs.device_index_buffer_data.insert(*ppBuffer as usize, dest_v);
ds.rs.device_index_buffer_createtime.push((*ppBuffer as usize, SystemTime::now()));
ds.rs.device_index_buffer_data.insert(buf_ptr, dest_v);
ds.rs.device_index_buffer_createtime.push((buf_ptr, SystemTime::now()));
}
else if is_vb {
ds.rs.device_vertex_buffer_data.insert(*ppBuffer as usize, dest_v);
ds.rs.device_vertex_buffer_createtime.push((*ppBuffer as usize, SystemTime::now()));
ds.rs.device_vertex_buffer_data.insert(buf_ptr, dest_v);
ds.rs.device_vertex_buffer_createtime.push((buf_ptr, SystemTime::now()));
}
});
}
Expand Down
Loading
Loading