Skip to content

perf: batched directory enumeration - #99

Open
Illustar0 wants to merge 70 commits into
ModOrganizer2:masterfrom
Illustar0:perf/batched-directory-enumeration
Open

Illustar0 wants to merge 70 commits into
ModOrganizer2:masterfrom
Illustar0:perf/batched-directory-enumeration

Conversation

@Illustar0

@Illustar0 Illustar0 commented Oct 17, 2026 •

Copy link
Copy Markdown

Note

Closes #95.
While the diff stat shows +5,694 / -1,128 across 65 commits, over 3,600 of the added lines are unit tests, differential tests against native ntdll, and benchmarks (ntdll.cpp itself shrinks by nearly 600 lines, and the core implementation is cleanly isolated in directory_records.{h,cpp} and directory_scan.{h,cpp}).
Every commit is atomic, builds cleanly, and passes the test suite on its own. That said, if the MO2 team would prefer to review this as a series of smaller staged PRs, I'd be happy to split it right away!

Overview

Previously, when enumerating virtual entries in a mapped directory, hook_NtQueryDirectoryFile(Ex) operated one virtual file at a time: for every virtual match, it opened the source mod directory via CreateFileW (re-entering USVFS's own open and attribute hooks under the HookContext lock), called NtQueryDirectoryFile twice, closed the handle, and returned at most one virtual record per caller buffer. During game startup with hundreds of thousands of virtual candidates, this caused a massive storm of redundant syscalls, hook re-entrancy, and lock acquisitions.

ntdll.cpp hasn't seen any fundamental architectural rework since roughly 2016, making it increasingly painful to keep piling workarounds on top of it—and the existing design itself had become a ceiling on further optimization. So I had to first "make the change easy" before making the performance improvements.

In this PR, I reimplemented the NtQueryDirectoryFile(Ex) hooks around a per-handle DirectoryScan and achieved major performance gains:

  • Normalizes both entry points into a unified DirectoryQuery.
  • RegularEntryReader synchronously reads the physical directory on disk and filters out entries shadowed by virtual ones.
  • VirtualEntryReader reads each mapping source directory at most once per scan.
  • DirectoryOutput serves as the sole writer to the caller's buffer, handling record alignment, NextEntryOffset linking, and truncation in one place.

Along the way, this PR fixes a wide range of long-standing edge cases in the hooks to match native ntdll semantics, backed by differential tests against ntdll.

Performance

Test environment: JueLun v6.1 modlist on an i5-12600K with 529 active plugins, CRDW in ACCELERATE mode (to avoid Skyrim's redundant startup directory traversals), and the latest versions of OAR, KID, and SPID. Measured via interleaved, paired A/B startup benchmarks with a warm filesystem cache after initial warm-up.

Launch Time Overview Kernel Syscall Volume
Launch Time Syscalls
Critical Path & Lock Contention Sampled CPU Attribution
Critical Path CPU Attribution
Detailed Tables
Timing Metric (median of 3 runs) Stock USVFS Batched Saved Reduction
Main-thread hook_NtQueryDirectoryFileEx inclusive time1 23.976 s 1.606 s 22.370 s 93.30%
Process creation → DataLoader thread start 41.789 s 19.214 s 22.575 s 54.02%
DataLoader thread lifetime (plugin compilation & DataLoaded) 21.126 s 14.082 s 7.044 s 33.34%
↳ DataLoader blocked in WaitForDirectoryCache (waiting on OAR) 4.411 s 0.000 s 4.411 s 100.00%
OAR directory cache worker lifetime (parallel) 21.016 s 5.739 s 15.277 s 72.69%
Process creation → Load Time Profiler main-menu report export 63.440 s 33.372 s 30.068 s 47.40%
API / Internal Operation (main thread, median of 3 runs) Stock USVFS Batched Reduction
Non-overlapping native NtQueryDirectoryFile / FileEx calls2 479,112 49,898 89.59%
Outer hook_NtQueryDirectoryFileEx calls 253,780 49,861 80.35%
Total native NtCreateFile / NtOpenFile calls3 248,955 43,359 82.58%
Hooked NtClose calls4 257,269 25,425 90.12%
Hooked GetFileAttributesW calls 469,569 5,787 98.77%
Hooked NtQueryAttributesFile calls 470,731 6,950 98.52%
HookContext::readAccess lock acquisitions 1,233,697 51,500 95.83%
Virtual directory scan constructions (VirtualEntryReader) 11,624 11,624 0%
Total virtual candidates 220,268 220,268 0%
Cumulative Native Call Time / Sampled CPU (main thread)5 Stock USVFS Batched Reduction
Native NtQueryDirectoryFile / FileEx 3.627 s 0.404 s 88.86%
Native NtCreateFile / NtOpenFile (incl. openScanDirectory) 4.586 s 1.135 s 75.25%
Native NtClose (via hook path) 2.049 s 0.216 s 89.46%
Native GetFileAttributesW 5.892 s 0.238 s 95.96%
Native NtQueryAttributesFile 10.567 s 0.257 s 97.57%
Outermost HookContext lock hold time 13.162 s 0.391 s 97.03%
Sampled CPU time under USVFS call stacks (incl. kernel & minifilters)6 28.266 CPU s 4.007 CPU s 85.82%
usvfs_x64.dll exclusive leaf CPU time6 2.245 CPU s 0.488 CPU s 78.26%

Main Breaking Changes

  • (a6a514e) Callers querying asynchronous handles, or passing an Event, ApcRoutine, or ApcContext (completion packet), will no longer see virtual entries. The legacy implementation only partially handled these and even forwarded the caller's Event and APC directly to internal reads backed by a stack-allocated IO_STATUS_BLOCK, which is undefined behavior. (Full emulation of asynchronous notifications is feasible if ever needed, though it would require additionally hooking NtSetInformationFile.) Frankly, I'm not sure whether anything in the ecosystem relies on asynchronous directory queries, but it should be an exceedingly rare pattern—only applications calling the low-level NT API directly with an asynchronous handle, Event, or APC are affected. Personally, I'd much rather make a clean breaking change than keep a broken implementation for the illusion of compatibility.

  • (6656902) Chained "virtual-to-virtual" mappings are no longer listed. Because the file-open hooks only perform a single-hop reroute, those files could never be opened in the first place, and re-entering the hooks to resolve them hurt performance.

Expected Behavior Changes

Below are the intended externally visible behavior changes beyond the main breaking changes above.
There may still be unintended behavior changes I introduced inadvertently, though the existing test suite and the new tests added here should minimize both their likelihood and their impact.

Expected Behavior Changes

Native Forwarding Rules

The following queries are now forwarded straight to ntdll; forwarded queries see only the physical directory on disk:

  • Non-directory-enumeration FileInformationClass values: return ntdll's status code instead of bogusly returning STATUS_NO_SUCH_FILE (fa76500).
  • Undersized or misaligned buffers: rejected just as native ntdll does (67c38e1).
  • SL_RETURN_ON_DISK_ENTRIES_ONLY and SL_NO_CURSOR_UPDATE (c874455):
    • FindFirstFileEx(FIND_FIRST_EX_ON_DISK_ENTRIES_ONLY) now honors the flag's contract and enumerates only the physical directory.
    • KernelBase keeps passing this flag ((flags & 4) * 2) on every subsequent FindNextFile call on that handle, so Win32 enumeration never mixes modes mid-scan.
    • Like the kernel I/O manager, SL_INDEX_SPECIFIED and undefined flag bits are ignored.

Buffer & Status Output

  • Status block (IO_STATUS_BLOCK) (ccedf5f):
    • Information is set to 0 when enumeration ends.
    • On query failure, the status block is left untouched (the stock implementation overwrote it unconditionally).
  • Records:
    • Each record starts at an 8-byte aligned offset and is linked via NextEntryOffset the same way ntdll does.
    • Information ends at the exact end of the last record, without trailing alignment padding (beb05c5).
    • Renamed records are sized to their full virtual name, without zeroing bytes past the name (06f2997).
  • Multiple virtual entries are packed into a single call until the caller's buffer is full (8e145ae).
  • Partial records:
    • Physical directory enumeration continues on subsequent calls after returning a partial record with STATUS_BUFFER_OVERFLOW (59f1d6e).
    • A virtual entry that does not fit into an empty buffer returns a partial record with STATUS_BUFFER_OVERFLOW and is repeated on subsequent calls until a buffer large enough to hold it is provided (e0bb294, 4bbf3d3).
    • A virtual entry is still listed whole when its virtual name fits the caller's buffer, even if its source file name on disk would be too long for that buffer (58ab031).
  • STATUS_NO_SUCH_FILE is returned only when the very first query on a handle yields no results. An empty result after a restart ends with STATUS_NO_MORE_FILES (d34800b).

Directory Enumeration & Matching

  • Errors from reading the physical directory are propagated to the caller rather than silently swallowed (b6f2cc3).
  • Restarting a scan:
    • SL_RESTART_SCAN also restarts the physical portion of the scan (c7f09a8).
    • A restart without FileName keeps the pattern latched by the first query, matching NTFS and ReFS (fdd30ff).
  • Case-sensitive directories list names that differ only in case as distinct entries (00977a0).
  • Stale scan state left behind under a handle value is dropped when NtOpenFile/NtCreateFile reissues that handle value (50bd6a6).
  • Wildcard pattern matching now uses RtlIsNameInExpression (cdd1a4a):
    • Supports DOS wildcards and non-ASCII case folding.
    • An empty pattern lists every entry.
    • foo no longer falsely matches .foo, and a trailing .* is no longer treated as *.
  • The physical portion of a scan always lists the real on-disk directory at the virtual path, regardless of how the caller opened the directory (fa8e67a). Previously this depended on FILE_OPEN_FOR_BACKUP_INTENT. Caller handles lacking FILE_LIST_DIRECTORY access fail just as in native ntdll.
  • Case folding matches NTFS and ReFS by folding one UTF-16 code unit at a time via RtlUpcaseUnicodeChar (dfe9dfa), no longer merging case variants outside the Basic Multilingual Plane (BMP).
  • Directories in the redirection tree without a linkTarget are left to the on-disk listing and returned in the same query batch as ntdll (96a46f5).
  • Purely virtual directories (directories that exist only in mods and not on disk, e.g., meshes) list only . and .. from the rerouted caller handle, followed by the virtual entries (87d91b6).
    • Previously, the entire directory of the first mod was listed, leaking entries that were excluded from the virtual tree: .mohidden files, skipped .git directories, and files added to the mod after mapping. Those files were listed even though they could not be opened.
  • Internal directory opens enable delete sharing (FILE_SHARE_DELETE), matching FindFirstFileExW (19887de):
    • Directories can now be renamed or deleted while being enumerated.
    • Directories already held open by another handle with DELETE access can still be enumerated from disk.

Changes Beyond Enumeration

  • Hooks & locking:
    • Handles opened internally by a scan bypass the open and close hooks (d736e6b).
    • Removed SearchHandleMap (7ad82c0). FindFirstFileExW, NtOpenFile, and NtCreateFile no longer write state to HookContext.
    • NtClose no longer acquires the HookContext lock or calls GetFileType (e4fa037).
  • Logging:
    • Logs one line per directory scan instead of one per query call (92fcba8).
    • Unmapped directories are no longer logged (454adab).
    • Logs the actual NTSTATUS when opening a mapping source directory fails, rather than an unrelated GetLastError (c4f6473).
    • Logs QueryFlags on both entry points (6fdfc61).
  • Build system:
    • Fixed Clang standard-conformance compilation errors (e52de37).
    • Added Google Benchmark 1.9.5 under the testing feature in vcpkg.json for a benchmark target not run by default under ctest (53695da).

Known Limitations & Trade-offs

  • Status block on ReFS when no files match:
    • When no files match, ReFS writes {STATUS_NO_SUCH_FILE, 0} to the status block, whereas NTFS leaves it untouched; the hook follows NTFS.
    • As a result, the status block on ReFS differs from native ntdll. The stock implementation wrote the status block unconditionally, matching ReFS but diverging from NTFS.
    • Differential tests compare only the returned NTSTATUS on failed queries.
  • Physical directory renamed or deleted before the first query:
    • Whether the scan falls back to "dot entries (. and ..) only" depends on why opening the physical directory at the virtual path failed.
    • If the caller's handle itself points to the physical directory (NtCreateFile does not reroute when the path is a directory both on disk and in the virtual tree) and that directory is renamed or marked for deletion before the first query, the hook lists only ., .., and virtual entries, whereas ntdll would still list the entire physical directory.
    • Enabling FILE_SHARE_DELETE in 19887de makes such renames easier to trigger. This case is not currently covered by tests.
  • Mixing forwarded and virtualized queries on the same handle:
    • If a low-level NT caller alternates between the two on the same handle (e.g., passing an Event on one call and omitting it on the next, or toggling flags mid-scan), the first virtualized call will start a fresh scan, which may return physical directory entries a second time.
    • This cannot happen through FindFirstFileEx/FindNextFile.
  • When a scan cannot open an existing physical directory at the virtual path for other reasons (such as an ACL denying directory listing), it falls back to reading the caller's handle in full. If that handle was opened via hook_NtOpenFile, it points to the first mapped mod directory—matching what the stock implementation listed.
  • Shadowing remains case-insensitive even in case-sensitive directories: a virtual a.txt hides both a.txt and A.txt from the physical directory, consistent with how the redirection tree resolves names.
  • Virtual entries carry no 8.3 short name, as on a volume with 8.3 names disabled (c5f7ef2). Previously they carried the short name of their source file, which is unique only in the source directory, so GetShortPathNameW could build paths that did not open or that led to another file.
  • Unhandled exceptions inside the hook still terminate enumeration with STATUS_NO_MORE_FILES, as in the stock implementation.
  • For mapped directories containing a large number of physical files, the physical portion takes roughly 1.16–1.52× native time due to filtering and copying overhead. Reading ahead into a larger buffer would improve throughput, but introduces cursor and information-class hazards that aren't worth the trade-off right now.
  • Verified only on NTFS and ReFS. (Though honestly, does anyone actually play Skyrim on ReFS or another filesystem?)

Verification

cmake --preset vs2022-windows-x64
cmake --build --preset vs2022-windows-x64 --config Release
cmake --preset vs2022-windows-x86
cmake --build --preset vs2022-windows-x86 --config Release

ctest --test-dir vsbuild64 -C Release --output-on-failure

Both unit and differential tests for this PR live in tvfs_test, so you can also run tvfs_test directly.
The differential tests run against C:\Windows and generated directories on the volume hosting the test binary. To cover ReFS, clone and run the repository on a ReFS volume.

Benchmarks (Optional)

cmake --build --preset vs2022-windows-x64 --config Release --target tvfs_directory_bench
.\test\bin\tvfs_directory_bench_x64.exe

Compares NativeListing / VirtualListing / UnmappedListing across mods ∈ {1, 8} and files ∈ {64, 512}. Use --benchmark_filter= to filter specific cases.

Notes for Reviewers

  • Suggested reading order:

    • Although the overall diff is large, the vast majority of added lines are unit and differential tests in test/tvfs_test/directory_enumeration.cpp. The core production code lives in directory_records.{h,cpp} (record layout parsing and DirectoryOutput buffer writing) and directory_scan.{h,cpp} (RegularEntryReader, VirtualEntryReader, and DirectoryScan state management)—you may find it easiest to read the final state of those two pairs of files directly.
  • Reviewing commit-by-commit: every commit builds cleanly and passes the test suite on its own, structured around "First make the change easy, then make the easy change":

    1. e52de37 – 6656902: Split directory enumeration out of ntdll.cpp and establish the DirectoryScan / DirectoryOutput skeleton, while fixing UB, resource leaks, dropped partial records, and hook re-entrancy when opening source directories that stood in the way of batching.
    2. 53695da – bc60b58 (plus 097ebff, a41b9a0, etc.): Implement the core batched reading optimizations.
    3. cdd1a4a – 3c2fa5d: Align remaining ntdll semantics orthogonal to batching (pattern matching and latching, SL_* flags, physical directory source, dot entries for purely virtual directories, FILE_SHARE_DELETE, etc.), along with follow-up performance work around the HookContext lock, SearchHandleMap, NtClose, and the name hash set.
  • Commits labeled refactor that also change externally visible details:

    • beb05c5: Drops trailing alignment padding after the last record.
    • 06f2997: Sizes renamed records to the virtual name and stops zeroing bytes past it.
    • 92fcba8: Logs once per directory scan instead of once per query call.
    • 7ad82c0: Removes SearchHandleMap along with the associated lock acquisition and logging in NtClose.
    • All other refactor commits are purely structural and should not change external behavior.

Footnotes

  1. Inclusive wall-clock time on the main thread inside usvfs::hook_NtQueryDirectoryFileEx prior to DataLoader thread creation. Because inner hook/native calls and HookContext lock hold times nest and overlap, rows cannot be summed directly. ↩

  2. Counts non-overlapping low-level Original_NtQueryDirectoryFile / FileEx call sites (including forwardToNative), avoiding double-counting across outer addNtSearchData spans. ↩

  3. Combines native ntdll_mess_NtCreateFile / NtOpenFile calls with the 26,294 internal openScanDirectory NtOpenFile calls added by the batched reader (which bypass outer hooks via InternalIoScope). ↩

  4. Reflects only calls going through usvfs::hook_NtClose; scan directory handles opened internally on the batched path are closed directly under InternalIoScope. ↩

  5. Native call times and lock hold durations nest and overlap, so rows cannot be summed directly. ↩

  6. Exact ETW SampledProfile CPU sample weights from two lossless paired runs (n=2 per group). "USVFS call stacks" includes kernel (ntoskrnl.exe, Ntfs.sys, FltMgr.sys) and filter driver work (e.g., AV minifilter sysdiag.sys) triggered by USVFS hooks; "exclusive leaf CPU" counts only instructions executed directly inside usvfs_x64.dll. ↩ ↩2

- Add missing typename keywords to dependent type constructors (DataT, NodePtrT, DeleterT, WeakPtrT) in TreeContainer.
- Disambiguate UnicodeString assignment from LPCWSTR in applyReroute by constructing UnicodeString explicitly.
- Cast unsigned hex status literals (0xC0000022, 0xC0000035) to signed NTSTATUS in std::formatter<usvfs::log::Wrap<NTSTATUS>> switch cases to avoid narrowing conversions.
- Remove quotes around pch.h in the /FI compile option in shared/CMakeLists.txt.
- Add DirectoryQuery to normalize both entry points, folding the legacy ReturnSingleEntry and RestartScan arguments into SL_* flags.
- Add queryNative to forward a query through its original entry point.
- Move the duplicated query body into queryDirectory; both hooks now only build a DirectoryQuery and route it.
- Log QueryFlags for both entry points while keeping the hook name in the log.
- Move HandleTracker into handle_tracker.h and declare ntdllHandleTracker extern so other hook sources can use it.
- Move DirectoryQuery, queryNative, queryDirectory and the search helpers into directory_scan.h/.cpp without changing their logic.
- Add eraseDirectorySearch so NtClose no longer touches Searches directly, keeping the SearchInfo data ID in a single translation unit.
- Drop includes from ntdll.cpp that only the moved code needed.
- Move the NtQueryDirectoryFile(Ex) hook tests out of tvfs_test/main.cpp into a dedicated directory_enumeration.cpp.
- Share REAL_FILEA/W, defaultUsvfsParams, the USVFSTest fixture and hooked_NtOpenFile through tvfs_test.h, made inline for both sources.
- Pass FileName->Length to string_cast when building the virtual search pattern, instead of reading the buffer up to the next NUL.
- Add a test querying a virtual file through a pattern whose buffer continues past Length.
- Add shouldVirtualize so queryDirectory only virtualizes the directory information classes whose records carry a FileName.
- Forward every other class to ntdll, which reports its own status instead of STATUS_NO_SUCH_FILE.
- Add a test comparing the hook with a direct ntdll call for a non-directory information class.
- Replace the Searches custom data with a DirectoryScans registry mapping handles to shared DirectoryScan objects under its own shared_mutex.
- Rename Searches::Info to DirectoryScan and drop the SearchInfo data ID along with the unused query mutex.
- Erase scans in NtClose outside the hook context lock, keeping the manual handle close where it was.
- Move the first-query block (SearchHandleMap lookup, reopen and gatherVirtualEntries) into the DirectoryScan constructor.
- Add DirectoryScans::scanFor, which constructs scans outside the registry lock and swaps in a new scan on restart instead of erasing the old one first.
- Track the first call in DirectoryScan::isFirstCall instead of inferring it from a missing registry entry.
- Move the regular and virtual phases and the status computation from queryDirectory into DirectoryScan::query.
- Reduce queryDirectory to shouldVirtualize followed by scanFor and query.
- Make the scan state private and turn gatherVirtualEntries and addVirtualSearchResult into private members.
- Add DirectoryScan::closeSearchHandle for NtClose and the end of the regular phase.
- Drop the dead searchPattern field.
- Move the regular and virtual phases into DirectoryScan::fill, which returns STATUS_SUCCESS when it wrote entries and STATUS_NO_MORE_FILES once both phases are exhausted.
- Keep the first-call status mapping, the status block and the logging in DirectoryScan::query.
- Turn gatherVirtualEntries back into a free function that returns a vector of VirtualMatch without touching scan state.
- Move VirtualMatch out of DirectoryScan to file scope.
- Fill the shadowed names from the returned matches in the DirectoryScan constructor.
- Replace the virtual match queue with a vector and a cursor.
- Add openRegularSource, which reopens the search path stored for a handle and returns INVALID_HANDLE_VALUE when there is none or the open fails.
- Look up SearchHandleMap under the single context lock, since the stored path also names the virtual directory.
- Call CreateFileW after releasing the context lock, as the last step of the DirectoryScan constructor.
- Add CaseInsensitiveNameSet with insert and clear, still backed by a std::set of to_upper keys.
- Use it for the found names of DirectoryScan and in addNtSearchData instead of uppercasing at each call site.
- Add OwnedHandle, a move-only handle that closes itself when destroyed, and use it for the scan's private directory handle.
- Close the handle of a scan replaced by a restart or dropped by NtClose when the scan is destroyed instead of leaking it.
- Remove the manual closes, including the one on INVALID_HANDLE_VALUE after a failed open.
- Move the handle validity check out of HandleTracker into the free function isValidHandle.
- Add a test that restarting a query keeps the process handle count unchanged.
- Guard the state of a DirectoryScan with a mutex held for the whole query, as ntdll serializes queries on a synchronous handle.
- Add a test that queries one handle from eight threads at once and expects every entry exactly once.
- Leave queries that ask for an event, an APC or a completion packet to ntdll, since the virtual listing completes them itself and delivers none of these notifications.
- Forward every query on an asynchronous handle to ntdll through a passthrough scan, decided once per scan from FileModeInformation.
- Stop handing the caller's event and APC to the internal directory reads, which notified the caller of the wrong read and could complete into a gone stack frame.
- Note in a TODO what a full emulation of the completion would take.
- Add tests that compare the event, APC and completion packet notifications of every query with those ntdll delivers.
- Report no bytes once a listing has ended, instead of the buffer length after the last entry.
- Leave the status block untouched when the first query finds nothing, as ntdll does for failed queries.
- Add tests that compare the status block of both cases with what ntdll writes.
- Leave queries whose buffer cannot hold a record, or is misaligned, to ntdll, which rejects them before reading the directory.
- Describe the record size and alignment of each directory information class in a DirectoryRecordLayout table that replaces the switch in shouldVirtualize.
- Require the alignment of the record on x64, 4 bytes for the Ex entry point under WOW64, and none for the legacy one, which wow64.dll realigns.
- Add tests that compare the result for undersized and misaligned buffers with what ntdll returns.
- Record the offsets of FileNameLength and FileName in the layout of each directory information class.
- Add DirectoryRecord, which reads the fields of a record in place and reports a record cut short by the end of the data as truncated.
- Add unit tests on synthetic records of every class, and check the names it reads against those FindFirstFileW lists on the system volume and on the volume the tests run from.
- Read the records in addNtSearchData through DirectoryRecord, and rename them and end their list with local helpers that keep the stock behaviour.
- Record where the 8.3 name sits in the layout of the classes that carry one.
- Delete file_information_utils.h, which nothing else used.
- Add DirectoryOutput, which lays out records 8 bytes apart and links them like ntdll.
- Report the size up to the end of the last record, without trailing padding.
- Fit a record after the first by its unpadded size, as NTFS and ReFS do.
- Keep the stock short-name rewrite in appendRenamed until 8.3 names are dropped.
- Turn the fill status into the query result and write the status block unless it is an error.
- Check the layout and the completions against native listings on the system volume and the test volume.
- Generalize the native listing helpers to list through any query function.
- List directories without virtual entries through ntdll and the hook in every class, and compare the result, status block and records of each query.
- Add RegularEntryReader, which reads regular entries into a staging area and writes those no virtual entry shadows.
- Hold it in an optional whose reset ends the regular phase and releases its handle and staging area.
- End the data of a query with its last regular record, without the padding after it.
- Keep the stock behavior of ending the regular phase on any result but success.
- Check that a query whose shadowed records follow ".." ends with "..".
- Compare the virtual file name in NtQueryDirectoryFileFindsVirtualFile by FileNameLength, since FileName is not terminated.
- Add VirtualEntryReader, which reads each virtual entry from the directory its file lies in and writes it under its virtual name.
- Open that directory for a single native read per entry and close it right after.
- Write the status block through DirectoryOutput::complete and remove addNtSearchData and addVirtualSearchResult.
- Report a renamed record up to the end of its virtual name.
- Check a virtual entry against the record ntdll lists for its file.
- Log one line per scan with the virtual path, the pattern, the regular source and the number of virtual entries.
- Drop the per-query log and the remainingCount it needed.
- Add DirectoryOutput::mirror, which writes the data of a native read as it is.
- Pass on the partial first record ntdll returns with STATUS_BUFFER_OVERFLOW instead of ending the regular entries.
- Check against ntdll that a listing goes on past a partial record.
- Write as much of a record as fits an empty output, with its full FileNameLength.
- Report the partial record of a virtual entry with STATUS_BUFFER_OVERFLOW.
- Return the partial record of a virtual entry again until a buffer holds it.
- Record the name of a virtual entry only once it is written whole.
- List a virtual entry whose file has a name too long for the buffer.
- Cut a virtual entry short when neither name fits the buffer.
- Match the names of virtual entries as the filesystem matches its own, DOS wildcards and case beyond ASCII included.
- List every virtual entry for an empty pattern, as ntdll lists every file for one.
- Stop ignoring a leading dot of a name or a trailing ".*" of a pattern.
- Keep the source directories of a scan in a deque, which grows without copying them as a vector of them did.
…twice

- Take the file name of a mapped path as UTF-16 directly, rather than convert it to UTF-8 and back.
- Latch the pattern per scan and keep it on a restart whose FileName is null or empty, as NTFS and ReFS do.
- Gather the virtual entries with the latched pattern instead of the pattern of the restarting query.
- Read the regular entries with the latched pattern, so that a reopened source directory keeps it too.
- Add a differential test against a namesake directory, for queried and reopened handles.
- Mask the query flags with SL_QUERY_DIRECTORY_MASK as the I/O manager does, so SL_INDEX_SPECIFIED and undefined bits are ignored.
- Leave queries with SL_RETURN_ON_DISK_ENTRIES_ONLY or SL_NO_CURSOR_UPDATE to ntdll, which lists only the physical directory.
- Update SL_QUERY_DIRECTORY_MASK to the WDK value 0x1b and declare SL_NO_CURSOR_UPDATE.
- Add a differential test of the query flags against ntdll and a namesake directory.
- Pass the NtOpenFile and NtClose calls of a directory scan straight to ntdll, so its handles are neither rerouted nor tracked and closing them takes no context lock.
- Open scan directories with NtOpenFile for listing only, instead of CreateFileW.
- Reopen the stored search path through the same function, without the hooks.
- Read the regular entries from the physical directory at the virtual path whenever the redirection tree maps entries into it, however the caller opened it.
- Read the caller's handle when it lacks FILE_LIST_DIRECTORY, so the query fails as ntdll fails it.
- Stop reading SearchHandleMap in the directory scan.
- Add a differential test for a directory a mapping source is linked onto.
- Stop storing the search path of directory handles in the NtOpenFile and NtCreateFile hooks, as the directory scan no longer reads it.
- Stop storing the find handle of FindFirstFileExW, which no query ever looked up and FindClose never removed.
- Stop erasing the map in NtClose, which no longer takes the hook context lock.
- Remove SearchHandleMap, SearchHandles and sharedids.h.
- Fold shadowed and deduplicated names one UTF-16 code unit at a time with RtlUpcaseUnicodeChar, as NTFS and ReFS do.
- Stop merging names that differ in the case of a character beyond the BMP, which LCMapStringW folded.
- Remove ush::to_upper, which has no callers left.
- Add a test that checks which names are one against ntdll.
- Add UnmappedListing, which lists a directory outside the redirection tree through the hooks while mods are mapped elsewhere.
- Share the mod mapping and the hook functions between the hooked benchmarks.
- Make a scan pass its queries through when the redirection tree does not hold the directory.
- Skip opening a regular source and building the shadow set for such directories.
- Stop logging scans of such directories, as master did not log them either.
- Copy the listable children of a directory out of the redirection tree as UTF-8 while holding the hook context.
- Convert and match the names after releasing it, which halves how long a scan holds the context.
- Take the hook context for reading, so that a scan takes the shared side once read and write access are split.
- Skip tree children without a link target when gathering virtual entries, as they map nothing.
- List what of them exists on disk with the regular entries, in the same query as ntdll does.
- Add a test that lists such children against ntdll.
- Drop the directory scan and the tracked path of a closed handle without asking for its file type, which took two system calls on every close.
- Look a closed handle up under the shared lock of the handle tracker before taking it exclusively.
- Drop the scan of a handle that is not on disk, such as the root of the named pipes, when it is closed.
- Add filler virtual names to the name folding test, so that the name sets meet case variants with more than a few buckets.
- List the pair that differs in the case of a Latin-1 letter on its own, so that no name of that listing lies beyond U+00FF.
- Fold names while hashing and comparing them, so that looking a name up allocates nothing.
- Fold ASCII inline and leave the other code units to RtlUpcaseUnicodeChar.
- Read the names of records in usvfs's own buffers in place rather than copying them.
- Gather the shadowed names only once a regular entry other than "." and ".." comes.
- Build the set that deduplicates virtual entries only when a virtual name is empty or not all ASCII.
- Rely on the redirection tree, which folds the ASCII letters as RtlUpcaseUnicodeChar does, to keep names that are all ASCII apart.
- Note what reading ahead in larger reads would save and why the regular reader reads no more than the caller's buffer.
- Stop listing entries of the first mod's directory that the tree leaves out, such as skipped .mohidden files.
- Read only "." and ".." from the rerouted caller handle when nothing lies at the virtual path on disk.
- Treat a missing path, a file at the path and a directory pending deletion alike.
- Recognize ".." when the smallest FileBothDirectoryInformation buffer cuts it short.
- Return the open status from openScanDirectory and add DirectoryRecord::fileNameLength.
- Open directories for the scan sharing delete access, as FindFirstFileExW does.
- Let others rename or delete a directory while a scan holds it open.
- Read the physical directory when another handle holds it with DELETE access.
- Test renaming a listed directory and listing one held for deletion against ntdll.
- Fail the open by denying listing in the ACL, as a DELETE holder no longer does.
- Log the status of the failed open instead of GetLastError, which NtOpenFile does not set.
- Move the loop that writes the listed records of a read out of RegularEntryReader::fill.
- Spell out the presence check of the last record offset, as offset 0 is a valid value.
@Illustar0
Illustar0 marked this pull request as ready for review October 17, 2026 11:05
- Zero the 8.3 fields of every virtual entry, as NTFS and ReFS do for a record without a short name.
- Drop the stock GetShortPathNameW call, which resolved the virtual name against the current directory.
- Write virtual entries through appendRenamed even when the tree gives no virtual name.
- Add tests for renamed records of the five Both classes and for a mapped C:\Program Files.
- Remove NtQueryDirectoryFileRegularFile, which ListsRegularEntriesLikeNtdll covers byte by byte.
- Remove NtQueryDirectoryFileFindsVirtualFile, which ListsAVirtualEntryLikeNtdllListsItsFile covers for every class.
- Remove NtQueryDirectoryFileExVirtualFile, as ExHandlesQueryFlagsLikeNtdll and RejectsMisalignedBuffersLikeNtdll list virtual entries through the Ex entry point.
- Drop NtQueryDirectoryFileListsSeveralVirtualEntriesPerQueryLikeNtdll, whose single-source listings ListsEntriesOfSeveralSourcesLikeNtdll repeats.
- List the several sources for a single entry too, which only the dropped test did.
- Count hook contexts in HookContext::generation, which the hooks read without the lock.
- Rebuild a scan on its next query once the context that built it is replaced, as the stock code dropped the scans it kept in the context.
- Take over the pattern of the outdated scan and leave the native cursor of the caller's handle where it is.
- Add a test that lists the mappings of a context replacing another.
- Forward the queries of a directory outside the redirection tree before asking for the mode of its handle, which saves the scan of an unmapped directory a NtQueryInformationFile call.
- Log as debug why a query on a mapped directory goes straight to ntdll.
- Log as info a mapped directory that an asynchronous handle lists without its virtual entries.
- Log as warn a directory on disk that a scan cannot open and lists through the caller's handle instead.
- Log as info a scan rebuilt for a new hook context, and as debug a failed read of the entries on disk.
- Add the handle, information class and query flags to the line each scan logs.
- Name the mapped file or source directory in the warnings about virtual entries that cannot be read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Batched Directory Enumeration

1 participant