diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-08 17:33:36 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-08 17:33:36 -0500 |
| commit | cf04c1780601dd3a4fc0e7ae079dc2aa5dbc13f2 (patch) | |
| tree | c93284194b80f32e682976bc94992b5de1304386 | |
| parent | 492b32bf3d4410a7dab629315e0ebd8f9346f6b4 (diff) | |
vfs: scope hook to mount, fix negcache to handle backing entries
Three bugs in the PE-side bridge collided to crash NSPBR ~54s after
launch with an AV in NPCWaterAIFix.dll. Root cause: the bridge said
STATUS_OBJECT_NAME_NOT_FOUND for files that exist on disk but are not
in the static index, masquerading as ground truth.
1. load_index() filtered every entry whose real_path didn't start
with '/'. That dropped every is_backing=true entry — for NSPBR,
the entire vanilla Skyrim base layer (Skyrim.esm, all base BSAs,
Video/BGS_Logo.bik). Now accept both forms; backing entries get
real_nt=NULL and exist only for query / negcache logic.
2. The "open negcache" comment claimed the index was authoritative
for the FUSE mount tree. With (1), it is not. Comment rewritten;
the comment about attr_cache_invalidate (which never existed) is
also corrected to point at attr_cache_mark_exists.
3. Hook_NtCreateFile and Hook_NtOpenFile returned ENOENT on
`!attr_cache_lookup() || !cexists` — the "no entry at all" case
got the same treatment as a confirmed-negative cache hit. Plugin
code that calls CreateFile without a preceding GetFileAttributes
would never reach Real_NtCreateFile. Now mirrors what
try_kernel32_attr_hit already did: only ENOENT on confirmed
negatives; pure cache miss falls through to Real_, which serves
correctly via Wine + FUSE.
Also includes the previously-uncommitted mount-scope fast gate so
out-of-mount paths (system DLLs, registry-backed objects, unrelated
drives) bypass the hook entirely with a single prefix compare on the
input string — no normalization, no cache lookup, no chance of a
wrong answer.
Verified by a self-contained 44-assertion C reproduction at
/home/luke/builds/fluorine-vfs-tests/test_negcache_logic.c (six
scenarios: backing-filter exclusion, first-open without pre-query,
pre-query masking, SKSE plugin probes, post-fix Skyrim recovery,
nt_path_to_key edge cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | src/src/vfs/fluorine_vfs_hooks.c | 256 |
1 files changed, 239 insertions, 17 deletions
diff --git a/src/src/vfs/fluorine_vfs_hooks.c b/src/src/vfs/fluorine_vfs_hooks.c index b056cf3..366963d 100644 --- a/src/src/vfs/fluorine_vfs_hooks.c +++ b/src/src/vfs/fluorine_vfs_hooks.c @@ -331,6 +331,21 @@ static CRITICAL_SECTION g_dir_lock; static int g_dir_lock_ready = 0; static HANDLE g_dummy_dir = INVALID_HANDLE_VALUE; static char* g_mount = NULL; +/* Precomputed mount prefixes for the fast scope gate. Built once in + * load_index() after g_mount is normalized. The gate keeps the bridge from + * touching any file outside the game's data dir: every probe to a system + * DLL, registry-backed object, or unrelated drive bypasses the hook entirely + * and goes straight to the real syscall. */ +static WCHAR* g_mount_w_nt = NULL; /* "\\??\\Z:\\<mount>" lowercase */ +static size_t g_mount_w_nt_chars = 0; +static WCHAR* g_mount_w_dosq = NULL; /* "\\\\?\\Z:\\<mount>" lowercase */ +static size_t g_mount_w_dosq_chars = 0; +static WCHAR* g_mount_w_bare = NULL; /* "Z:\\<mount>" lowercase */ +static size_t g_mount_w_bare_chars = 0; +static char* g_mount_a_bare = NULL; /* "z:\\<mount>" lowercase ANSI */ +static size_t g_mount_a_bare_len = 0; +static char* g_mount_a_dosq = NULL; /* "\\\\?\\z:\\<mount>" lowercase ANSI */ +static size_t g_mount_a_dosq_len = 0; static volatile LONG g_index_ready = 0; static __thread int t_inside_redirect = 0; static __thread WCHAR t_redirect_path[32768]; @@ -449,6 +464,160 @@ static WCHAR* unix_path_to_nt_wide(const char* path) return out; } +/* --- Mount-scope fast gate -------------------------------------------------- + * + * The bridge only ever has knowledge of files inside the FUSE mount (i.e. the + * game's Data dir). Any probe to a path outside that subtree must be invisible + * to our hooks: pass straight through to Wine, no normalization, no cache + * lookup, no possible chance of a wrong answer. + * + * To make that cheap, we precompute the mount prefix in the three forms that + * Wine/games commonly produce, lowercased, and do a byte/wide prefix compare + * directly on the input the kernel/k32 hook receives. Out-of-mount paths fail + * the compare in a handful of characters and pass through without any of the + * UTF-8/slash/lowercase machinery in nt_path_to_key(). + */ + +static WCHAR ascii_lower_w(WCHAR c) +{ + return (c >= L'A' && c <= L'Z') ? (WCHAR)(c + 32) : c; +} + +static int wide_match_prefix(const WCHAR* path, size_t path_chars, + const WCHAR* prefix, size_t prefix_chars) +{ + if (prefix == NULL || prefix_chars == 0) return 0; + if (path_chars < prefix_chars) return 0; + for (size_t i = 0; i < prefix_chars; ++i) { + if (ascii_lower_w(path[i]) != prefix[i]) return 0; + } + if (path_chars == prefix_chars) return 1; + WCHAR n = path[prefix_chars]; + return n == L'\\' || n == L'/'; +} + +static int byte_match_prefix(const char* path, size_t path_len, + const char* prefix, size_t prefix_len) +{ + if (prefix == NULL || prefix_len == 0) return 0; + if (path_len < prefix_len) return 0; + for (size_t i = 0; i < prefix_len; ++i) { + if (ascii_lower(path[i]) != prefix[i]) return 0; + } + if (path_len == prefix_len) return 1; + char n = path[prefix_len]; + return n == '\\' || n == '/'; +} + +static void build_mount_prefixes(void) +{ + if (g_mount == NULL) return; + size_t mlen = strlen(g_mount); + if (mlen == 0) return; + + /* g_mount is lowercase, forward-slash, no leading or trailing slash. + * Build "z:\\<mount-with-backslashes>" in ANSI lowercase first. */ + size_t bare_len = 3 + mlen; /* "z:\\" + mlen */ + char* a_bare = (char*)malloc(bare_len + 1); + if (a_bare == NULL) return; + a_bare[0] = 'z'; + a_bare[1] = ':'; + a_bare[2] = '\\'; + for (size_t i = 0; i < mlen; ++i) { + char c = g_mount[i]; + a_bare[3 + i] = (c == '/') ? '\\' : c; + } + a_bare[bare_len] = '\0'; + g_mount_a_bare = a_bare; + g_mount_a_bare_len = bare_len; + + /* "\\\\?\\z:\\<...>" */ + size_t dosq_len = 4 + bare_len; + char* a_dosq = (char*)malloc(dosq_len + 1); + if (a_dosq != NULL) { + a_dosq[0] = '\\'; + a_dosq[1] = '\\'; + a_dosq[2] = '?'; + a_dosq[3] = '\\'; + memcpy(a_dosq + 4, a_bare, bare_len); + a_dosq[dosq_len] = '\0'; + g_mount_a_dosq = a_dosq; + g_mount_a_dosq_len = dosq_len; + } + + /* Wide bare. */ + WCHAR* w_bare = (WCHAR*)malloc((bare_len + 1) * sizeof(WCHAR)); + if (w_bare == NULL) return; + for (size_t i = 0; i < bare_len; ++i) { + w_bare[i] = (WCHAR)(unsigned char)a_bare[i]; + } + w_bare[bare_len] = 0; + g_mount_w_bare = w_bare; + g_mount_w_bare_chars = bare_len; + + /* "\\??\\Z:\\<...>" wide */ + size_t nt_chars = 4 + bare_len; + WCHAR* w_nt = (WCHAR*)malloc((nt_chars + 1) * sizeof(WCHAR)); + if (w_nt != NULL) { + w_nt[0] = L'\\'; + w_nt[1] = L'?'; + w_nt[2] = L'?'; + w_nt[3] = L'\\'; + for (size_t i = 0; i < bare_len; ++i) w_nt[4 + i] = w_bare[i]; + w_nt[nt_chars] = 0; + g_mount_w_nt = w_nt; + g_mount_w_nt_chars = nt_chars; + } + + /* "\\\\?\\Z:\\<...>" wide */ + size_t dosq_chars = 4 + bare_len; + WCHAR* w_dosq = (WCHAR*)malloc((dosq_chars + 1) * sizeof(WCHAR)); + if (w_dosq != NULL) { + w_dosq[0] = L'\\'; + w_dosq[1] = L'\\'; + w_dosq[2] = L'?'; + w_dosq[3] = L'\\'; + for (size_t i = 0; i < bare_len; ++i) w_dosq[4 + i] = w_bare[i]; + w_dosq[dosq_chars] = 0; + g_mount_w_dosq = w_dosq; + g_mount_w_dosq_chars = dosq_chars; + } +} + +static int us_under_mount(PUNICODE_STRING_FL us) +{ + if (us == NULL || us->Buffer == NULL) return 0; + size_t chars = (size_t)(us->Length / sizeof(WCHAR)); + return wide_match_prefix(us->Buffer, chars, g_mount_w_nt, + g_mount_w_nt_chars) || + wide_match_prefix(us->Buffer, chars, g_mount_w_dosq, + g_mount_w_dosq_chars) || + wide_match_prefix(us->Buffer, chars, g_mount_w_bare, + g_mount_w_bare_chars); +} + +static int wide_under_mount(LPCWSTR path) +{ + if (path == NULL) return 0; + size_t chars = 0; + while (path[chars] != 0) ++chars; + return wide_match_prefix(path, chars, g_mount_w_nt, g_mount_w_nt_chars) || + wide_match_prefix(path, chars, g_mount_w_dosq, + g_mount_w_dosq_chars) || + wide_match_prefix(path, chars, g_mount_w_bare, + g_mount_w_bare_chars); +} + +static int ansi_under_mount(LPCSTR path) +{ + if (path == NULL) return 0; + size_t plen = strlen(path); + return byte_match_prefix(path, plen, g_mount_a_bare, + g_mount_a_bare_len) || + byte_match_prefix(path, plen, g_mount_a_dosq, + g_mount_a_dosq_len); +} + static WCHAR* utf8_to_wide_n(const char* s, size_t n) { int wlen = MultiByteToWideChar(CP_UTF8, 0, s, (int)n, NULL, 0); @@ -944,6 +1113,7 @@ static int load_index(void) g_mount = heap_strndup(mount, strlen(mount)); if (g_mount == NULL) return 0; normalize_utf8_path(g_mount); + build_mount_prefixes(); char* file = NULL; size_t file_len = 0; @@ -962,14 +1132,23 @@ static int load_index(void) if (type != NULL && ascii_eq_ci(type, "file")) { char* vp = json_string_field(line, "virtual_path"); char* rp = json_string_field(line, "real_path"); - if (vp != NULL && rp != NULL && rp[0] == '/') { + /* Accept both absolute (mod / Overwrite) and backing-relative + * (vanilla data dir) entries. Backing entries get real_nt=NULL + * — they exist for query_index_attrs_key and the negcache + * decision, but Hook_NtCreateFile skips redirect for them and + * falls through to Real_, which serves them via the FUSE + * mount path. Without this, every base-game file open without + * a preceding GetFileAttributes would hit the negcache and + * return STATUS_OBJECT_NAME_NOT_FOUND. */ + if (vp != NULL && rp != NULL) { unsigned long long size = json_u64_field(line, "size"); FILETIME mtime = filetime_from_unix_ns(json_u64_field(line, "mtime_ns")); add_path_to_dirs(vp, size, mtime); normalize_utf8_path(vp); - WCHAR* real_nt = unix_path_to_nt_wide(rp); - if (real_nt == NULL || + WCHAR* real_nt = (rp[0] == '/') ? unix_path_to_nt_wide(rp) : NULL; + int unix_alloc_failed = (rp[0] == '/' && real_nt == NULL); + if (unix_alloc_failed || !append_entry(vp, real_nt, FILE_ATTRIBUTE_ARCHIVE, size, mtime)) { free(vp); @@ -1473,9 +1652,10 @@ static int should_neg_attr_miss(const char* key) * path skip the Real_ syscall entirely. Game-agnostic — no subtree * carve-outs needed because we always real-probe before answering. * - * Negatives are cached too. Mid-boot writes that would invalidate a - * negative cache entry are caught by attr_cache_invalidate() called - * from Hook_NtCreateFile on create-success. */ + * Negatives are cached too. Mid-boot writes that would flip a + * negative entry to positive are caught by attr_cache_mark_exists(), + * called from Hook_NtCreateFile after a successful write/create — + * attr_cache_insert() overwrites any prior entry for the same key. */ typedef struct AttrCacheEntry { @@ -1992,6 +2172,14 @@ static NTSTATUS NTAPI Hook_NtCreateFile(PHANDLE out, ACCESS_MASK access, ULONG ea_len) { InterlockedIncrement64(&g_nt_create); + /* Mount-scope gate: anything outside the FUSE mount is invisible to the + * bridge. Pass through immediately, no normalization, no cache. */ + if (g_index_ready && t_inside_redirect == 0 && oa != NULL && + oa->ObjectName != NULL && oa->RootDirectory == NULL && + !us_under_mount(oa->ObjectName)) { + return Real_NtCreateFile(out, access, oa, iosb, alloc_sz, file_attrs, + share, disp, opts, ea, ea_len); + } char key[2048]; key[0] = '\0'; int have_key = 0; @@ -2036,19 +2224,22 @@ static NTSTATUS NTAPI Hook_NtCreateFile(PHANDLE out, ACCESS_MASK access, } return st; } - } else { + } else if (e == NULL) { + /* Index miss. Only return STATUS_OBJECT_NAME_NOT_FOUND when + * attr_cache has a *confirmed* negative for this key — that + * is, a previous Real_NtQueryAttributesFile probe returned + * ENOENT. A pure cache miss falls through to Real_, because + * the bridge index is not authoritative: backing-relative + * entries can carry real_nt=NULL, runtime-created files + * never reach the index, and other-process writes bypass + * our hooks entirely. Mirrors try_kernel32_attr_hit. */ InterlockedIncrement64(&g_redirect_miss); - /* Aggressive open negcache: index is authoritative for the - * VFS mount tree (it walks FUSE which already composes mods + - * Overwrite). If the path is not in the index and not - * recently created via our hooks (attr_cache positive), it - * does not exist. Skip the Real_ -> Wine -> FUSE round-trip. */ ULONG cattrs = 0; unsigned long long csize = 0; FILETIME cmtime; int cexists = 0; - if (!attr_cache_lookup(key, &cattrs, &csize, &cmtime, &cexists) - || !cexists) { + if (attr_cache_lookup(key, &cattrs, &csize, &cmtime, &cexists) + && !cexists) { if (iosb != NULL) { iosb->u.Status = STATUS_OBJECT_NAME_NOT_FOUND; iosb->Information = 0; @@ -2057,6 +2248,8 @@ static NTSTATUS NTAPI Hook_NtCreateFile(PHANDLE out, ACCESS_MASK access, return STATUS_OBJECT_NAME_NOT_FOUND; } } + /* else: entry in index but real_nt==NULL (backing file). Fall + * through to Real_NtCreateFile so Wine + FUSE serve it. */ } else { InterlockedIncrement64(&g_redirect_skip); } @@ -2084,6 +2277,11 @@ static NTSTATUS NTAPI Hook_NtOpenFile(PHANDLE out, ACCESS_MASK access, ULONG opts) { InterlockedIncrement64(&g_nt_open); + if (g_index_ready && t_inside_redirect == 0 && oa != NULL && + oa->ObjectName != NULL && oa->RootDirectory == NULL && + !us_under_mount(oa->ObjectName)) { + return Real_NtOpenFile(out, access, oa, iosb, share, opts); + } char ofkey[2048]; ofkey[0] = '\0'; int of_have_key = 0; @@ -2127,14 +2325,16 @@ static NTSTATUS NTAPI Hook_NtOpenFile(PHANDLE out, ACCESS_MASK access, } return st; } - } else { + } else if (e == NULL) { + /* Index miss — only ENOENT on confirmed-negative attr_cache. + * See Hook_NtCreateFile for the full rationale. */ InterlockedIncrement64(&g_redirect_miss); ULONG cattrs = 0; unsigned long long csize = 0; FILETIME cmtime; int cexists = 0; - if (!attr_cache_lookup(key, &cattrs, &csize, &cmtime, &cexists) - || !cexists) { + if (attr_cache_lookup(key, &cattrs, &csize, &cmtime, &cexists) + && !cexists) { if (iosb != NULL) { iosb->u.Status = STATUS_OBJECT_NAME_NOT_FOUND; iosb->Information = 0; @@ -2143,6 +2343,8 @@ static NTSTATUS NTAPI Hook_NtOpenFile(PHANDLE out, ACCESS_MASK access, return STATUS_OBJECT_NAME_NOT_FOUND; } } + /* else: entry in index, real_nt==NULL (backing file). Fall + * through to Real_NtOpenFile. */ } else { InterlockedIncrement64(&g_redirect_skip); } @@ -2220,6 +2422,11 @@ static NTSTATUS NTAPI Hook_NtQueryAttributesFile(POBJECT_ATTRIBUTES_FL oa, PVOID info) { InterlockedIncrement64(&g_nt_qattr); + if (g_index_ready && t_inside_redirect == 0 && oa != NULL && + oa->ObjectName != NULL && oa->RootDirectory == NULL && + !us_under_mount(oa->ObjectName)) { + return Real_NtQueryAttributesFile(oa, info); + } if (info != NULL && t_inside_redirect == 0) { ULONG attrs = 0; unsigned long long size = 0; @@ -2276,6 +2483,11 @@ static NTSTATUS NTAPI Hook_NtQueryFullAttributesFile(POBJECT_ATTRIBUTES_FL oa, PVOID info) { InterlockedIncrement64(&g_nt_qfullattr); + if (g_index_ready && t_inside_redirect == 0 && oa != NULL && + oa->ObjectName != NULL && oa->RootDirectory == NULL && + !us_under_mount(oa->ObjectName)) { + return Real_NtQueryFullAttributesFile(oa, info); + } if (info != NULL && t_inside_redirect == 0) { ULONG attrs = 0; unsigned long long size = 0; @@ -2379,6 +2591,11 @@ static BOOL WINAPI Hook_GetFileAttributesExW(LPCWSTR path, LPVOID info) { InterlockedIncrement64(&g_k32_gfaexw); + if (g_index_ready && t_inside_redirect == 0 && path != NULL && + !wide_under_mount(path)) { + InterlockedIncrement64(&g_k32_passthru); + return Real_GetFileAttributesExW(path, level, info); + } int hit = try_kernel32_attr_hit(path, level, info); if (hit > 0) return TRUE; if (hit < 0) return FALSE; @@ -2391,6 +2608,11 @@ static BOOL WINAPI Hook_GetFileAttributesExA(LPCSTR path, LPVOID info) { InterlockedIncrement64(&g_k32_gfaexa); + if (g_index_ready && t_inside_redirect == 0 && path != NULL && + !ansi_under_mount(path)) { + InterlockedIncrement64(&g_k32_passthru); + return Real_GetFileAttributesExA(path, level, info); + } if (path != NULL && info != NULL && level == GetFileExInfoStandard && t_inside_redirect == 0 && g_index_ready) { int wlen = MultiByteToWideChar(CP_ACP, 0, path, -1, NULL, 0); |
