@@ -186,6 +186,15 @@ def deploy_filemap(
186186 # {custom_deploy_dir_str: {top_level_folder_name, ...}} — populated as we
187187 # build tasks, consumed by the folder-replace pass below.
188188 _custom_top_roots : dict [str , set [str ]] = {}
189+ # {(custom_deploy_dir_str, top_folder_lower): owner_mod_name or None} —
190+ # records which mod owns each top-level folder. None means multiple mods
191+ # contribute to it (so we fall back to per-file deploy instead of a
192+ # single directory symlink). Folders ending up with exactly one owner are
193+ # candidates for the directory-symlink optimization.
194+ _top_folder_owner : dict [tuple [str , str ], str | None ] = {}
195+ # {(custom_deploy_dir_str, top_folder_lower): one (src_str, rel_str) pair}
196+ # — used to derive the source-side top-level folder path for the symlink.
197+ _top_folder_sample : dict [tuple [str , str ], tuple [str , str ]] = {}
189198 for line in _tab_lines :
190199 rel_str , mod_name = line .split ("\t " , 1 )
191200 # Guard against path traversal in filemap entries.
@@ -244,7 +253,15 @@ def deploy_filemap(
244253 # their top-level folders are merged with the target instead of
245254 # wholesale-replaced; per-file backup-and-replace still applies.
246255 if is_custom_task and "/" in rel_str and mod_name not in _per_merge :
247- _custom_top_roots .setdefault (_eff_s , set ()).add (rel_str .split ("/" , 1 )[0 ])
256+ _top = rel_str .split ("/" , 1 )[0 ]
257+ _custom_top_roots .setdefault (_eff_s , set ()).add (_top )
258+ _key = (_eff_s , _top .lower ())
259+ _existing_owner = _top_folder_owner .get (_key , "__unset__" )
260+ if _existing_owner == "__unset__" :
261+ _top_folder_owner [_key ] = mod_name
262+ _top_folder_sample [_key ] = (src_str , rel_str )
263+ elif _existing_owner != mod_name :
264+ _top_folder_owner [_key ] = None # multi-owner → no dir-symlink
248265
249266 if progress_fn is not None and line_idx % 500 == 0 :
250267 progress_fn (line_idx , total_lines )
@@ -309,6 +326,88 @@ def deploy_filemap(
309326 _resolved_dir_cache .pop (_existing_dir_str .lower (), None )
310327 _dir_listing_cache .pop (os .path .dirname (_existing_dir_str ), None )
311328
329+ # Directory-symlink pass: for every single-owner top-level folder we just
330+ # replaced above, drop a directory symlink <dest>/<top> → <staging>/<mod>/<src_top>.
331+ # New files written by the game land directly in the mod's staging dir
332+ # (no manual sync on restore needed). Tasks that fall under one of these
333+ # symlinked folders are excluded from the per-file deploy below.
334+ _dir_symlink_log : list [str ] = []
335+ _skipped_task_prefixes : set [str ] = set ()
336+ for (_eff_dir_s , _top_lower ), _owner in _top_folder_owner .items ():
337+ if _owner is None :
338+ continue
339+ _sample = _top_folder_sample .get ((_eff_dir_s , _top_lower ))
340+ if _sample is None :
341+ continue
342+ _sample_src , _sample_rel = _sample
343+ # Derive the source-side folder path: rel_str is e.g. "Saves/foo.ess"
344+ # and src_str ends in "<staging>/<mod>/<resolved>/Saves/foo.ess" (the
345+ # resolved part may include strip_prefix folders). Walk parents of
346+ # src_str up by the number of "/" components in rel_str minus one to
347+ # land on the source-side top-level folder.
348+ _rel_depth = _sample_rel .count ("/" ) # files-deep below the top folder
349+ _src_top = _sample_src
350+ for _ in range (_rel_depth ):
351+ _src_top = os .path .dirname (_src_top )
352+ if not os .path .isdir (_src_top ):
353+ # Couldn't resolve a real source directory — fall back to per-file.
354+ continue
355+ # Destination path uses the same case-resolved form as per-file deploy.
356+ _dst_top = _resolve_root_path_str (
357+ _eff_dir_s , _sample_rel .split ("/" , 1 )[0 ], _dir_listing_cache ,
358+ resolved_dir_cache = _resolved_dir_cache ,
359+ )
360+ # The wholesale-replace pass above moved any vanilla folder away, so
361+ # the dest path should not exist; create the parent dir, then symlink.
362+ try :
363+ os .makedirs (os .path .dirname (_dst_top ), exist_ok = True )
364+ # Defensive: drop a leftover empty dir or stale symlink at the spot
365+ try :
366+ _existing_st = os .lstat (_dst_top )
367+ if _stat_module .S_ISLNK (_existing_st .st_mode ):
368+ os .unlink (_dst_top )
369+ elif _stat_module .S_ISDIR (_existing_st .st_mode ):
370+ try :
371+ os .rmdir (_dst_top )
372+ except OSError :
373+ # Non-empty — bail; per-file deploy will handle it.
374+ continue
375+ except OSError :
376+ pass
377+ os .symlink (_src_top , _dst_top )
378+ _dir_symlink_log .append (_dst_top )
379+ _skipped_task_prefixes .add (_dst_top .rstrip ("/" ) + "/" )
380+ _log (f" Symlinked folder { os .path .basename (_dst_top )} / → { _src_top } " )
381+ except OSError as exc :
382+ _log (f" WARN: could not symlink folder { _dst_top } : { exc } " )
383+
384+ # Filter out tasks whose destination falls under a directory-symlinked
385+ # folder — they're already covered by the symlink.
386+ if _skipped_task_prefixes :
387+ def _under_symlinked (dst : str ) -> bool :
388+ for _pfx in _skipped_task_prefixes :
389+ if dst .startswith (_pfx ):
390+ return True
391+ return False
392+ before_count = len (tasks )
393+ tasks = [t for t in tasks if not _under_symlinked (t [1 ])]
394+ # Mark every skipped rel_lower as "placed" so deploy_core() doesn't
395+ # try to provide a vanilla fallback for these paths.
396+ for _line in _tab_lines :
397+ if "\t " not in _line :
398+ continue
399+ _rs , _mn = _line .rstrip ("\n " ).split ("\t " , 1 )
400+ _dst_check = _resolve_root_path_str (
401+ str (_per_deploy .get (_mn , deploy_dir )) if _mn in _per_deploy else _deploy_dir_str ,
402+ _rs , _dir_listing_cache , resolved_dir_cache = _resolved_dir_cache ,
403+ )
404+ if _under_symlinked (_dst_check ):
405+ placed_lower .add (_rs .lower ())
406+ print (f" [TIMER] deploy_filemap — directory-symlink pass: skipped "
407+ f"{ before_count - len (tasks )} per-file task(s) under "
408+ f"{ len (_skipped_task_prefixes )} folder symlink(s)." )
409+ total = len (tasks )
410+
312411 # Pre-create all destination directories up front (single-threaded) to
313412 # avoid mkdir races inside the thread pool.
314413 with _timer ("deploy_filemap — mkdir" ):
@@ -370,12 +469,14 @@ def _do_transfer(item: tuple[str, str, str, bool, bool, "LinkMode | None"]) -> t
370469 print (f" [TIMER] deploy_filemap — transfer { total } files: { _time .perf_counter () - _t_transfer :.3f} s" )
371470
372471 # Write a log of files placed in custom locations so cleanup knows what to
373- # remove. Each line is the absolute path of a deployed file.
472+ # remove. Each line is the absolute path of a deployed file (or a
473+ # directory symlink we created via the dir-symlink pass).
374474 custom_deployed = [
375475 dst
376476 for _src , dst , rel_lower , is_custom , _use_sym , _ov in tasks
377477 if is_custom and rel_lower in placed_lower
378478 ]
479+ custom_deployed .extend (_dir_symlink_log )
379480 try :
380481 if custom_deployed :
381482 _custom_log_path .write_text ("\n " .join (custom_deployed ), encoding = "utf-8" )
0 commit comments