├── .gitignore ├── renovate.json ├── register_test.go ├── go.mod ├── path.go ├── vpk.go ├── path_test.go ├── .github └── workflows │ └── test.yml ├── go.sum ├── filesystem_test.go ├── README.md ├── errors.go ├── filesystem.go └── register.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /register_test.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import "testing" 4 | 5 | func TestRegisterGameResourcePaths(t *testing.T) { 6 | t.Skip() 7 | } 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/galaco/filesystem 2 | 3 | go 1.23.0 4 | 5 | replace github.com/galaco/bsp => ../bsp 6 | 7 | require ( 8 | github.com/galaco/KeyValues v1.4.1 9 | github.com/galaco/vpk2 v1.0.0 10 | ) 11 | 12 | require github.com/go-gl/mathgl v1.2.0 // indirect 13 | -------------------------------------------------------------------------------- /path.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import "strings" 4 | 5 | // NormalisePath ensures that the same filepath format is used for paths, 6 | // regardless of platform. 7 | func NormalisePath(filePath string) string { 8 | return strings.Replace(filePath, "\\", "/", -1) 9 | } 10 | -------------------------------------------------------------------------------- /vpk.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "github.com/galaco/vpk2" 5 | ) 6 | 7 | // openVPK Basic wrapper around vpk library. 8 | // Just opens a multi-part vpk (ver 2 only) 9 | func openVPK(filepath string) (*vpk.VPK, error) { 10 | return vpk.Open(vpk.MultiVPK(filepath)) 11 | } 12 | -------------------------------------------------------------------------------- /path_test.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import "testing" 4 | 5 | func TestNormalisePath(t *testing.T) { 6 | path := "foo\\bar\\baz" 7 | expected := "foo/bar/baz" 8 | actual := NormalisePath(path) 9 | if expected != actual { 10 | t.Errorf("incorrect path normalised. Expected %s, but received: %s", expected, actual) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.23' 19 | 20 | - name: Run tests 21 | run: go test -v ./... 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/galaco/KeyValues v1.4.1 h1:g50MJ4Ephqe1EqG8WB2S55Zye1JFnjOsHP5TwIKM7Ao= 2 | github.com/galaco/KeyValues v1.4.1/go.mod h1:00r0hZpLlOBIHehyWAgUngjKPoo3vCVP25BgWLwOP7E= 3 | github.com/galaco/vpk2 v1.0.0 h1:+C44FliOTW2CH+Nz1w1WvtL93hJUUCHEGPcl0z5dBJc= 4 | github.com/galaco/vpk2 v1.0.0/go.mod h1:jL22XAWuUlYUmONuamxDdbDlGJhuOFkqNRPJwuBA3X8= 5 | github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= 6 | github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= 7 | -------------------------------------------------------------------------------- /filesystem_test.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import "testing" 4 | 5 | func TestGetFile(t *testing.T) { 6 | t.Skip() 7 | } 8 | 9 | func TestRegisterPakfile(t *testing.T) { 10 | t.Skip() 11 | } 12 | 13 | func TestRegisterVpk(t *testing.T) { 14 | t.Skip() 15 | } 16 | 17 | func TestUnregisterVpk(t *testing.T) { 18 | t.Skip() 19 | } 20 | 21 | func TestRegisterLocalDirectory(t *testing.T) { 22 | fs := NewFileSystem() 23 | dir := "foo/bar/baz" 24 | fs.RegisterLocalDirectory(dir) 25 | found := false 26 | for _, path := range fs.localDirectories { 27 | if path == dir { 28 | found = true 29 | break 30 | } 31 | } 32 | if found == false { 33 | t.Error("local filepath was not found in registered paths") 34 | } 35 | } 36 | 37 | func TestUnregisterLocalDirectory(t *testing.T) { 38 | fs := NewFileSystem() 39 | dir := "foo/bar/baz" 40 | fs.RegisterLocalDirectory(dir) 41 | fs.UnregisterLocalDirectory(dir) 42 | found := false 43 | for _, path := range fs.localDirectories { 44 | if path == dir { 45 | found = true 46 | break 47 | } 48 | } 49 | if found == true { 50 | t.Error("local filepath was not found in registered paths") 51 | } 52 | } 53 | 54 | func TestUnregisterPakfile(t *testing.T) { 55 | t.Skip() 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/galaco/filesystem?status.svg)](https://godoc.org/github.com/galaco/filesystem) 2 | [![Go report card](https://goreportcard.com/badge/github.com/galaco/filesystem)](hhttps://goreportcard.com/report/github.com/galaco/filesystem) 3 | [![GolangCI](https://golangci.com/badges/github.com/galaco/filesystem.svg)](https://golangci.com/r/github.com/galaco/filesystem) 4 | [![codecov](https://codecov.io/gh/galaco/filesystem/branch/master/graph/badge.svg)](https://codecov.io/gh/galaco/filesystem) 5 | [![CircleCI](https://circleci.com/gh/galaco/filesystem.svg?style=svg)](https://circleci.com/gh/galaco/filesystem) 6 | 7 | # Filesystem 8 | 9 | > A filesystem utility for reading Source engine game structures. 10 | 11 | Source Engine is a little annoying in that there are potentially unlimited possible 12 | locations that engine resources can be located. Filesystem provides a way to register 13 | and organise any potential resource path or filesystem, while preserving filesystem type 14 | search priority. 15 | 16 | A filesystem can either be manually defined, or created from a GameInfo.txt-derived KeyValues. 17 | 18 | ### Features 19 | * Supports local directories 20 | * Supports VPK's 21 | * Supports BSP Pakfile 22 | * Respects Source Engines search priority (pakfile->local directory->vpk) 23 | * A ready to use Filesystem can be constructed from GameInfo.txt definitions -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | // ErrorInvalidGameInfo 11 | ErrorInvalidGameInfo = errors.New("gameinfo keyvalues do not match expected specification") 12 | ) 13 | 14 | // FileNotFoundError 15 | type FileNotFoundError struct { 16 | fileName string 17 | } 18 | 19 | // Error 20 | func (err FileNotFoundError) Error() string { 21 | return fmt.Sprintf("%s not found in filesystem", err.fileName) 22 | } 23 | 24 | // NewFileNotFoundError 25 | func NewFileNotFoundError(filename string) *FileNotFoundError { 26 | return &FileNotFoundError{ 27 | fileName: filename, 28 | } 29 | } 30 | 31 | // InvalidResourcePathCollectionError 32 | type InvalidResourcePathCollectionError struct { 33 | paths []string 34 | } 35 | 36 | // Error will return a list of paths that could not be added. 37 | // This list is a pipe-separated(|) string 38 | func (err InvalidResourcePathCollectionError) Error() string { 39 | msg := "" 40 | for _,p := range err.paths { 41 | msg += p + "|" 42 | } 43 | return strings.Trim(msg, "|") 44 | } 45 | 46 | // AddPath adds a new path to this error colleciton 47 | func (err InvalidResourcePathCollectionError) AddPath(path string) { 48 | err.paths = append(err.paths, path) 49 | } 50 | 51 | // NewInvalidResourcePathCollectionError 52 | func NewInvalidResourcePathCollectionError() *InvalidResourcePathCollectionError { 53 | return &InvalidResourcePathCollectionError{ 54 | paths: make([]string, 0), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /filesystem.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/galaco/vpk2" 10 | ) 11 | 12 | type PakFile interface { 13 | GetFile(filePath string) ([]byte, error) 14 | } 15 | 16 | // FileSystem implements a Source Engine style filesystem, prioritizing 17 | // pakfile, local directories, and vpk packages in that order. 18 | type FileSystem struct { 19 | gameVPKs map[string]vpk.VPK 20 | localDirectories []string 21 | pakFile PakFile 22 | } 23 | 24 | // NewFileSystem returns a new filesystem 25 | func NewFileSystem() *FileSystem { 26 | return &FileSystem{ 27 | gameVPKs: map[string]vpk.VPK{}, 28 | localDirectories: make([]string, 0), 29 | pakFile: nil, 30 | } 31 | } 32 | 33 | // PakFile returns loaded pakfile 34 | // There can only be 1 registered pakfile at once. 35 | func (fs *FileSystem) PakFile() PakFile { 36 | return fs.pakFile 37 | } 38 | 39 | // RegisterVpk registers a vpk package as a valid 40 | // asset directory 41 | func (fs *FileSystem) RegisterVpk(path string, vpkFile *vpk.VPK) { 42 | fs.gameVPKs[path] = *vpkFile 43 | } 44 | 45 | func (fs *FileSystem) UnregisterVpk(path string) { 46 | for key := range fs.gameVPKs { 47 | if key == path { 48 | delete(fs.gameVPKs, key) 49 | } 50 | } 51 | } 52 | 53 | // RegisterLocalDirectory register a filesystem path as a valid 54 | // asset directory 55 | func (fs *FileSystem) RegisterLocalDirectory(directory string) { 56 | realpath := directory 57 | if strings.HasSuffix(realpath, "/") { 58 | realpath = strings.TrimRight(realpath, "/") 59 | } 60 | fs.localDirectories = append(fs.localDirectories, realpath) 61 | } 62 | 63 | func (fs *FileSystem) UnregisterLocalDirectory(directory string) { 64 | for idx, dir := range fs.localDirectories { 65 | if dir == directory { 66 | if len(fs.localDirectories) == 1 { 67 | fs.localDirectories = make([]string, 0) 68 | return 69 | } 70 | fs.localDirectories = append(fs.localDirectories[:idx], fs.localDirectories[idx+1:]...) 71 | } 72 | } 73 | } 74 | 75 | // RegisterPakFile Set a pakfile to be used as an asset directory. 76 | // This would normally be called during each map load 77 | func (fs *FileSystem) RegisterPakFile(pakFile PakFile) { 78 | fs.pakFile = pakFile 79 | } 80 | 81 | // UnregisterPakFile removes the current pakfile from 82 | // available search locations 83 | func (fs *FileSystem) UnregisterPakFile() { 84 | fs.pakFile = nil 85 | } 86 | 87 | // EnumerateResourcePaths returns all registered resource paths. 88 | // PakFile is excluded. 89 | func (fs *FileSystem) EnumerateResourcePaths() []string { 90 | list := make([]string, 0) 91 | 92 | for idx := range fs.gameVPKs { 93 | list = append(list, string(idx)) 94 | } 95 | 96 | list = append(list, fs.localDirectories...) 97 | 98 | return list 99 | } 100 | 101 | // GetFile attempts to get stream for filename. 102 | // Search order is Pak->FileSystem->VPK 103 | func (fs *FileSystem) GetFile(filename string) (io.Reader, error) { 104 | // sanitise file 105 | searchPath := NormalisePath(strings.ToLower(filename)) 106 | 107 | // try to read from pakfile first 108 | if fs.pakFile != nil { 109 | f, err := fs.pakFile.GetFile(searchPath) 110 | if err == nil && f != nil && len(f) != 0 { 111 | return bytes.NewReader(f), nil 112 | } 113 | } 114 | 115 | // Fallback to local filesystem 116 | for _, dir := range fs.localDirectories { 117 | realpath := dir + "/" + searchPath 118 | if _, err := os.Stat(realpath); os.IsNotExist(err) { 119 | continue 120 | } 121 | file, err := os.ReadFile(realpath) 122 | if err != nil { 123 | return nil, err 124 | } 125 | return bytes.NewBuffer(file), nil 126 | } 127 | 128 | // Fall back to game vpk 129 | for _, vfs := range fs.gameVPKs { 130 | entry := vfs.Entry(searchPath) 131 | if entry != nil { 132 | return entry.Open() 133 | } 134 | } 135 | 136 | return nil, NewFileNotFoundError(filename) 137 | } 138 | -------------------------------------------------------------------------------- /register.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "path/filepath" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/galaco/KeyValues" 9 | ) 10 | 11 | // CreateFilesystemFromGameInfoDefinitions Reads game resource data paths 12 | // from gameinfo.txt 13 | // All games should ship with a gameinfo.txt, but it isn't actually mandatory. 14 | // GameInfo definitions are quite unreliable, there are often bad entries; 15 | // allowInvalidLocations will skip over bad paths, and an error collection 16 | // will be returned will all paths that are invalid. 17 | func CreateFilesystemFromGameInfoDefinitions(basePath string, gameInfo *keyvalues.KeyValue, allowInvalidLocations bool) (*FileSystem, error) { 18 | fs := NewFileSystem() 19 | var gameInfoNode *keyvalues.KeyValue 20 | if gameInfo.Key() != "GameInfo" { 21 | gameInfoNode, _ = gameInfo.Find("GameInfo") 22 | } else { 23 | gameInfoNode = gameInfo 24 | } 25 | if gameInfoNode == nil { 26 | return nil, ErrorInvalidGameInfo 27 | } 28 | fsNode, _ := gameInfoNode.Find("FileSystem") 29 | 30 | searchPathsNode, _ := fsNode.Find("SearchPaths") 31 | searchPaths, _ := searchPathsNode.Children() 32 | basePath, _ = filepath.Abs(basePath) 33 | basePath = strings.Replace(basePath, "\\", "/", -1) 34 | 35 | // @NOTE: Kind of hacky. 36 | // extract the game directory from the base path (e.g. cstrike) 37 | // This is assumed to be where the gameinfo is located. 38 | // Will be used to avoid paths defined relative to the game dir. 39 | basePathLastFolderIndex := strings.LastIndex(basePath, "/") 40 | gameDir := "" 41 | gameRootPath := basePath 42 | if basePathLastFolderIndex != -1 { 43 | gameDir = basePath[basePathLastFolderIndex+1:] 44 | gameRootPath = strings.TrimSuffix(basePath, "/"+gameDir) 45 | } 46 | 47 | badPathErrorCollection := NewInvalidResourcePathCollectionError() 48 | 49 | for _, searchPath := range searchPaths { 50 | kv := searchPath 51 | path, _ := kv.AsString() 52 | path = strings.Trim(path, " ") 53 | 54 | // Current directory 55 | gameInfoPathRegex := regexp.MustCompile(`(?i)\|gameinfo_path\|`) 56 | if gameInfoPathRegex.MatchString(path) { 57 | path = gameInfoPathRegex.ReplaceAllString(path, basePath+"/") 58 | 59 | // Search for vpk directories in the top directory. Cannot confirm if this is actually accurate behaviour, 60 | // but CS:GO doesn't include any explicit vpk definitions in it's gameinfo.txt 61 | vpkDirectories, _ := filepath.Glob(basePath + "/*_dir.vpk") 62 | for _, key := range vpkDirectories { 63 | vpkHandle, err := openVPK(strings.TrimRight(key, "_dir.vpk")) 64 | if err != nil { 65 | if !allowInvalidLocations { 66 | return nil, err 67 | } 68 | badPathErrorCollection.AddPath(path) 69 | continue 70 | } 71 | fs.RegisterVpk(key, vpkHandle) 72 | } 73 | } 74 | 75 | // Executable directory 76 | allSourceEnginePathsRegex := regexp.MustCompile(`(?i)\|all_source_engine_paths\|`) 77 | if allSourceEnginePathsRegex.MatchString(path) { 78 | path = allSourceEnginePathsRegex.ReplaceAllString(path, gameRootPath+"/") 79 | } 80 | 81 | path = strings.TrimPrefix(path, gameDir+"/") 82 | 83 | if strings.Contains(strings.ToLower(kv.Key()), "mod") && !strings.HasPrefix(path, basePath) { 84 | path = basePath + "/" + path 85 | } 86 | 87 | path = strings.ReplaceAll(path, "//", "/") 88 | 89 | // Strip vpk extension, then load it 90 | path = strings.Trim(strings.Trim(path, " "), "\"") 91 | if strings.HasSuffix(path, ".vpk") { 92 | path = strings.Replace(path, ".vpk", "", 1) 93 | vpkHandle, err := openVPK(path) 94 | if err != nil { 95 | if !allowInvalidLocations { 96 | return nil, err 97 | } 98 | badPathErrorCollection.AddPath(path) 99 | continue 100 | } 101 | fs.RegisterVpk(path, vpkHandle) 102 | } else { 103 | // wildcard suffixes not useful 104 | if strings.HasSuffix(path, "/*") { 105 | path = strings.Replace(path, "/*", "", -1) 106 | } 107 | // @TODO: handle relative paths properly 108 | // For now, just assume non-absolute paths are relative to basePath 109 | // Absolute paths start with / or \, or with a drive letter (X:/ or X:\) on Windows 110 | if !((strings.HasPrefix(path, "/")) || (strings.HasPrefix(path, "\\")) || (len(path) > 3 && (path[1:3] == ":/" || path[1:3] == ":\\"))) { 111 | path = basePath + "/" + path 112 | } 113 | 114 | // @TODO: Hack to fix the trailing '/.' current dir 115 | path = strings.ReplaceAll(path, "/./", "/") 116 | path = strings.TrimSuffix(path, "/.") 117 | 118 | fs.RegisterLocalDirectory(path) 119 | } 120 | } 121 | 122 | // A filesystem can be valid, even if some GameInfo defined locations 123 | // were not. 124 | if allowInvalidLocations && len(badPathErrorCollection.paths) > 0 { 125 | return fs, badPathErrorCollection 126 | } 127 | 128 | return fs, nil 129 | } 130 | --------------------------------------------------------------------------------