Files
pzmanager/internal/casefix/casefix.go
T
mrleo1nidandClaude Opus 5 5d9bedaad2
CI / test (push) Successful in 24s
Release / release (push) Successful in 28s
Строчные ссылки на файлы модов и своё ожидание готовности
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком,
вместе с именем папки мода. На Linux такие файлы не находятся, и сервер
сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды»
раскладывает рядом строчные симлинки: файлы модов не меняются, повторный
запуск после докачки безопасен, а на файловых системах без различия
регистра кнопка честно говорит, что делать нечего.

Ожидание строки о готовности стало настройкой профиля со значением по
умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати
не хватает, и панель объявляла бы работающим сервер, который ещё никого
не пустит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:30:40 +03:00

173 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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("…дальше молча, итог будет в конце")
}
}