Строчные ссылки на файлы модов и своё ожидание готовности
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком, вместе с именем папки мода. На 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("…дальше молча, итог будет в конце")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package casefix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// content собирает дерево мастерской с файлами в разном регистре.
|
||||
func content(t *testing.T) (root, dir string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
dir = filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
|
||||
"3722134990", "mods", "GunsOfMarz", "42.16", "media", "AnimSets")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"LoadShotgun_HB.xml", "aim_down.xml"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("<anim/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root, dir
|
||||
}
|
||||
|
||||
// Проверять создание ссылок есть смысл только там, где регистр вообще
|
||||
// различается: на Windows и macOS чинить нечего.
|
||||
func skipCaseInsensitive(t *testing.T, result Result) {
|
||||
t.Helper()
|
||||
if result.CaseInsensitive {
|
||||
t.Skip("файловая система не различает регистр")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLinksUppercaseNames(t *testing.T) {
|
||||
root, dir := content(t)
|
||||
|
||||
result, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skipCaseInsensitive(t, result)
|
||||
if result.Created == 0 {
|
||||
t.Fatalf("не создано ни одной ссылки: %+v", result)
|
||||
}
|
||||
|
||||
// Ссылка нужна и на файл, и на каждую папку пути: игра опускает в нижний
|
||||
// регистр путь целиком, включая имя мода.
|
||||
link := filepath.Join(dir, "loadshotgun_hb.xml")
|
||||
if data, err := os.ReadFile(link); err != nil || string(data) != "<anim/>" {
|
||||
t.Fatalf("ссылка на файл не читается: %v", err)
|
||||
}
|
||||
modDir := filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
|
||||
"3722134990", "mods", "gunsofmarz")
|
||||
if _, err := os.Stat(modDir); err != nil {
|
||||
t.Fatalf("нет ссылки на папку мода: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(modDir, "42.16", "media", "animsets", "loadshotgun_hb.xml")); err != nil {
|
||||
t.Fatalf("путь целиком строчными не собрался: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Повторный запуск после докачки модов не должен ничего ломать.
|
||||
func TestApplyIsIdempotent(t *testing.T) {
|
||||
root, _ := content(t)
|
||||
|
||||
first, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skipCaseInsensitive(t, first)
|
||||
second, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Created != 0 {
|
||||
t.Errorf("второй проход создал ссылки заново: %d", second.Created)
|
||||
}
|
||||
if second.Existing != first.Created {
|
||||
t.Errorf("готовых ссылок %d, а создано было %d", second.Existing, first.Created)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя, которое уже занято настоящим файлом, не трогаем.
|
||||
func TestApplyKeepsExistingLowercaseFile(t *testing.T) {
|
||||
root, dir := content(t)
|
||||
path := filepath.Join(dir, "loadshotgun_hb.xml")
|
||||
if err := os.WriteFile(path, []byte("свой файл"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := Apply(context.Background(), []string{root}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != "свой файл" {
|
||||
t.Fatalf("существующий файл перезаписан: %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithoutWorkshopDir(t *testing.T) {
|
||||
if _, err := Apply(context.Background(), []string{t.TempDir()}, nil); err == nil {
|
||||
t.Fatal("ожидалась ошибка про ненайденную папку модов")
|
||||
}
|
||||
}
|
||||
|
||||
// На Windows и macOS кнопка должна честно сказать, что чинить нечего, а не
|
||||
// пересчитывать все имена как «уже готовые».
|
||||
func TestApplyReportsCaseInsensitiveFilesystem(t *testing.T) {
|
||||
root, dir := content(t)
|
||||
if !caseInsensitive(dir) {
|
||||
t.Skip("файловая система различает регистр")
|
||||
}
|
||||
|
||||
result, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.CaseInsensitive {
|
||||
t.Error("нечувствительность к регистру не распознана")
|
||||
}
|
||||
if result.Created != 0 || result.Existing != 0 || result.Scanned != 0 {
|
||||
t.Errorf("дерево обошли впустую: %+v", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user