diff --git a/010_editor_templates/dir.bt b/010_editor_templates/dir.bt index 43b687f..97da140 100644 --- a/010_editor_templates/dir.bt +++ b/010_editor_templates/dir.bt @@ -58,5 +58,3 @@ string readFile(File &f) { string name; return f.filename; }; - - diff --git a/Makefile b/Makefile index 471e33a..c3cf9e6 100644 --- a/Makefile +++ b/Makefile @@ -6,10 +6,15 @@ COVER_FILE=cover.out all: build test vet cover +build_all: build_linux_64 build_windows_32 + build: go build -ldflags "-s -w" -o ${OUTPUT_DIR} ./cmd/... -build_windows: +build_linux_64: + GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ${OUTPUT_DIR} ./cmd/... + +build_windows_32: GOOS=windows GOARCH=386 go build -ldflags "-s -w" -o ${OUTPUT_DIR} ./cmd/... test: go test ./cmd/... ./pkg/... diff --git a/cmd/dir_pack/dir_pack.go b/cmd/dir_pack/dir_pack.go new file mode 100755 index 0000000..829b6f7 --- /dev/null +++ b/cmd/dir_pack/dir_pack.go @@ -0,0 +1,101 @@ +/* +Dir_pack packs directories to .dir/.wad files +*/ +package main + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/halamix2/stunt_gp_tools/pkg/dir" + "github.com/spf13/pflag" +) + +// flags +var ( + outputName string + // verbose bool +) + +func parseFlags() { + pflag.StringVarP(&outputName, "output", "o", "", "name of the output file") + // verbose + pflag.Parse() +} + +func usage() { + fmt.Println("Packs dir archive used by Stunt GP and Worms Armageddon.") + fmt.Println("Usage:\ndir_pack input_dir [input_dirs]") + fmt.Println("Flags:") + pflag.PrintDefaults() +} + +func main() { + parseFlags() + args := pflag.Args() + if len(args) < 1 { + usage() + os.Exit(1) + } + + if outputName != "" && len(args) > 1 { + fmt.Println("Output name can only be used with one input directory") + usage() + os.Exit(1) + } + + for _, inputName := range args { + if outputName == "" { + // I know, Worms uses .dir extension, but this is primarily Stunt GP program after all + outputName = strings.TrimSuffix(inputName, filepath.Ext(inputName)) + ".wad" + } + + dir := dir.Dir{} + + walkFunc := getWalkFunc(&dir) + err := filepath.Walk(inputName, walkFunc) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't get list of files: %s\n", outputName) + os.Exit(1) + } + fmt.Printf("packing %d files\n", len(dir)) + + outputFile, err := os.Create(outputName) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't create output image file %s!\n", outputName) + os.Exit(1) + } + + err = dir.ToWriter(outputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't save dir file %s: %s\n", outputName, err) + os.Exit(1) + } + + err = outputFile.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't close output file %s: %s\n", outputName, err) + os.Exit(1) + } + } +} + +func getWalkFunc(dir *dir.Dir) filepath.WalkFunc { + return func(path string, info fs.FileInfo, err error) error { + if !info.IsDir() { + // file + data, err := os.ReadFile(path) + if err != nil { + return err + } + pathNormalized := strings.ReplaceAll(path, "/", "\\") + split := strings.Split(pathNormalized, "\\") + pathNormalized = strings.Join(split[1:], "\\") + (*dir)[pathNormalized] = data + } + return nil + } +} diff --git a/cmd/dir_unpack/dir_unpack.go b/cmd/dir_unpack/dir_unpack.go new file mode 100755 index 0000000..5581612 --- /dev/null +++ b/cmd/dir_unpack/dir_unpack.go @@ -0,0 +1,90 @@ +/* +Dir_unpack unpacks .dir/.wad files to directories +*/ +package main + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/halamix2/stunt_gp_tools/pkg/dir" + "github.com/spf13/pflag" +) + +// flags +var ( + outputDirectory string +) + +func parseFlags() { + pflag.StringVarP(&outputDirectory, "output", "o", "", "name of the output directory") + pflag.Parse() +} + +func usage() { + fmt.Println("Unpacks dir archive used by Stunt GP and Worms Armageddon.") + fmt.Println("Usage:\ndir_unpack input_file [input_files]") + fmt.Println("Flags:") + pflag.PrintDefaults() +} + +func main() { + parseFlags() + args := pflag.Args() + if len(args) < 1 { + usage() + os.Exit(1) + } + + if outputDirectory != "" && len(args) > 1 { + fmt.Println("Output name can only be used with one input directory") + usage() + os.Exit(1) + } + + for _, inputName := range args { + if outputDirectory == "" { + // I know, Worms uses .dir extension, but this is primarily Stunt GP program after all + outputDirectory = strings.TrimSuffix(inputName, filepath.Ext(inputName)) + } + + dirFile, err := dir.FromDir(inputName) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't read Dir file: %s\n", err) + os.Exit(1) + } + + err = os.MkdirAll(outputDirectory, 0750) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't create %s path: %s\n", outputDirectory, err) + os.Exit(1) + } + + err = saveFiles(dirFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't save files: %s\n", err) + os.Exit(1) + } + } +} + +func saveFiles(dirFile dir.Dir) error { + for archFilename, archData := range dirFile { + archFilename = strings.ReplaceAll(archFilename, "\\", "/") + outPath := filepath.Join(outputDirectory, archFilename) + + err := os.MkdirAll(path.Dir(outPath), 0750) + if err != nil { + return fmt.Errorf("couldn't create %s path: %s", outPath, err) + } + + err = os.WriteFile(outPath, archData, 0666) + if err != nil { + return fmt.Errorf("couldn't save %s file: %s", outPath, err) + } + } + return nil +} diff --git a/go.mod b/go.mod index d7e4265..e41a537 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,7 @@ go 1.19 require ( github.com/gojuno/go.morton v0.0.0-20180202102823-94709bd871ce github.com/spf13/cobra v1.6.1 + github.com/spf13/pflag v1.0.5 ) -require ( - github.com/inconshreveable/mousetrap v1.0.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect -) +require github.com/inconshreveable/mousetrap v1.0.1 // indirect diff --git a/pkg/dir/common.go b/pkg/dir/common.go new file mode 100755 index 0000000..3d2c6a5 --- /dev/null +++ b/pkg/dir/common.go @@ -0,0 +1,50 @@ +// Package dir implements functions used to manipulate Worms Armageddon / Stunt GP .dir / .wad files. +package dir + +const testDir = "test_data/" + +// Dir is a base representation of a Dir archive +type Dir map[string][]byte + +// HashTable stores whole hash table +type HashTable struct { + Header [4]byte + Entries [1024]uint32 +} + +// Header contains all data at the beginning of a file +type Header struct { + Magic [4]byte + Size uint32 + DirectoryAddress uint32 +} + +// FileMetadata contains all metadata stored in the file list +type FileMetadata struct { + NextHash uint32 + DataOffset uint32 + DataSize uint32 + Filename string +} + +// FileMetadataSmall contains metadata stored in the file list without the filename +type FileMetadataSmall struct { + NextHash uint32 + DataOffset uint32 + DataSize uint32 +} + +// GetHash generates hash used by the DIR archives. Filename should be provided in ASCII encoding and without trailing \0 +func GetHash(filename string) uint32 { + var hash uint32 + + const hashBits uint32 = 10 + const hashSize uint32 = 1 << hashBits + + for _, char := range filename { + hash = (uint32(hash<<1) % hashSize) | uint32(hash>>(hashBits-1)&1) + hash = uint32(hash+uint32(char)) % hashSize + } + + return hash +} diff --git a/pkg/dir/common_test.go b/pkg/dir/common_test.go new file mode 100755 index 0000000..1ff3dcc --- /dev/null +++ b/pkg/dir/common_test.go @@ -0,0 +1,45 @@ +package dir + +import ( + "testing" +) + +func TestHashingFunction(t *testing.T) { + // names should be provided without trailing \0, but the tests also check for that + testCases := []struct { + path string + expectedHash uint32 + }{ + { + path: "", + expectedHash: 0, + }, + { + path: "a", + expectedHash: 97, + }, + { + path: "test", + expectedHash: 655, + }, + { + path: "test\x00", + expectedHash: 287, + }, + { + path: "graphics24\\cars\\car15\\b15grid5.pc", + expectedHash: 341, + }, + { + path: "replays\\track19.rpl", + expectedHash: 738, + }, + } + + for _, testCase := range testCases { + hash := GetHash(testCase.path) + if hash != testCase.expectedHash { + t.Fatalf("HashingFunction: path: %s, expected hash: %d, got %d", testCase.path, testCase.expectedHash, hash) + } + } +} diff --git a/pkg/dir/reader.go b/pkg/dir/reader.go new file mode 100755 index 0000000..fa2c24d --- /dev/null +++ b/pkg/dir/reader.go @@ -0,0 +1,143 @@ +package dir + +import ( + "encoding/binary" + "fmt" + "io" + "os" +) + +// FromDir creates new Dir map from a dir file +func FromDir(filename string) (Dir, error) { + dir := Dir{} + + r, err := os.Open(filename) + if err != nil { + return nil, err + } + + header, err := getHeader(r) + if err != nil { + return nil, err + } + + if string(header.Magic[:]) != "DIR\x1a" { + return nil, fmt.Errorf("Incorrect magic, expected DIR\x1a, got %s", header.Magic) + } + + // jump to hash array + _, err = r.Seek(int64(header.DirectoryAddress), 0) + if err != nil { + return nil, err + } + + table, err := readTable(r) + if err != nil { + return nil, err + } + + for tableElement := range table.Entries { + _, err = r.Seek(int64(header.DirectoryAddress)+4+(int64(tableElement)*4), 0) + if err != nil { + return nil, err + } + var elementOffset uint32 + err := binary.Read(r, binary.LittleEndian, &elementOffset) + if err != nil { + return nil, err + } + err = dir.readElements(r, header.DirectoryAddress, elementOffset) + if err != nil { + return nil, err + } + } + err = r.Close() + if err != nil { + return nil, err + } + return dir, nil +} + +func getHeader(r io.Reader) (Header, error) { + header := Header{} + err := binary.Read(r, binary.LittleEndian, &header) + if err != nil { + return header, err + } + return header, nil +} + +func readTable(r io.Reader) (HashTable, error) { + table := HashTable{} + err := binary.Read(r, binary.LittleEndian, &table) + if err != nil { + return table, err + } + if string(table.Header[:]) != "\x0a\x00\x00\x00" { + return table, fmt.Errorf("Incorrect magtable header, expected \x0a, got %s", table.Header) + } + return table, nil +} + +// parses one whole hash value chain +func (d *Dir) readElements(r *os.File, tableOffset, elementOffset uint32) error { + // skip empty hashes + if elementOffset == 0 { + return nil + } + + _, err := r.Seek(int64(tableOffset)+int64(elementOffset), 0) + if err != nil { + return err + } + + var meta FileMetadata + var metaSmall FileMetadataSmall + err = binary.Read(r, binary.LittleEndian, &metaSmall) + if err != nil { + return err + } + filename := "" + // TODO read name + buf := make([]byte, 1) + + for { + _, err := r.Read(buf) + if err != nil { + return fmt.Errorf("couldn't parse filename %s", err) + } + if buf[0] != '\x00' { + filename += string(buf) + } else { + break + } + } + + meta = FileMetadata{ + NextHash: metaSmall.NextHash, + DataOffset: metaSmall.DataOffset, + DataSize: metaSmall.DataSize, + Filename: filename, + } + + _, err = r.Seek(int64(meta.DataOffset), 0) + if err != nil { + return err + } + + fileData := make([]byte, meta.DataSize) + // TODO check if this reader type is correct here + _, err = r.Read(fileData) + if err != nil { + return err + } + + (*d)[meta.Filename] = fileData + if meta.NextHash > 0 { + err = d.readElements(r, tableOffset, meta.NextHash) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/dir/reader_test.go b/pkg/dir/reader_test.go new file mode 100644 index 0000000..0e4dd91 --- /dev/null +++ b/pkg/dir/reader_test.go @@ -0,0 +1,76 @@ +package dir + +import ( + "reflect" + "strings" + "testing" +) + +func TestFromDir(t *testing.T) { + var testCases = []struct { + name string + filename string + expectedFiles Dir + shouldFail bool + }{ + { + name: "Empty dir file", + filename: testDir + "empty.wad", + expectedFiles: Dir{}, + shouldFail: false, + }, + { + name: "Dir file with content", + filename: testDir + "data.wad", + expectedFiles: Dir{ + "one.txt": []byte("first"), + "two.txt": []byte("second"), + "three\\three.txt": []byte("third"), + }, + shouldFail: false, + }, + + { + name: "Broken dir file", + filename: testDir + "broken.wad", + expectedFiles: Dir{}, + shouldFail: true, + }, + { + name: "Not a dir file", + filename: testDir + "dummy.txt", + expectedFiles: Dir{}, + shouldFail: true, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + result, err := FromDir(test.filename) + if test.shouldFail { + if err == nil { + t.Error("FromDir didn't fail when it should") + } + } else { + if err != nil { + t.Errorf("FromDir failed when it shouldn't: %s", err) + + } + if !reflect.DeepEqual(test.expectedFiles, result) { + want := mapKeysToString(test.expectedFiles) + got := mapKeysToString(result) + t.Errorf("List of files is incorrect:\nwant: %s\ngot: %s", want, got) + } + } + }) + } +} + +func mapKeysToString(dir Dir) string { + elements := make([]string, 0) + for k, data := range dir { + element := k + ": " + string(data) + elements = append(elements, element) + } + return "[\n\t" + strings.Join(elements, ",\n\t") + "\n]" +} diff --git a/pkg/dir/test_data/broken.wad b/pkg/dir/test_data/broken.wad new file mode 100644 index 0000000..a26dad2 Binary files /dev/null and b/pkg/dir/test_data/broken.wad differ diff --git a/pkg/dir/test_data/data.wad b/pkg/dir/test_data/data.wad new file mode 100644 index 0000000..2c75c87 Binary files /dev/null and b/pkg/dir/test_data/data.wad differ diff --git a/pkg/dir/test_data/empty.wad b/pkg/dir/test_data/empty.wad new file mode 100644 index 0000000..5c85745 Binary files /dev/null and b/pkg/dir/test_data/empty.wad differ diff --git a/pkg/dir/writer.go b/pkg/dir/writer.go new file mode 100755 index 0000000..18b0da7 --- /dev/null +++ b/pkg/dir/writer.go @@ -0,0 +1,244 @@ +package dir + +import ( + "encoding/binary" + "io" + "os" + "sort" +) + +// FileData contains physical file data +type FileData struct { + Data []byte + Footer []byte + Padding []byte +} + +type writer struct { + *Dir + keys []string + dataSize uint32 + descriptorsSize uint32 + descriptorsTable []string + fh map[string]fileHelper +} + +type fileHelper struct { + dataOffset uint32 + descriptorOffset uint32 + padding int + filenamePadding int + nextFile string +} + +// ToDir creates new Dir file from a Dir +func (d *Dir) ToDir(filename string) error { + f, err := os.Create(filename) + if err != nil { + return err + } + + err = d.ToWriter(f) + if err != nil { + return err + } + + return f.Close() +} + +// ToWriter saves Dir archive to a file object +func (d *Dir) ToWriter(f io.Writer) error { + w := writer{Dir: d} + w.keys = w.getSortedKeys() + w.prepareMetadata() + w.prepareDescriptorTable() + + err := w.writeAll(f) + if err != nil { + return err + } + + return nil +} + +func (d *Dir) getSortedKeys() []string { + keys := make([]string, 0) + for k := range *d { + keys = append(keys, k) + } + + sort.Strings(keys) + return keys +} + +func (w *writer) prepareMetadata() { + dataSize := 0 + descriptorOffset := 4 + (4 * 1024) + w.fh = make(map[string]fileHelper) + + // data should be padded to nearest 4 bytes + // luckily the Dir header is 12 bytes + for _, k := range w.keys { + fh := w.fh[k] + fh.dataOffset = uint32(12 + dataSize) + + data := (*w.Dir)[k] + dataSize += len(data) + 2 + padding := dataSize % 4 + if padding > 0 { + fh.padding = 4 - padding + dataSize += fh.padding + } + + fh.descriptorOffset = uint32(descriptorOffset) + // +1 for null byte, and then some padding for a neat file + descriptorOffset += 12 + len(k) + 1 + filenamePadding := descriptorOffset % 4 + if filenamePadding > 0 { + fh.filenamePadding = 4 - filenamePadding + descriptorOffset += fh.filenamePadding + } + + w.fh[k] = fh + } + w.dataSize = uint32(dataSize) + w.descriptorsSize = uint32(descriptorOffset) +} + +func (w *writer) prepareDescriptorTable() { + w.descriptorsTable = make([]string, 1024) + + for _, k := range w.keys { + hash := GetHash(k) + if w.descriptorsTable[hash] == "" { + w.descriptorsTable[hash] = k + } else { + s := w.descriptorsTable[hash] + fh := w.fh[s] + + for fh.nextFile != "" { + s = fh.nextFile + fh = w.fh[s] + } + fh.nextFile = k + w.fh[s] = fh + } + } +} + +func (w *writer) writeAll(f io.Writer) error { + if err := w.writeHeader(f); err != nil { + return err + } + + if err := w.writeData(f); err != nil { + return err + } + + if err := w.writeDirectory(f); err != nil { + return err + } + + if err := w.writeEntries(f); err != nil { + return err + } + + return nil +} + +func (w *writer) writeHeader(f io.Writer) error { + magic := []byte("DIR\x1A") + dirOffset := 12 + w.dataSize + fileSize := dirOffset + w.descriptorsSize + + if _, err := f.Write(magic); err != nil { + return err + } + if err := binary.Write(f, binary.LittleEndian, fileSize); err != nil { + return err + } + if err := binary.Write(f, binary.LittleEndian, dirOffset); err != nil { + return err + } + return nil +} + +func (w *writer) writeData(f io.Writer) error { + fileFooter := []byte("\x1A\x00") + for _, k := range w.keys { + fh := w.fh[k] + if _, err := f.Write((*w.Dir)[k]); err != nil { + return err + } + if _, err := f.Write(fileFooter); err != nil { + return err + } + if err := writePadding(f, fh.padding); err != nil { + return err + } + + } + return nil +} + +func (w *writer) writeDirectory(f io.Writer) error { + directoryHeader := []byte("\x0A\x00\x00\x00") + if _, err := f.Write(directoryHeader); err != nil { + return err + } + + for _, k := range w.descriptorsTable { + if k != "" { + fh := w.fh[k] + if err := binary.Write(f, binary.LittleEndian, fh.descriptorOffset); err != nil { + return err + } + } else { + if err := binary.Write(f, binary.LittleEndian, uint32(0)); err != nil { + return err + } + } + } + return nil +} + +func (w *writer) writeEntries(f io.Writer) error { + for _, k := range w.keys { + fh := w.fh[k] + data := (*w.Dir)[k] + + nextOffset := uint32(0) + if fh.nextFile != "" { + fhNext := w.fh[fh.nextFile] + nextOffset = fhNext.descriptorOffset + } + + if err := binary.Write(f, binary.LittleEndian, nextOffset); err != nil { + return err + } + if err := binary.Write(f, binary.LittleEndian, fh.dataOffset); err != nil { + return err + } + if err := binary.Write(f, binary.LittleEndian, uint32(len(data))); err != nil { + return err + } + if _, err := f.Write([]byte(k)); err != nil { + return err + } + if _, err := f.Write([]byte{'\x00'}); err != nil { + return err + } + if err := writePadding(f, fh.filenamePadding); err != nil { + return err + } + } + return nil +} + +func writePadding(f io.Writer, padSize int) error { + var err error + for i := 0; i < padSize; i++ { + _, err = f.Write([]byte{'\x00'}) + } + return err +} diff --git a/pkg/dir/writer_test.go b/pkg/dir/writer_test.go new file mode 100644 index 0000000..6b24b89 --- /dev/null +++ b/pkg/dir/writer_test.go @@ -0,0 +1,41 @@ +package dir + +import ( + "bytes" + "os" + "reflect" + "testing" +) + +func TestToDir(t *testing.T) { + var testCases = []struct { + name string + files Dir + expectedOutputFilename string + }{ + { + name: "packing 2 files", + files: Dir{"one.txt": []byte("first"), + "two.txt": []byte("second"), + "three\\three.txt": []byte("third")}, + expectedOutputFilename: testDir + "data.wad", + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + expectedOutput, err := os.ReadFile(test.expectedOutputFilename) + if err != nil { + t.Errorf("Couldn't load test datat: %s", err) + } + var buf bytes.Buffer + err = test.files.ToWriter(&buf) + if err != nil { + t.Errorf("ToWriter failed: %s", err) + } + if !reflect.DeepEqual(buf.Bytes(), expectedOutput) { + t.Errorf("Saved file differs from expected") + } + }) + } +} diff --git a/python/SGPTools/DIR.py b/python/SGPTools/DIR.py deleted file mode 100755 index 292a1d5..0000000 --- a/python/SGPTools/DIR.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/python3 -# coding=utf-8 - -import os - -# files -from pathlib import Path - -from typing import List, Dict, Optional, BinaryIO - -#binary -import struct - -class DIR(dict): - - @classmethod - def from_dir(cls, filename:str): - dir = cls() - with open(filename, 'rb') as dir_file: - magic = dir_file.read(4) # magic - if magic != b'DIR\x1a': - raise RuntimeError("Not a DIR file!") - - archive_size = struct.unpack(" int: - hash = 0 - - HASH_BITS = 10 - hash_size = 1 << HASH_BITS - - for char in filename: - # bitise rotate left on 10 bits - hash = ((hash << 1) % hash_size) | (hash >> (HASH_BITS - 1) & 1) - hash = (hash + ord(char)) % hash_size - return hash diff --git a/python/SGPTools/__init__.py b/python/SGPTools/__init__.py deleted file mode 100755 index 08900cc..0000000 --- a/python/SGPTools/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__ = ["DIR"] -from SGPTools.DIR import DIR diff --git a/python/dir_pack.py b/python/dir_pack.py deleted file mode 100755 index 4b9713f..0000000 --- a/python/dir_pack.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 -# coding=utf-8 - -import argparse -from pathlib import Path - -from SGPTools import DIR - - -parser = argparse.ArgumentParser() -parser.add_argument("filename", help="name of the folder") -parser.add_argument("-o", "--output", help="name of the output archive") - - -def main(): - args = parser.parse_args() - - dir = DIR.from_folder(args.filename) - - if not dir: - raise Exception('Not a folder apparently') - - output = str(Path(args.filename).parent/Path(args.filename).stem)+'.dir' - if args.output: - output = args.output - dir.to_dir(output) - -if __name__ == "__main__": - main() diff --git a/python/dir_unpack.py b/python/dir_unpack.py deleted file mode 100755 index 6db24e9..0000000 --- a/python/dir_unpack.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 -# coding=utf-8 - -import argparse -from pathlib import Path - -from SGPTools import DIR - - -parser = argparse.ArgumentParser() -parser.add_argument("filename", help="name of the dir file") -parser.add_argument("-o", "--output", help="name of the output folder") - - -def main(): - args = parser.parse_args() - - dir = DIR.from_dir(args.filename) - - if not dir: - raise Exception('Not a valid dir archive') - - output = Path(args.filename).parent/Path(args.filename).stem - if args.output: - output = args.output - dir.to_folder(output) - -if __name__ == "__main__": - main()