// Package casefix лечит расхождение в регистре имён у модов мастерской. // // Project Zomboid приводит путь к вложенному файлу анимаций к нижнему регистру // целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux // файл просто не находится, и сервер сыплет в лог сотнями исключений вида // // AnimNode.Parse> ... loadshotgun_hb.xml (No such file or directory) // // при том, что рядом лежит LoadShotgun_HB.xml. Переименование не помогает: // ссылки внутри модов написаны в разном регистре, и в нижний уходит весь путь. // Поэтому рядом с каждым именем, где есть заглавные буквы, кладём симлинк на // него же строчными буквами. Файлы модов при этом не трогаются: операция // только добавляет имена и никогда не переписывает существующие. package casefix import ( "context" "fmt" "os" "path/filepath" "strings" ) // pzAppID — раздел мастерской, в который Steam кладёт моды Project Zomboid. const pzAppID = "108600" // maxDepth ограничивает обход: дерево мода мельче, а бесконечно глубокое // означает, что мы куда-то не туда попали. const maxDepth = 24 // logSamples — сколько первых ссылок показать в консоли. Их бывают тысячи, и // сплошной список вытеснит из буфера всё остальное. const logSamples = 20 // Result — что получилось. Existing считает уже готовые ссылки: повторный // запуск после докачки модов должен быть дешёвым и безопасным. type Result struct { Roots []string `json:"roots"` Scanned int `json:"scanned"` Created int `json:"created"` Existing int `json:"existing"` Failed int `json:"failed"` // CaseInsensitive — файловая система сама не различает регистр, и ссылки // не нужны. Так ведут себя Windows и macOS: там этой беды просто нет. CaseInsensitive bool `json:"case_insensitive"` } // Apply обходит папки мастерской внутри roots и раскладывает строчные ссылки. func Apply(ctx context.Context, roots []string, log func(string)) (Result, error) { if log == nil { log = func(string) {} } var result Result var firstErr error for _, root := range roots { if root == "" { continue } content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID) if info, err := os.Stat(content); err != nil || !info.IsDir() { continue } result.Roots = append(result.Roots, content) // Проверяем до обхода: на нечувствительной к регистру ФС каждое имя // «уже существует», и обход насчитал бы тысячи готовых ссылок там, где // их нет вовсе. if caseInsensitive(content) { result.CaseInsensitive = true log(content + ": файловая система не различает регистр, ссылки не нужны") continue } log("Просматриваю " + content) if err := walk(ctx, content, 0, &result, log, &firstErr); err != nil { return result, err } } if len(result.Roots) == 0 { return result, fmt.Errorf("папка модов мастерской не найдена: сервер ещё не скачивал моды") } if result.CaseInsensitive && result.Scanned == 0 { return result, nil } // Ни одной ссылки не создано, зато все попытки провалились — значит дело // не в отдельном файле, а в системе: нет прав или ФС не умеет симлинки. if result.Created == 0 && result.Failed > 0 && firstErr != nil { return result, fmt.Errorf("не удалось создать ни одной ссылки: %w", firstErr) } return result, nil } // caseInsensitive выясняет опытом, различает ли файловая система регистр: // кладёт временный файл с заглавными буквами и ищет его строчными. Пробовать // надо именно в папке модов — она может лежать на отдельном разделе. func caseInsensitive(dir string) bool { probe, err := os.CreateTemp(dir, "PZMcase*.tmp") if err != nil { // Права на запись всё равно нужны для ссылок: пусть обход сам // расскажет, чего именно не хватает. return false } name := probe.Name() _ = probe.Close() defer func() { _ = os.Remove(name) }() _, err = os.Lstat(filepath.Join(filepath.Dir(name), strings.ToLower(filepath.Base(name)))) return err == nil } // walk обходит одну директорию. По симлинкам не спускаемся: и чтобы не ходить // по только что созданным ссылкам, и чтобы не зациклиться на чужих. func walk(ctx context.Context, dir string, depth int, result *Result, log func(string), firstErr *error) error { if depth > maxDepth { return nil } if err := ctx.Err(); err != nil { return err } entries, err := os.ReadDir(dir) if err != nil { // Нечитаемая папка — повод сказать об этом, но не бросать весь обход. log("Пропускаю " + dir + ": " + err.Error()) return nil } for _, entry := range entries { result.Scanned++ name := entry.Name() lower := strings.ToLower(name) if lower != name { link(dir, name, lower, result, log, firstErr) } // entry.IsDir() у симлинка на папку даёт false: ReadDir не разыменовывает. if entry.IsDir() { if err := walk(ctx, filepath.Join(dir, name), depth+1, result, log, firstErr); err != nil { return err } } } return nil } // link кладёт рядом с name ссылку на него же строчными буквами. func link(dir, name, lower string, result *Result, log func(string), firstErr *error) { path := filepath.Join(dir, lower) // Lstat, а не Stat: битая ссылка тоже занимает имя, и создавать поверх неё // нечего. if _, err := os.Lstat(path); err == nil { result.Existing++ return } // Цель относительная — ссылка переживёт переезд папки сервера. if err := os.Symlink(name, path); err != nil { result.Failed++ if *firstErr == nil { *firstErr = err } if result.Failed <= logSamples { log("Не удалось создать ссылку " + path + ": " + err.Error()) } return } result.Created++ if result.Created <= logSamples { log(lower + " → " + name) } if result.Created == logSamples+1 { log("…дальше молча, итог будет в конце") } }