├── .circleci └── config.yml ├── .gitignore ├── README.md ├── errors.go ├── filesystem.go ├── filesystem_test.go ├── go.mod ├── go.sum ├── path.go ├── path_test.go ├── register.go ├── register_test.go ├── renovate.json └── vpk.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:1.17 6 | steps: 7 | - checkout 8 | - run: go test -v ./... 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ -------------------------------------------------------------------------------- /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 | "github.com/galaco/bsp/lumps" 6 | "github.com/galaco/vpk2" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | // FileSystem 14 | type FileSystem struct { 15 | gameVPKs map[string]vpk.VPK 16 | localDirectories []string 17 | pakFile *lumps.Pakfile 18 | } 19 | 20 | // NewFileSystem returns a new filesystem 21 | func NewFileSystem() *FileSystem { 22 | return &FileSystem{ 23 | gameVPKs: map[string]vpk.VPK{}, 24 | localDirectories: make([]string, 0), 25 | pakFile: nil, 26 | } 27 | } 28 | 29 | // PakFile returns loaded pakfile 30 | // There can only be 1 registered pakfile at once. 31 | func (fs *FileSystem) PakFile() *lumps.Pakfile { 32 | return fs.pakFile 33 | } 34 | 35 | // RegisterVpk registers a vpk package as a valid 36 | // asset directory 37 | func (fs *FileSystem) RegisterVpk(path string, vpkFile *vpk.VPK) { 38 | fs.gameVPKs[path] = *vpkFile 39 | } 40 | 41 | func (fs *FileSystem) UnregisterVpk(path string) { 42 | for key := range fs.gameVPKs { 43 | if key == path { 44 | delete(fs.gameVPKs, key) 45 | } 46 | } 47 | } 48 | 49 | // RegisterLocalDirectory register a filesystem path as a valid 50 | // asset directory 51 | func (fs *FileSystem) RegisterLocalDirectory(directory string) { 52 | fs.localDirectories = append(fs.localDirectories, directory) 53 | } 54 | 55 | func (fs *FileSystem) UnregisterLocalDirectory(directory string) { 56 | for idx, dir := range fs.localDirectories { 57 | if dir == directory { 58 | if len(fs.localDirectories) == 1 { 59 | fs.localDirectories = make([]string, 0) 60 | return 61 | } 62 | fs.localDirectories = append(fs.localDirectories[:idx], fs.localDirectories[idx+1:]...) 63 | } 64 | } 65 | } 66 | 67 | // RegisterPakFile Set a pakfile to be used as an asset directory. 68 | // This would normally be called during each map load 69 | func (fs *FileSystem) RegisterPakFile(pakFile *lumps.Pakfile) { 70 | fs.pakFile = pakFile 71 | } 72 | 73 | // UnregisterPakFile removes the current pakfile from 74 | // available search locations 75 | func (fs *FileSystem) UnregisterPakFile() { 76 | fs.pakFile = nil 77 | } 78 | 79 | // EnumerateResourcePaths returns all registered resource paths. 80 | // PakFile is excluded. 81 | func (fs *FileSystem) EnumerateResourcePaths() []string { 82 | list := make([]string, 0) 83 | 84 | for idx := range fs.gameVPKs { 85 | list = append(list, string(idx)) 86 | } 87 | 88 | list = append(list, fs.localDirectories...) 89 | 90 | return list 91 | } 92 | 93 | // GetFile attempts to get stream for filename. 94 | // Search order is Pak->FileSystem->VPK 95 | func (fs *FileSystem) GetFile(filename string) (io.Reader, error) { 96 | // sanitise file 97 | searchPath := NormalisePath(strings.ToLower(filename)) 98 | 99 | // try to read from pakfile first 100 | if fs.pakFile != nil { 101 | f, err := fs.pakFile.GetFile(searchPath) 102 | if err == nil && f != nil && len(f) != 0 { 103 | return bytes.NewReader(f), nil 104 | } 105 | } 106 | 107 | // Fallback to local filesystem 108 | for _, dir := range fs.localDirectories { 109 | if _, err := os.Stat(dir + "\\" + searchPath); os.IsNotExist(err) { 110 | continue 111 | } 112 | file, err := ioutil.ReadFile(dir + searchPath) 113 | if err != nil { 114 | return nil, err 115 | } 116 | return bytes.NewBuffer(file), nil 117 | } 118 | 119 | // Fall back to game vpk 120 | for _, vfs := range fs.gameVPKs { 121 | entry := vfs.Entry(searchPath) 122 | if entry != nil { 123 | return entry.Open() 124 | } 125 | } 126 | 127 | return nil, NewFileNotFoundError(filename) 128 | } 129 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/galaco/filesystem 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/galaco/KeyValues v1.4.1 7 | github.com/galaco/bsp v0.3.0 8 | github.com/galaco/vpk2 v1.0.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/galaco/KeyValues v1.3.1 h1:Mka6aZcMSrFawDAGTk7Y0mKHTlGmqiqfz1zIaHs5jYc= 2 | github.com/galaco/KeyValues v1.3.1/go.mod h1:EByNBjnai9BpKGMykvBD3G28R+L6OubVnRkvZrYxP2Y= 3 | github.com/galaco/KeyValues v1.4.0/go.mod h1:00r0hZpLlOBIHehyWAgUngjKPoo3vCVP25BgWLwOP7E= 4 | github.com/galaco/KeyValues v1.4.1 h1:g50MJ4Ephqe1EqG8WB2S55Zye1JFnjOsHP5TwIKM7Ao= 5 | github.com/galaco/KeyValues v1.4.1/go.mod h1:00r0hZpLlOBIHehyWAgUngjKPoo3vCVP25BgWLwOP7E= 6 | github.com/galaco/bsp v0.2.2 h1:BomFvMNrlG9AvtOLppfwoj1ToAYJukL0LEa8gRnjUxI= 7 | github.com/galaco/bsp v0.2.2/go.mod h1:2T3tF0vzvY0NBPrLGe0B5EEQrbG2F0Ur+HWnaSy9YA4= 8 | github.com/galaco/bsp v0.3.0 h1:+NngnwLiFEJbvZ2yrva75Veh+zaWtvkN+l4oIGos1t8= 9 | github.com/galaco/bsp v0.3.0/go.mod h1:DKbfL4GiSQ0RvdbMJAE8q/hP4AvKx4AZM2NQzMgqNe4= 10 | github.com/galaco/vpk2 v0.0.0-20181012095330-21e4d1f6c888 h1:QCMt6AZ5gwsJ3SNsvTULg/xjZIfmcbRWv6BBnkYNOWI= 11 | github.com/galaco/vpk2 v0.0.0-20181012095330-21e4d1f6c888/go.mod h1:jL22XAWuUlYUmONuamxDdbDlGJhuOFkqNRPJwuBA3X8= 12 | github.com/galaco/vpk2 v1.0.0 h1:+C44FliOTW2CH+Nz1w1WvtL93hJUUCHEGPcl0z5dBJc= 13 | github.com/galaco/vpk2 v1.0.0/go.mod h1:jL22XAWuUlYUmONuamxDdbDlGJhuOFkqNRPJwuBA3X8= 14 | github.com/go-gl/mathgl v0.0.0-20190713194549-592312d8590a h1:yoAEv7yeWqfL/l9A/J5QOndXIJCldv+uuQB1DSNQbS0= 15 | github.com/go-gl/mathgl v0.0.0-20190713194549-592312d8590a/go.mod h1:yhpkQzEiH9yPyxDUGzkmgScbaBVlhC06qodikEM0ZwQ= 16 | github.com/go-gl/mathgl v1.0.0 h1:t9DznWJlXxxjeeKLIdovCOVJQk/GzDEL7h/h+Ro2B68= 17 | github.com/go-gl/mathgl v1.0.0/go.mod h1:yhpkQzEiH9yPyxDUGzkmgScbaBVlhC06qodikEM0ZwQ= 18 | golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 19 | golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a h1:gHevYm0pO4QUbwy8Dmdr01R5r1BuKtfYqRqF0h/Cbh0= 20 | golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 21 | golang.org/x/image v0.0.0-20210504121937-7319ad40d33e h1:PzJMNfFQx+QO9hrC1GwZ4BoPGeNGhfeQEgcQFArEjPk= 22 | golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 23 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /register.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "github.com/galaco/KeyValues" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // CreateFilesystemFromGameInfoDefinitions Reads game resource data paths 11 | // from gameinfo.txt 12 | // All games should ship with a gameinfo.txt, but it isn't actually mandatory. 13 | // GameInfo definitions are quite unreliable, there are often bad entries; 14 | // allowInvalidLocations will skip over bad paths, and an error collection 15 | // will be returned will all paths that are invalid. 16 | func CreateFilesystemFromGameInfoDefinitions(basePath string, gameInfo *keyvalues.KeyValue, allowInvalidLocations bool) (*FileSystem, error) { 17 | fs := NewFileSystem() 18 | var gameInfoNode *keyvalues.KeyValue 19 | if gameInfo.Key() != "GameInfo" { 20 | gameInfoNode, _ = gameInfo.Find("GameInfo") 21 | } else { 22 | gameInfoNode = gameInfo 23 | } 24 | if gameInfoNode == nil { 25 | return nil, ErrorInvalidGameInfo 26 | } 27 | fsNode, _ := gameInfoNode.Find("FileSystem") 28 | 29 | searchPathsNode, _ := fsNode.Find("SearchPaths") 30 | searchPaths, _ := searchPathsNode.Children() 31 | basePath, _ = filepath.Abs(basePath) 32 | basePath = strings.Replace(basePath, "\\", "/", -1) 33 | 34 | badPathErrorCollection := NewInvalidResourcePathCollectionError() 35 | 36 | for _, searchPath := range searchPaths { 37 | kv := searchPath 38 | path, _ := kv.AsString() 39 | path = strings.Trim(path, " ") 40 | 41 | // Current directory 42 | gameInfoPathRegex := regexp.MustCompile(`(?i)\|gameinfo_path\|`) 43 | if gameInfoPathRegex.MatchString(path) { 44 | path = gameInfoPathRegex.ReplaceAllString(path, basePath+"/") 45 | 46 | // Search for vpk directories in the top directory. Cannot confirm if this is actually accurate behaviour, 47 | // but CS:GO doesn't include any explicit vpk definitions in it's gameinfo.txt 48 | vpkDirectories,_ := filepath.Glob(basePath + "/*_dir.vpk") 49 | for _,key := range vpkDirectories { 50 | vpkHandle, err := openVPK(strings.TrimRight(key, "_dir.vpk")) 51 | if err != nil { 52 | if !allowInvalidLocations { 53 | return nil, err 54 | } 55 | badPathErrorCollection.AddPath(path) 56 | continue 57 | } 58 | fs.RegisterVpk(key, vpkHandle) 59 | } 60 | } 61 | 62 | // Executable directory 63 | allSourceEnginePathsRegex := regexp.MustCompile(`(?i)\|all_source_engine_paths\|`) 64 | if allSourceEnginePathsRegex.MatchString(path) { 65 | path = allSourceEnginePathsRegex.ReplaceAllString(path, basePath+"/../") 66 | } 67 | if strings.Contains(strings.ToLower(kv.Key()), "mod") && !strings.HasPrefix(path, basePath) { 68 | path = basePath + "/../" + path 69 | } 70 | 71 | path = strings.ReplaceAll(path, "//", "/") 72 | 73 | // Strip vpk extension, then load it 74 | path = strings.Trim(strings.Trim(path, " "), "\"") 75 | if strings.HasSuffix(path, ".vpk") { 76 | path = strings.Replace(path, ".vpk", "", 1) 77 | vpkHandle, err := openVPK(path) 78 | if err != nil { 79 | if !allowInvalidLocations { 80 | return nil, err 81 | } 82 | badPathErrorCollection.AddPath(path) 83 | continue 84 | } 85 | fs.RegisterVpk(path, vpkHandle) 86 | } else { 87 | // wildcard suffixes not useful 88 | if strings.HasSuffix(path, "/*") { 89 | path = strings.Replace(path, "/*", "", -1) 90 | } 91 | fs.RegisterLocalDirectory(path) 92 | } 93 | } 94 | 95 | // A filesystem can be valid, even if some GameInfo defined locations 96 | // were not. 97 | if allowInvalidLocations && len(badPathErrorCollection.paths) > 0 { 98 | return fs, badPathErrorCollection 99 | } 100 | 101 | return fs, nil 102 | } 103 | -------------------------------------------------------------------------------- /register_test.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import "testing" 4 | 5 | func TestRegisterGameResourcePaths(t *testing.T) { 6 | t.Skip() 7 | } 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------