aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--main.go248
3 files changed, 255 insertions, 0 deletions
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..98a5dfe
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module partyvan.io/entq
+
+go 1.22.5
+
+require github.com/ulikunitz/xz v0.5.12
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d7cb43d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
+github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..98ed5b9
--- /dev/null
+++ b/main.go
@@ -0,0 +1,248 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "unicode"
+
+ "github.com/ulikunitz/xz/lzma"
+)
+
+type stateFn func([]byte, bool) (int, []byte, error, stateFn)
+
+func outsideString(pred func(byte) stateFn) stateFn {
+ return func(data []byte, atEOF bool) (int, []byte, error, stateFn) {
+ i := 0
+ for ; i < len(data); i++ {
+ b := data[i]
+ if unicode.IsSpace(rune(b)) {
+ continue
+ }
+ if f := pred(b); f != nil {
+ return i + 1, data[i:i+1], nil, f
+ }
+ if b == '\000' && atEOF {
+ return i, nil, bufio.ErrFinalToken, nil
+ }
+ return i, nil, fmt.Errorf("unexpected %q", b), nil
+ }
+ return i, nil, nil, outsideString(pred)
+ }
+}
+
+func scanEntsTo(w io.Writer, r io.Reader) error {
+ w.Write([]byte{'['})
+ defer w.Write([]byte{'\n', ']', '\n'})
+ // should technically be 1021 as valve's implementation stores "" & \0
+ const VMAXLEN = 1024
+
+ var candidate map[string]string
+ var key *string
+
+ var topLevel, inEnt, inString stateFn
+
+ topLevel = outsideString(func(c byte) stateFn {
+ if c == '{' {
+ candidate = map[string]string{}
+ return inEnt
+ }
+ return nil
+ })
+
+ nReported := 0
+ inEnt = outsideString(func(c byte) stateFn {
+ switch c {
+ case '"':
+ return inString
+ case '}':
+ if nReported > 0 {
+ w.Write([]byte{','})
+ }
+ w.Write([]byte{'\n', '\t'})
+ b, err := json.MarshalIndent(candidate, "\t", "\t")
+ if err != nil {
+ die(err)
+ }
+ _, err = w.Write(b)
+ if err != nil {
+ die(err)
+ }
+ nReported++
+ candidate = nil
+ return topLevel
+ }
+ return nil
+ })
+
+ // "runs" of unescaped characters. we may reread, so pains are taken to
+ // avoid eager copying. in the overwhelmingly common case of no escapes,
+ // `runBuf` is entirely bypassed.
+ runBuf := make([][]byte, VMAXLEN/2)
+ nt := [2]byte{'\n', '\t'}
+ inString = func(data []byte, atEOF bool) (int, []byte, error, stateFn) {
+ var i, iRun, runStart int
+ inEscape := false
+ for ; i < len(data); i++ {
+ if b := data[i]; !inEscape {
+ switch b {
+ default:
+ continue
+ case '"':
+ var str string
+ if iRun == 0 {
+ str = string(data[:i])
+ } else {
+ runBuf[iRun] = data[runStart:i]
+ str = string(slices.Concat(runBuf[:iRun+1]...))
+ }
+
+ if key == nil {
+ key = &str
+ } else {
+ candidate[*key] = str
+ key = nil
+ }
+ return i + 1, data[:i], nil, inEnt
+ case '\\':
+ runBuf[iRun] = data[runStart:i]
+ iRun++
+ inEscape = true
+ }
+ } else {
+ inEscape = false
+ runStart = i
+ switch b {
+ case 'n':
+ runBuf[iRun] = nt[:1]
+ case 't':
+ runBuf[iRun] = nt[1:]
+ case '\\', '"':
+ continue
+ default:
+ return i, nil, fmt.Errorf("invalid escape %q", b), nil
+ }
+ iRun++
+ runStart++
+ }
+ }
+
+ if len(data) >= VMAXLEN {
+ return 0, nil, errors.New("string too long"), nil
+ }
+ if atEOF {
+ return 0, nil, errors.New("truncated entity lump"), nil
+ }
+ return 0, nil, nil, inString
+ }
+
+ scanner := bufio.NewScanner(r)
+ curState := topLevel
+ scanner.Split(func(data []byte, eof bool) (adv int, tok []byte, err error) {
+ adv, tok, err, curState = curState(data, eof)
+ return
+ })
+ scanner.Buffer(make([]byte, VMAXLEN), VMAXLEN)
+ for scanner.Scan() {
+ }
+
+ if err := scanner.Err()
+ err != nil && !errors.Is(err, bufio.ErrFinalToken) {
+ return err
+ }
+ if candidate != nil || key != nil {
+ return errors.New("truncated entity lump")
+ }
+ return nil
+}
+
+func die(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
+
+func readerForBSP(f io.ReaderAt) (r io.Reader, err error) {
+ var fileofs, filelen int64
+
+ {
+ var order binary.ByteOrder
+
+ {
+ sigbuf := [4]byte{}
+ if _, err := f.ReadAt(sigbuf[:], 0); err != nil {
+ return nil, fmt.Errorf("header: %w", err)
+ }
+
+ switch 0 {
+ case bytes.Compare(sigbuf[:], []byte("VBSP")):
+ order = binary.LittleEndian
+ case bytes.Compare(sigbuf[:], []byte("PSBV")):
+ order = binary.BigEndian
+ default:
+ return nil, errors.New("header: no VBSP signature")
+ }
+ }
+
+ lumpbuf := [16]byte{}
+ if _, err := f.ReadAt(lumpbuf[:], 8); err != nil {
+ return nil, fmt.Errorf("header: %w", err)
+ }
+ // double conversion so reads fail if these go negative
+ fileofs = int64(int32(order.Uint32(lumpbuf[:4])))
+ filelen = int64(int32(order.Uint32(lumpbuf[4:8])))
+ }
+
+ lzmabuf := [17]byte{}
+ if _, err := f.ReadAt(lzmabuf[:], fileofs); err != nil {
+ return nil, fmt.Errorf("lump contents: %w", err)
+ }
+
+ // always little endian apparently.
+ // https://developer.valvesoftware.com/wiki/BSP_(Source)#Lump_compression
+ if bytes.Compare(lzmabuf[0:4], []byte("LZMA")) == 0 {
+ actualSize := binary.LittleEndian.Uint32(lzmabuf[4:8])
+ lzmaSize := int64(binary.LittleEndian.Uint32(lzmabuf[8:12]))
+ if lzmaSize+17 != filelen {
+ return nil, errors.New("filelen and lzmaSize disagree")
+ }
+ fakeHeader := [13]byte{}
+ copy(fakeHeader[0:5], lzmabuf[12:])
+ binary.LittleEndian.PutUint64(fakeHeader[5:], uint64(actualSize))
+ lzmaReader := io.MultiReader(
+ bytes.NewReader(fakeHeader[:]),
+ io.NewSectionReader(f, fileofs+17, lzmaSize),
+ )
+ r, err := lzma.NewReader(lzmaReader)
+ if err != nil {
+ return nil, fmt.Errorf("compressed lump: %w", err)
+ }
+ return r, nil
+ }
+ return io.NewSectionReader(f, fileofs, filelen), nil
+}
+
+func main() {
+ f, err := os.Open(os.Args[1])
+ if err != nil {
+ die(err)
+ }
+
+ r, err := readerForBSP(f)
+ if err != nil {
+ die(err)
+ }
+
+ bw := bufio.NewWriter(os.Stdout)
+ defer bw.Flush()
+ err = scanEntsTo(bw, r)
+
+ if err != nil {
+ die(err)
+ }
+}