Files
arma-modlist-tools/check_names.py
revernomad17 2ab6d87532 fix check_names false positives from server steam_id collisions
When a server folder's meta.cpp publishedid appears in a local folder
that has a completely different name, the steam_id lookup was returning
a wrong MISMATCH. Added a two-pass classification: any proposed rename
target that is already an exact OK match for another folder is
reclassified as NOT_ON_SERVER (steam_id collision) instead of MISMATCH.

_resolve_status moved to module level and takes ok_disk_names as a
parameter so it can be unit-tested independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:15:30 +07:00

273 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""
Diagnostic: compare mod folder names on disk against the server's canonical
names, and optionally rename mismatched folders + fix broken junctions.
Matching strategy (most-reliable first):
1. Read meta.cpp from the local folder -> steam_id -> server by_steam_id
2. Normalize folder name (@CBA_A3 -> cbaa3) -> server by_name
Status codes:
OK disk name matches server name exactly
MISMATCH disk name differs from server canonical name
NOT_ON_SERVER server has no folder for this mod (new mod? typo?)
Usage:
python check_names.py # report only
python check_names.py --fix # rename + fix junctions
python check_names.py --group shared --fix
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from arma_modlist_tools.compat import fix_console_encoding
from arma_modlist_tools.config import load_config
from arma_modlist_tools.fetcher import (
_normalize_name, _parse_meta_cpp,
build_server_index, make_session,
)
from arma_modlist_tools.linker import _is_junction, create_junction, remove_junction
fix_console_encoding()
# Column widths
W_DISK = 44
W_SERVER = 44
W_GROUP = 24
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _server_name_from_url(url: str) -> str:
"""Extract the folder name from a trailing-slash server URL."""
return url.rstrip("/").split("/")[-1]
def _read_local_steam_id(mod_dir: Path) -> str | None:
"""Try to read publishedid from meta.cpp inside a local mod folder."""
for candidate in (mod_dir / "meta.cpp", mod_dir / "Meta.cpp"):
if candidate.exists():
try:
return _parse_meta_cpp(candidate.read_text(encoding="utf-8", errors="ignore"))
except OSError:
pass
return None
def _lookup_server_name(local_name: str, mod_dir: Path, index: dict) -> str | None:
"""
Return the server's canonical folder name for this local mod, or None.
Tries steam_id first, then normalized name.
"""
# 1. Steam ID from local meta.cpp (most reliable)
steam_id = _read_local_steam_id(mod_dir)
if steam_id:
url = index["by_steam_id"].get(steam_id)
if url:
return _server_name_from_url(url)
# 2. Normalized name fallback
url = index["by_name"].get(_normalize_name(local_name))
if url:
return _server_name_from_url(url)
return None
def _collect_mod_folders(cfg, group_filter: str | None) -> list[tuple[str, Path]]:
"""Return list of (group_name, mod_path) for every @Mod in downloads/."""
result = []
if not cfg.downloads.is_dir():
return result
for group_dir in sorted(cfg.downloads.iterdir()):
if not group_dir.is_dir():
continue
if group_filter and group_dir.name != group_filter:
continue
for mod_dir in sorted(group_dir.iterdir()):
if mod_dir.is_dir() and mod_dir.name.startswith("@"):
result.append((group_dir.name, mod_dir))
return result
# ---------------------------------------------------------------------------
# Classification helpers
# ---------------------------------------------------------------------------
def _resolve_status(
disk_name: str,
server_name: str | None,
ok_disk_names: set[str],
) -> tuple[str, str]:
"""
Return (status, display_col) for one mod, after false-positive filtering.
A MISMATCH is downgraded to NOT_ON_SERVER when the proposed server name is
already the exact disk name of another folder (steam_id collision on server).
"""
if server_name is None:
return "NOT_ON_SERVER", "---"
if server_name == disk_name:
return "OK", server_name
if server_name in ok_disk_names:
return "NOT_ON_SERVER", "--- (steam_id collision)"
return "MISMATCH", server_name
# ---------------------------------------------------------------------------
# Fix: rename folder + update junction
# ---------------------------------------------------------------------------
def _fix_mismatch(
group: str,
mod_dir: Path,
server_name: str,
cfg,
) -> tuple[bool, str]:
"""
Rename mod_dir to server_name and update the arma_dir junction.
Returns (success, message).
"""
new_dir = mod_dir.parent / server_name
if new_dir.exists():
return False, f"target already exists: {new_dir}"
# --- Remove old junction in arma_dir (if present) ---
old_link = cfg.arma_dir / mod_dir.name
removed_old_link = False
if _is_junction(old_link):
ok, err = remove_junction(old_link)
if not ok:
return False, f"could not remove old junction {old_link}: {err}"
removed_old_link = True
# --- Rename the download folder ---
try:
mod_dir.rename(new_dir)
except OSError as exc:
# Try to restore the junction we just removed
if removed_old_link:
create_junction(old_link, mod_dir)
return False, f"rename failed: {exc}"
# --- Create new junction in arma_dir ---
new_link = cfg.arma_dir / server_name
if cfg.arma_dir.exists():
if _is_junction(new_link) or new_link.exists():
pass # already correct or non-junction exists; leave it
else:
create_junction(new_link, new_dir)
return True, f"{mod_dir.name} -> {server_name}"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
cfg = load_config()
parser = argparse.ArgumentParser(
description="Check and optionally fix mod folder name mismatches."
)
parser.add_argument("--group", "-g", metavar="GROUP",
help="Only check this group (default: all)")
parser.add_argument("--fix", action="store_true",
help="Rename mismatched folders and fix junctions")
args = parser.parse_args()
# ---- Build server index ----
print(f"\nBuilding server index...")
index = build_server_index(cfg.server_url, cfg.server_auth)
print(f" {len(index['by_steam_id'])} mods indexed by steam_id, "
f"{len(index['by_name'])} by name\n")
# ---- Collect local mod folders ----
mods = _collect_mod_folders(cfg, args.group)
if not mods:
print("No @Mod folders found in downloads/.\n")
sys.exit(0)
# ---- Pass 1: raw lookup for every mod ----
# result row: (group, mod_dir, server_name | None)
raw: list[tuple[str, Path, str | None]] = []
for group, mod_dir in mods:
raw.append((group, mod_dir, _lookup_server_name(mod_dir.name, mod_dir, index)))
# ---- Pass 2: discard false positives caused by steam_id collisions ----
# If a server name is already an exact disk name (i.e. an OK match exists),
# any other folder whose steam_id lookup points to that same server name is a
# false positive — the server has a bad/shared publishedid in meta.cpp.
# Reclassify those entries as NOT_ON_SERVER.
ok_disk_names: set[str] = {
mod_dir.name
for _, mod_dir, server_name in raw
if server_name == mod_dir.name # exact match = genuinely OK
}
def _classify(disk_name: str, server_name: str | None) -> tuple[str, str]:
return _resolve_status(disk_name, server_name, ok_disk_names)
# ---- Print table ----
ok_count = mismatch_count = unknown_count = 0
mismatches: list[tuple[str, Path, str]] = []
print(f" {'Disk name':<{W_DISK}} {'Group':<{W_GROUP}} {'Server name':<{W_SERVER}} Status")
print(f" {'-'*W_DISK} {'-'*W_GROUP} {'-'*W_SERVER} ------")
for group, mod_dir, server_name in raw:
status, server_col = _classify(mod_dir.name, server_name)
if status == "OK":
ok_count += 1
elif status == "MISMATCH":
mismatch_count += 1
mismatches.append((group, mod_dir, server_name))
else:
unknown_count += 1
print(f" {mod_dir.name:<{W_DISK}} {group:<{W_GROUP}} {server_col:<{W_SERVER}} {status}")
print(f"\n {ok_count} OK, {mismatch_count} mismatch, {unknown_count} not on server\n")
if not mismatches:
if mismatch_count == 0:
print(" All folder names match the server. Safe to run full pipeline.\n")
sys.exit(0)
# ---- Fix mode ----
if not args.fix:
print(" Run with --fix to rename mismatched folders and update junctions.\n")
sys.exit(0)
print(f"Fixing {len(mismatches)} mismatch(es)...\n")
fixed = failed = 0
for group, mod_dir, server_name in mismatches:
ok, msg = _fix_mismatch(group, mod_dir, server_name, cfg)
if ok:
print(f" [+] {msg}")
fixed += 1
else:
print(f" [X] {mod_dir.name} FAILED: {msg}")
failed += 1
print(f"\n Done: {fixed} renamed"
+ (f", {failed} failed" if failed else "")
+ "\n")
if fixed:
print(" Run 'python run.py --skip-fetch' to verify links are correct.\n")
if __name__ == "__main__":
main()