Строчные ссылки на файлы модов и своё ожидание готовности
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком, вместе с именем папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды» раскладывает рядом строчные симлинки: файлы модов не меняются, повторный запуск после докачки безопасен, а на файловых системах без различия регистра кнопка честно говорит, что делать нечего. Ожидание строки о готовности стало настройкой профиля со значением по умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати не хватает, и панель объявляла бы работающим сервер, который ещё никого не пустит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// 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("…дальше молча, итог будет в конце")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user