diff --git a/.gitignore b/.gitignore
index 687d2cc..3978900 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,12 @@
*.sav
*.csv
*.json
+cmd/temp/
+*.dll
+node_modules/
+
+# embeded files built by pkger
+pkged.go
# built executables
civ3sat/civ3sat
diff --git a/civ3decompress/readfile.go b/civ3decompress/readfile.go
new file mode 100644
index 0000000..df3eff0
--- /dev/null
+++ b/civ3decompress/readfile.go
@@ -0,0 +1,44 @@
+package civ3decompress
+
+import (
+ "io/ioutil"
+ "os"
+)
+
+// ReadFile takes a filename and returns the decompressed file data or the raw data if it's not compressed. Also returns true if compressed.
+func ReadFile(path string) ([]byte, bool, error) {
+ // Open file, hanlde errors, defer close
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, false, FileError{err}
+ }
+ defer file.Close()
+
+ var compressed bool
+ var data []byte
+ header := make([]byte, 2)
+ _, err = file.Read(header)
+ if err != nil {
+ return nil, false, FileError{err}
+ }
+ // reset pointer to parse from beginning
+ _, err = file.Seek(0, 0)
+ if err != nil {
+ return nil, false, FileError{err}
+ }
+ switch {
+ case header[0] == 0x00 && (header[1] == 0x04 || header[1] == 0x05 || header[1] == 0x06):
+ compressed = true
+ data, err = Decompress(file)
+ if err != nil {
+ return nil, false, err
+ }
+ default:
+ // Not a compressed file. Proceeding with uncompressed stream.
+ data, err = ioutil.ReadFile(path)
+ if err != nil {
+ return nil, false, FileError{err}
+ }
+ }
+ return data, compressed, error(nil)
+}
diff --git a/civ3satgql/lookups.go b/civ3satgql/lookups.go
deleted file mode 100644
index 29a2c58..0000000
--- a/civ3satgql/lookups.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package civ3satgql
-
-import (
- "errors"
- "strconv"
-)
-
-// to make calling functions readable
-const Signed = true
-const Unsigned = false
-
-func ReadInt32(offset int, signed bool) int {
- n := int(saveGame.data[offset]) +
- int(saveGame.data[offset+1])*0x100 +
- int(saveGame.data[offset+2])*0x10000 +
- int(saveGame.data[offset+3])*0x1000000
- if signed && n > 0x80000000 {
- n = n - 0x80000000
- }
- return n
-}
-
-func ReadInt16(offset int, signed bool) int {
- n := int(saveGame.data[offset]) +
- int(saveGame.data[offset+1])*0x100
- if signed && n > 0x8000 {
- n = n - 0x8000
- }
- return n
-}
-
-func ReadInt8(offset int, signed bool) int {
- n := int(saveGame.data[offset])
- if signed && n > 0x80 {
- n = n - 0x80
- }
- return n
-}
-
-func SectionOffset(sectionName string, nth int) (int, error) {
- var i, n int
- for i < len(saveGame.sections) {
- if saveGame.sections[i].name == sectionName {
- n++
- if n >= nth {
- return saveGame.sections[i].offset + len(sectionName), nil
- }
- }
- i++
- }
- return -1, errors.New("Could not find " + strconv.Itoa(nth) + " section named " + sectionName)
-}
diff --git a/civ3satgql/query.go b/civ3satgql/query.go
deleted file mode 100644
index bdda353..0000000
--- a/civ3satgql/query.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package civ3satgql
-
-import (
- "encoding/base64"
- "encoding/hex"
-
- "github.com/graphql-go/graphql"
-)
-
-var queryType = graphql.NewObject(graphql.ObjectConfig{
- Name: "Query",
- Fields: graphql.Fields{
- "civ3": &graphql.Field{
- Type: civ3Type,
- Description: "Civ3 save data",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- wrldSection, err := SectionOffset("WRLD", 1)
- if err != nil {
- return nil, err
- }
- return worldData{worldOffset: wrldSection}, nil
- },
- },
- "bytes": &graphql.Field{
- Type: graphql.NewList(graphql.Int),
- Description: "Byte array",
- Args: graphql.FieldConfigArgument{
- "section": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.String),
- Description: "Four-character section name. e.g. TILE",
- },
- "nth": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "e.g. 2 for the second named section instance",
- },
- "offset": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Offset from start of section",
- },
- "count": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Number of bytes to return",
- },
- },
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- section, _ := p.Args["section"].(string)
- nth, _ := p.Args["nth"].(int)
- offset, _ := p.Args["offset"].(int)
- count, _ := p.Args["count"].(int)
- savSection, err := SectionOffset(section, nth)
- if err != nil {
- return nil, err
- }
- return saveGame.data[savSection+offset : savSection+offset+count], nil
- },
- },
- "base64": &graphql.Field{
- Type: graphql.String,
- Description: "Base64-encoded byte array",
- Args: graphql.FieldConfigArgument{
- "section": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.String),
- Description: "Four-character section name. e.g. TILE",
- },
- "nth": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "e.g. 2 for the second named section instance",
- },
- "offset": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Offset from start of section",
- },
- "count": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Number of bytes to return",
- },
- },
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- section, _ := p.Args["section"].(string)
- nth, _ := p.Args["nth"].(int)
- offset, _ := p.Args["offset"].(int)
- count, _ := p.Args["count"].(int)
- savSection, err := SectionOffset(section, nth)
- if err != nil {
- return nil, err
- }
- return base64.StdEncoding.EncodeToString(saveGame.data[savSection+offset : savSection+offset+count]), nil
- },
- },
- "hexString": &graphql.Field{
- Type: graphql.String,
- Description: "Base64-encoded byte array",
- Args: graphql.FieldConfigArgument{
- "section": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.String),
- Description: "Four-character section name. e.g. TILE",
- },
- "nth": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "e.g. 2 for the second named section instance",
- },
- "offset": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Offset from start of section",
- },
- "count": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Number of bytes to return",
- },
- },
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- section, _ := p.Args["section"].(string)
- nth, _ := p.Args["nth"].(int)
- offset, _ := p.Args["offset"].(int)
- count, _ := p.Args["count"].(int)
- savSection, err := SectionOffset(section, nth)
- if err != nil {
- return nil, err
- }
- return hex.EncodeToString(saveGame.data[savSection+offset : savSection+offset+count]), nil
- },
- },
- "int16s": &graphql.Field{
- Type: graphql.NewList(graphql.Int),
- Description: "Int16 array",
- Args: graphql.FieldConfigArgument{
- "section": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.String),
- Description: "Four-character section name. e.g. TILE",
- },
- "nth": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "e.g. 2 for the second named section instance",
- },
- "offset": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Offset from start of section",
- },
- "count": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Number of int16s to return",
- },
- },
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- section, _ := p.Args["section"].(string)
- nth, _ := p.Args["nth"].(int)
- offset, _ := p.Args["offset"].(int)
- count, _ := p.Args["count"].(int)
- savSection, err := SectionOffset(section, nth)
- if err != nil {
- return nil, err
- }
- intList := make([]int, count)
- for i := 0; i < count; i++ {
- intList[i] = ReadInt16(savSection+offset+2*i, Signed)
- }
- return intList, nil
- },
- },
- "int32s": &graphql.Field{
- Type: graphql.NewList(graphql.Int),
- Description: "Int32 array",
- Args: graphql.FieldConfigArgument{
- "section": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.String),
- Description: "Four-character section name. e.g. TILE",
- },
- "nth": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "e.g. 2 for the second named section instance",
- },
- "offset": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Offset from start of section",
- },
- "count": &graphql.ArgumentConfig{
- Type: graphql.NewNonNull(graphql.Int),
- Description: "Number of int32s to return",
- },
- },
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- section, _ := p.Args["section"].(string)
- nth, _ := p.Args["nth"].(int)
- offset, _ := p.Args["offset"].(int)
- count, _ := p.Args["count"].(int)
- savSection, err := SectionOffset(section, nth)
- if err != nil {
- return nil, err
- }
- intList := make([]int, count)
- for i := 0; i < count; i++ {
- intList[i] = ReadInt32(savSection+offset+4*i, Signed)
- }
- return intList, nil
- },
- },
- },
-})
diff --git a/civ3satgql/types.go b/civ3satgql/types.go
deleted file mode 100644
index 184649a..0000000
--- a/civ3satgql/types.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package civ3satgql
-
-import (
- "github.com/graphql-go/graphql"
-)
-
-type worldData struct {
- worldOffset int
-}
-
-var civ3Type = graphql.NewObject(graphql.ObjectConfig{
- Name: "civ3",
- Fields: graphql.Fields{
- "worldSeed": &graphql.Field{
- Type: graphql.Int,
- Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+170, Signed), nil
- }
- return -1, nil
- },
- },
- "climate": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+186, Signed), nil
- }
- return -1, nil
- },
- },
- "climateFinal": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+190, Signed), nil
- }
- return -1, nil
- },
- },
- "barbarians": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+194, Signed), nil
- }
- return -1, nil
- },
- },
- "barbariansFinal": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+198, Signed), nil
- }
- return -1, nil
- },
- },
- "landMass": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+202, Signed), nil
- }
- return -1, nil
- },
- },
- "landMassFinal": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+206, Signed), nil
- }
- return -1, nil
- },
- },
- "oceanCoverage": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+210, Signed), nil
- }
- return -1, nil
- },
- },
- "oceanCoverageFinal": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+214, Signed), nil
- }
- return -1, nil
- },
- },
- "temperature": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+218, Signed), nil
- }
- return -1, nil
- },
- },
- "temperatureFinal": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+222, Signed), nil
- }
- return -1, nil
- },
- },
- "age": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+226, Signed), nil
- }
- return -1, nil
- },
- },
- "ageFinal": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+230, Signed), nil
- }
- return -1, nil
- },
- },
- "size": &graphql.Field{
- Type: graphql.Int,
- // Description: "Random seed of random worlds",
- Resolve: func(p graphql.ResolveParams) (interface{}, error) {
- if wdat, ok := p.Source.(worldData); ok {
- return ReadInt32(wdat.worldOffset+234, Signed), nil
- }
- return -1, nil
- },
- },
- },
-})
diff --git a/cmd/cia3/findsav.go b/cmd/cia3/findsav.go
new file mode 100644
index 0000000..40151dc
--- /dev/null
+++ b/cmd/cia3/findsav.go
@@ -0,0 +1,72 @@
+package main
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "strings"
+
+ "github.com/myjimnelson/c3sat/queryciv3"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+// really should be const, but can't have literal string arrays as const
+var civInstallPathKeyTry = []string{
+ `SOFTWARE\WOW6432Node\Infogrames\Conquests`,
+ `SOFTWARE\Infogrames\Conquests`,
+}
+
+func findWinCivInstall() (string, error) {
+ var k registry.Key
+ var err error
+ for i := 0; i < len(civInstallPathKeyTry); i++ {
+ k, err = registry.OpenKey(registry.LOCAL_MACHINE, civInstallPathKeyTry[i], registry.QUERY_VALUE)
+ if err != nil {
+ return "", err
+ } else {
+ break
+ }
+ }
+ defer k.Close()
+ s, _, err := k.GetStringValue("install_path")
+ if err != nil {
+ return "", err
+ }
+ return s, nil
+}
+
+// Look in conquests.ini in the Civ3 Conquests install path for the "Lastest Save=" value
+func getLastSav(path string) (string, error) {
+ const key = `Latest Save=`
+ file, err := os.Open(path + `\conquests.ini`)
+ if err != nil {
+ return "", err
+ }
+ ini, err := ioutil.ReadAll(file)
+ if err != nil {
+ return "", err
+ }
+ var pathStart, pathEnd int
+ for i := 0; i < (len(ini) - len(key)); i++ {
+ if ini[i] == key[0] {
+ if string(ini[i:i+len(key)]) == key {
+ pathStart = i + len(key)
+ break
+ }
+ }
+ }
+ for i := pathStart; i < (len(ini)); i++ {
+ if ini[i] == '\r' || ini[i] == '\n' {
+ pathEnd = i
+ break
+ }
+ }
+ if pathEnd <= pathStart {
+ return "", fmt.Errorf("Failed to find Latest Save in conquests.ini")
+ }
+ // Assuming conquests.ini file is not UTF-8
+ s, err := queryciv3.CivString(ini[pathStart:pathEnd])
+ // My .ini has a space after the filename, so trimming leading/trailing whitespace
+ return strings.TrimSpace(s) + ".SAV", err
+}
diff --git a/cmd/cia3/fswatcher.go b/cmd/cia3/fswatcher.go
new file mode 100644
index 0000000..f841c07
--- /dev/null
+++ b/cmd/cia3/fswatcher.go
@@ -0,0 +1,97 @@
+package main
+
+import (
+ "log"
+ "os"
+ "strings"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/myjimnelson/c3sat/queryciv3"
+)
+
+type watchListType struct {
+ watches []string
+}
+
+// Does not check to see if already added
+func (w *watchListType) addWatch(path string) error {
+ err := savWatcher.Add(path)
+ if err != nil {
+ return err
+ }
+ w.watches = append(w.watches, path)
+ return nil
+}
+
+// Only deletes one
+func (w *watchListType) removeWatch(path string) error {
+ err := savWatcher.Remove(path)
+ if err != nil {
+ return err
+ }
+ for i := 0; i < len(w.watches); i++ {
+ if w.watches[i] == path {
+ // remove element from array by swapping last element and replacing with one-shorter array
+ w.watches[i] = w.watches[len(w.watches)-1]
+ w.watches[len(w.watches)-1] = ""
+ w.watches = w.watches[:len(w.watches)-1]
+ break
+ }
+ }
+ return nil
+}
+
+func loadDefaultBiq(s string) error {
+ fi, err := os.Stat(s)
+ if err != nil {
+ return err
+ }
+ if fi.Mode().IsRegular() {
+ err := queryciv3.ChangeDefaultBicPath(s)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func loadNewSav(s string) error {
+ if len(s) > 4 && strings.ToLower(s[len(s)-4:]) == ".sav" {
+ fi, err := os.Stat(s)
+ if err != nil {
+ return err
+ }
+ if fi.Mode().IsRegular() {
+ err := queryciv3.ChangeSavePath(s)
+ if err != nil {
+ return err
+ }
+ longPoll.Publish("refresh", s)
+ }
+ }
+ return nil
+}
+
+func watchSavs() {
+ var fn string
+ for {
+ select {
+ case event, ok := <-savWatcher.Events:
+ if !ok {
+ return
+ }
+ fn = event.Name
+ if event.Op&fsnotify.Write == fsnotify.Write {
+ debounceTimer.Reset(debounceInterval)
+ }
+ case <-debounceTimer.C:
+ // This will get called once debounceInterval after program start, and I'm going to live with that
+ loadNewSav(fn)
+ case err, ok := <-savWatcher.Errors:
+ if !ok {
+ return
+ }
+ log.Println("error:", err)
+ }
+ }
+}
diff --git a/cmd/cia3/fyneui.go b/cmd/cia3/fyneui.go
new file mode 100644
index 0000000..d63cc5e
--- /dev/null
+++ b/cmd/cia3/fyneui.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "net/url"
+
+ "fyne.io/fyne/app"
+ "fyne.io/fyne/widget"
+)
+
+func fyneUi() {
+ app := app.New()
+ u, err := url.Parse(httpUrlString)
+ if err != nil {
+ errorChannel <- err
+ }
+ w := app.NewWindow("Civ Intelligence Agency III")
+ w.SetContent(widget.NewVBox(
+ widget.NewLabel("Civ Intelligence Agency III"),
+ widget.NewLabel("v"+appVersion),
+ widget.NewLabel("Browse to the following link"),
+ widget.NewHyperlink(httpUrlString, u),
+ widget.NewButton("Quit", func() {
+ app.Quit()
+ }),
+ ))
+
+ w.ShowAndRun()
+}
diff --git a/cmd/cia3/html/about.html b/cmd/cia3/html/about.html
new file mode 100644
index 0000000..0b6b7d1
--- /dev/null
+++ b/cmd/cia3/html/about.html
@@ -0,0 +1,21 @@
+
+
+
+ About Civ Intelligence Agency III
+
+
+
+
+
+ Main menu
+ About Civ Intelligence Agency III
+ Civ Intelligence Agency III (CIA3) is intended to be a non-spoiling, non-cheating game assistant for single-human-player games of Sid Meier's Civilization III Complete and/or Conquests. Multiplayer non-spoiling support may be possible in the future.
+ CIA3 watches save game folder locations for new .SAV files, then reads the file and updates the information. Every time you save your game or begin a new turn, a new save or autosave will be generated and trigger CIA3 to update its info. It does not update mid-turn unless you save your game during the turn.
+ CIA3 currently reads the Windows registry to determine the default save and autosave folders for Civ3 and watches those. In the future I'll add the ability to watch specified folders and perhaps open files on demand.
+ The information provided by CIA3 is either available in-game, was available earlier in-game or during map creation, or accepted as not cheating by Civ III competitions. For example, the player may not be able to determine in-game if forests have been previously chopped for shield production on newly-revealed map tiles, but previous assistants (CivAssist II) have shown this information and have been allowed in competitive games. Another example is that previous tools allowed exact map tile counts when at least 80% of the map is revealed to allow for determining how far the player is from winning by domination.
+ Previous assistants such as CRpSuite's MapStat (download page) and CivAsist II (download page) worked well for many years, but neither of these seem to work in Windows 10.
+ I, Jim Nelson, or Puppeteer on CivFanatics Forums , have been on-and-off since 2013 been working on a save game reader for other purposes with Civ3 Show-And-Tell (C3SAT). Until 2020 I explicitly did not want to recreate a game assistant, but now that the others aren't working and seem to be abandoned by their creators, I started working on CIA3 from the C3SAT code base. CIA3 is a different product from C3SAT, but they share a common code base for most functions.
+ Release thread on CivFanatics Forums where new releases are announced. Discussion thread on CivFanatics Forums where I babble about development progress. CIA3 discussion begins on page 10
+ Source code is available on GitHub. During alpha relases, the code may only be in the develop branch .
+
+
\ No newline at end of file
diff --git a/cmd/cia3/html/cia3.css b/cmd/cia3/html/cia3.css
new file mode 100644
index 0000000..61f08a4
--- /dev/null
+++ b/cmd/cia3/html/cia3.css
@@ -0,0 +1,130 @@
+*,::before,::after {
+ box-sizing: border-box
+}
+body>*+*,main>*+* {
+ margin-top: 1em
+}
+body {
+ font-family: Verdana, Geneva, Tahoma, sans-serif;
+ min-height: 100vh;
+ text-rendering: optimizeSpeed;
+ color: #363940;
+ font-size: 1em;
+ line-height: 1.5em
+
+}
+li {
+ /* for click target size */
+ line-height: 1.5em;
+}
+li+li {
+ margin-top: 0.5em;
+}
+cia3-map {
+ --tile-width: 5em;
+ --tile-height: calc(var(--tile-width) / 2);
+ --tile-color: black;
+ --map-width: 3;
+
+ --desert: khaki;
+ --plains: goldenrod;
+ --grassland: yellowgreen;
+ --tundra: white;
+ --coast: lightseagreen;
+ --sea: mediumseagreen;
+ --ocean: seagreen;
+}
+
+cia3-error {
+ color: red;
+ display: block;
+}
+cia3-map {
+ display: flex;
+ /* flex-wrap and width needed to align the tiles into a map */
+ flex-wrap: wrap;
+ background-color: black;
+ width: calc(calc(var(--map-width) + 0.5) * var(--tile-width));
+ padding-bottom: calc(var(--tile-height) / 2);
+}
+cia3-tile {
+ display: flex;
+ margin-bottom: calc(var(--tile-height) * -0.5);
+ box-sizing: border-box;
+ /* margin: 0 0; */
+ /* needed to allow text div to overlay shape div */
+ position: relative;
+ /* need height & width here because of the 0-w/h div below */
+ width: var(--tile-width);
+ height: var(--tile-height);
+}
+cia3-tile.odd-row {
+ /* shift half-tile to the right */
+ margin-left: calc(var(--tile-width) / 2);
+}
+div.isotile {
+ display: flex;
+ box-sizing: border-box;
+ /* margin: 0 0; */
+ /* using borders and 0-w/h to make top half of a diamond */
+ width: 0;
+ height: 0;
+ border: calc(var(--tile-width) / 2) solid transparent;
+ border-bottom: calc(var(--tile-height) / 2) solid var(--tile-color);
+ position: absolute;
+ top: calc(var(--tile-width) / -2);
+}
+div.isotile:after {
+ /* using borders and 0-w/h to make bottom half of a diamond */
+ content: '';
+ position: absolute;
+ left: calc(var(--tile-width) / -2);
+ top: calc(var(--tile-height) / 2);
+ width: 0;
+ height: 0;
+ border: calc(var(--tile-width) / 2) solid transparent;
+ border-top: calc(var(--tile-height) / 2) solid var(--tile-color);
+}
+.tiletext, .terrain-overlay {
+ position: absolute;
+ width: var(--tile-width);
+ height: var(--tile-height);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+.chopped {
+ --size-ratio: 0.7;
+ position: absolute;
+ left: calc(var(--tile-width) / 2 - calc(var(--size-ratio) * var(--tile-width) / 2));
+ top: calc(var(--tile-height) / 2 - calc(var(--size-ratio) * var(--tile-height) / 2));
+ width: calc(var(--size-ratio) * var(--tile-width));
+ height: calc(var(--size-ratio) * var(--tile-height));
+ background: red;
+ border-radius: 50%;
+ opacity: 0.3;
+}
+cia3-hexdump {
+ display: block;
+ font-family: 'Consolas', 'Lucidia Console', 'Courier New', Courier, monospace;
+}
+cia3-civs > table > tr > td {
+ font-family: 'Consolas', 'Lucidia Console', 'Courier New', Courier, monospace;
+ text-align: right;
+ padding: 0 1em;
+}
+.dump {
+ font-family: 'Consolas', 'Lucidia Console', 'Courier New', Courier, monospace;
+ white-space: pre;
+ /* font-weight: bolder; */
+ font-size: x-large;
+}
+span.dim {
+ opacity: 0.33;
+}
+span.medium {
+ opacity: 0.66;
+}
+span.changed {
+ color: red;
+}
diff --git a/cmd/cia3/html/cia3.js b/cmd/cia3/html/cia3.js
new file mode 100644
index 0000000..5511b15
--- /dev/null
+++ b/cmd/cia3/html/cia3.js
@@ -0,0 +1,812 @@
+/*
+ loading this module starts long polling and xhr queries (if appropriate)
+ pollXhr implements long polling
+ if event received, launch xhr query
+ Each custom element adds its query part to gqlQuery (type GqlQuery)
+ use GraphQL aliases for each custom element for clarity, especially if arguments differ
+ GqlQuery dedups query parts and builds the xhr request body
+ Upon successful xhr query, "refresh" event dispatched
+ each custom element listens to refresh and renders itself when received
+ event "cia3Error" dispatched on errors, with detail being a string which the Error custom element will add to itself
+*/
+
+let xhr = new XMLHttpRequest();
+let pollXhr = new XMLHttpRequest();
+let pollSince = Date.now() - 86400000;
+const longPollTimeout = 30;
+let data = {};
+
+xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ data = JSON.parse(xhr.responseText).data;
+ const refreshData = new CustomEvent("refresh");
+ dispatchEvent(refreshData);
+ /* tried to see if can make alert come to front. Not with Chrome or FF, apparently
+ maybe check out web notifications instead
+ https://www.w3.org/TR/notifications/
+ https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+ https://developers.google.com/web/fundamentals/push-notifications
+ https://www.google.com/search?q=web+push+notifications
+ also, would not notify on every refresh; this would only be for alerts
+ */
+ // window.focus();
+ } else {
+ console.log(xhr);
+ let cia3Error = new CustomEvent("cia3Error", { 'detail' : `Received non-2xx response on data query. Live updates will continue, but the latest save file is not shown here.`});
+ dispatchEvent(cia3Error);
+ }
+}
+
+xhr.onerror = e => {
+ console.log(e);
+ let cia3Error = new CustomEvent("cia3Error", { 'detail' : `Data query request failed. Live updates will continue, but the latest save file is not shown here.`});
+ dispatchEvent(cia3Error);
+}
+
+let pollNow = () => {
+ pollXhr.open('GET', `/events?timeout=${longPollTimeout}&category=refresh&since_time=${pollSince}`);
+ pollXhr.send();
+}
+
+pollXhr.onload = () => {
+ if (pollXhr.status >= 200 & pollXhr.status < 300) {
+ let pollData = JSON.parse(pollXhr.responseText);
+ if (typeof pollData.events != 'undefined') {
+ pollSince = pollData.events[0].timestamp;
+ xhr.open('POST', '/graphql');
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.send(JSON.stringify(gqlQuery.body()));
+ }
+ if (pollData.timeout != undefined) {
+ pollSince = pollData.timestamp;
+ }
+ pollNow();
+ } else {
+ console.log("failed xhr request:", pollXhr);
+ let cia3Error = new CustomEvent("cia3Error", { 'detail' : `Received non-2xx response on polling query. Live updates have stopped.`});
+ dispatchEvent(cia3Error);
+ }
+}
+
+pollXhr.onerror = e => {
+ console.error("Long poll returned error");
+ console.log(e);
+ let cia3Error = new CustomEvent("cia3Error", { 'detail' : `Polling error. Live updates have stopped. Correct and refresh page.`});
+ dispatchEvent(cia3Error);
+}
+
+class GqlQuery {
+ // Using Set to deduplicate queries
+ queryParts = new Set();
+ query() {
+ return '{' + Array.from(this.queryParts).join('\n') + '}';
+ }
+ body() {
+ return {
+ 'query' : this.query()
+ }
+ }
+}
+let gqlQuery = new GqlQuery();
+
+// Most of the cia3-* elements follow this form, so extend this class
+class Cia3Element extends HTMLElement {
+ connectedCallback() {
+ this.registerAndListen();
+ }
+ render() {
+ this.innerText = 'REPLACE ME';
+ }
+ registerAndListen() {
+ gqlQuery.queryParts.add(this.queryPart);
+ window.addEventListener('refresh', () => this.render());
+ }
+ queryPart = 'REPLACE ME';
+}
+
+// TODO: Allow removal of error messages
+class Error extends HTMLElement {
+ connectedCallback() {
+ window.addEventListener('cia3Error', (e) => this.render(e.detail));
+ }
+ render(errMsg) {
+ const p = document.createElement('p');
+ p.innerText = errMsg;
+ this.appendChild(p);
+ }
+}
+
+class Filename extends Cia3Element {
+ render() {
+ this.innerText = data.fileName;
+ }
+ queryPart = 'fileName';
+}
+
+class Fullpath extends Cia3Element {
+ render() {
+ this.innerText = data.fullPath;
+ }
+ queryPart = 'fullPath';
+}
+
+class Difficulty extends Cia3Element {
+ render() {
+ this.innerText = data.difficultyNames[data.difficulty[0]].name;
+ }
+ queryPart = `
+ difficulty: int32s(section: "GAME", nth: 2, offset: 20, count: 1)
+ difficultyNames: listSection(target: "bic", section: "DIFF", nth: 1) { name: string(offset:0, maxLength: 64) }
+ `;
+}
+
+class Map extends Cia3Element {
+ connectedCallback() {
+ let spoilerMask = 0x2;
+ this.queryPart = `
+ map(playerSpoilerMask: ${spoilerMask}) {
+ tileSetWidth
+ tileSetHeight
+ tiles {
+ hexTerrain
+ chopped
+ }
+ }
+ `;
+ this.registerAndListen();
+ }
+ render() {
+ this.innerHTML = '';
+ let tilesWide = Math.floor(data.map.tileSetWidth / 2);
+ this.style.setProperty('--map-width', tilesWide);
+ data.map.tiles.forEach( (e, i) => {
+ const tile = document.createElement('cia3-tile');
+ if (e.hexTerrain) {
+ tile.setAttribute('data-terrain', e.hexTerrain);
+ }
+ if (e.chopped) {
+ tile.setAttribute('data-chopped', 'true');
+ }
+ if ((i + tilesWide) % data.map.tileSetWidth == 0) {
+ tile.classList.add('odd-row');
+ }
+ this.appendChild(tile);
+ });
+ }
+ queryPart = '';
+}
+
+class Tile extends HTMLElement {
+ connectedCallback () {
+ this.render();
+ }
+ baseTerrainCss = {
+ '0': 'desert',
+ '1': 'plains',
+ '2': 'grassland',
+ '3': 'tundra',
+ 'b': 'coast',
+ 'c': 'sea',
+ 'd': 'ocean'
+ }
+ overlayTerrain = {
+ '4': 'fp',
+ '5': 'hill',
+ '6': '⛰️',
+ '7': '🌲',
+ '8': '🌴',
+ '9': 'marsh',
+ 'a': '🌋'
+ }
+ render () {
+ const tileDiv = document.createElement('div');
+ this.appendChild(tileDiv);
+ tileDiv.classList.add('isotile');
+ if (this.dataset.chopped == 'true') {
+ const chopDiv = document.createElement('div');
+ chopDiv.classList.add('chopped');
+ this.appendChild(chopDiv);
+ }
+ let terr = this.dataset.terrain;
+ if (terr) {
+ if (this.baseTerrainCss[terr[1]]) {
+ this.style.setProperty('--tile-color', `var(--${this.baseTerrainCss[terr[1]]})`);
+ }
+ if (this.overlayTerrain[terr[0]]) {
+ const terrOverlayDiv = document.createElement('div');
+ this.appendChild(terrOverlayDiv);
+ terrOverlayDiv.className = 'terrain-overlay';
+ terrOverlayDiv.innerText = this.overlayTerrain[terr[0]];
+ }
+ }
+ let text = this.dataset.text;
+ if (text) {
+ const textDiv = document.createElement('div');
+ textDiv.classList.add('tiletext');
+ this.appendChild(textDiv);
+ }
+ }
+}
+
+class Url extends HTMLElement {
+ connectedCallback() {
+ this.render();
+ }
+ render() {
+ let url = location.protocol + "//" + location.host;
+ this.innerHTML = `${url} `;
+ }
+}
+
+// TODO: Add controls to customize query and re-query. And remove old query from gqlQuery.
+class HexDump extends Cia3Element {
+ render() {
+ this.innerText = 'Hex dump tool under construction, no controls yet.\n' + data.cia3Hexdump;
+ }
+ // queryPart = 'cia3Hexdump: hexDump(section: "WRLD", nth: 1, offset: 0, count: 8192)';
+ queryPart = 'cia3Hexdump: hexDump(section: "GAME", nth: 2, offset: 0, count: 8192)';
+}
+
+class MapX extends Cia3Element {
+ render() {
+ this.innerText = data.mapx[0];
+ }
+ queryPart = 'mapx: int32s(section: "WRLD", nth: 2, offset: 8, count: 1)';
+}
+
+class MapY extends Cia3Element {
+ render() {
+ this.innerText = data.mapy[0];
+ }
+ queryPart = 'mapy: int32s(section: "WRLD", nth: 2, offset: 28, count: 1)';
+}
+
+class WorldSize extends Cia3Element {
+ render() {
+ this.innerText = data.worldSizeNames[data.worldsize.size].name;
+ }
+ queryPart = `
+ worldsize: civ3 { size }
+ worldSizeNames: listSection(target: "bic", section: "WSIZ", nth: 1) { name: string(offset:32, maxLength: 32) }
+ `;
+}
+
+class Barbarians extends Cia3Element {
+ render() {
+ this.innerText = this.barbariansSettings[data.barbarians.barbariansFinal.toString()];
+ }
+ queryPart = 'barbarians: civ3 { barbariansFinal }';
+ barbariansSettings = {
+ '-1': 'No Barbarians',
+ '0': 'Sedentary',
+ '1': 'Roaming',
+ '2': 'Restless',
+ '3': 'Raging',
+ '4': 'Random'
+ };
+}
+
+class WorldSeed extends Cia3Element {
+ render() {
+ this.innerText = data.worldseed.worldSeed;
+ }
+ queryPart = 'worldseed: civ3 { worldSeed }';
+}
+
+class LandMass extends Cia3Element {
+ render() {
+ this.innerText = this.landMassNames[data.landmass.landMassFinal];
+ }
+ queryPart = 'landmass: civ3 { landMassFinal }';
+ landMassNames = [
+ "Archipelago",
+ "Continents",
+ "Pangea",
+ "Random"
+ ];
+}
+
+class OceanCoverage extends Cia3Element {
+ render() {
+ this.innerText = this.oceanCoverageNames[data.oceancoverage.oceanCoverageFinal];
+ }
+ queryPart = 'oceancoverage: civ3 { oceanCoverageFinal }';
+ oceanCoverageNames = [
+ "80% Water",
+ "70% Water",
+ "60% Water",
+ "Random"
+ ];
+}
+
+class Climate extends Cia3Element {
+ render() {
+ this.innerText = this.climateNames[data.climate.climateFinal];
+ }
+ queryPart = 'climate: civ3 { climateFinal }';
+ climateNames = [
+ "Arid",
+ "Normal",
+ "Wet",
+ "Random"
+ ];
+}
+
+class Temperature extends Cia3Element {
+ render() {
+ this.innerText = this.temperatureNames[data.temperature.temperatureFinal];
+ }
+ queryPart = 'temperature: civ3 { temperatureFinal }';
+ temperatureNames = [
+ "Warm",
+ "Temperate",
+ "Cool",
+ "Random"
+ ];
+}
+
+class Age extends Cia3Element {
+ render() {
+ this.innerText = this.ageNames[data.age.ageFinal];
+ }
+ queryPart = 'age: civ3 { ageFinal }';
+ ageNames = [
+ "3 Billion",
+ "4 Billion",
+ "5 Billion",
+ "Random"
+ ];
+}
+
+class Civs extends Cia3Element {
+ numFields = 111;
+ player = 1;
+ render() {
+ this.innerHTML = '';
+ const friendlyTable = document.createElement('table');
+ this.appendChild(friendlyTable);
+ friendlyTable.innerHTML = `
+ Civ
+ Contact
+ Relationship
+ Will Talk
+ Government
+ Era
+ City Count
+ `;
+ data.civs.filter(this.civsFilter, this).forEach((e, i) => {
+ const friendlyRow = document.createElement('tr');
+ const contactValue = data.civs[this.player].contactWith[e.playerNumber[0]];
+ const haveContact = contactValue != 0;
+ const warVarlue = data.civs[this.player].atWar[e.playerNumber[0]];
+ const atWar = warVarlue != 0;
+ // friendlyRow.innerHTML += `${e.playerNumber[0]} `;
+ friendlyRow.innerHTML += `${data.race[e.raceId[0]].civName} `;
+ friendlyRow.innerHTML += `${this.contactWithName(contactValue)} `;
+ friendlyRow.innerHTML += `${haveContact ? this.relationshipName(warVarlue) : '-'} `;
+ friendlyRow.innerHTML += `${haveContact ? this.willTalk(e) : '-'} `;
+ friendlyRow.innerHTML += `${haveContact ? data.governmentNames[e.governmentType[0]].name : '-'} `;
+ friendlyRow.innerHTML += `${haveContact ? data.eraNames[e.era[0]].name : '-'} `;
+ // TODO: I don't like using the string value here. Figure out how to use the number without repeating code
+ friendlyRow.innerHTML += `${haveContact && this.willTalk(e) == "Yes" ? e.cityCount[0] : '-'} `;
+ friendlyTable.appendChild(friendlyRow);
+ // if (this.oldCivsData != undefined) {
+ // // put alerts code in here
+ // }
+ })
+ this.oldCivsData = data.civs
+ }
+ civsFilter (e) {
+ return (e.raceId[0] > 0) && (this.player != e.playerNumber[0]); // non-barb, non "player" players
+ }
+ oldCivsData = undefined;
+ queryPart = `
+ civs {
+ playerNumber: int32s(offset:0, count: 1)
+ raceId: int32s(offset:4, count: 1)
+ governmentType: int32s(offset:132, count: 1)
+ era: int32s(offset:216, count: 1)
+ cityCount: int32s(offset:376, count: 1)
+ atWar: bytes(offset:3348, count: 32)
+ willTalkTo: int32s(offset:2964, count: 32)
+ contactWith: int32s(offset:3732, count: 32)
+ }
+ race {
+ civName: string(offset:128, maxLength: 40)
+ }
+ governmentNames: listSection(target: "bic", section: "GOVT", nth: 1) { name: string(offset:24, maxLength: 64) }
+ eraNames: listSection(target: "bic", section: "ERAS", nth: 1) { name: string(offset:0, maxLength: 64) }
+ `;
+ contactWithName (i) {
+ if (i==0) return "No";
+ // if (i==1) return "Yes";
+ // return "Yes (" + i.toString() + ")"; // apparently other flags exist for units in territory
+ return "Yes";
+ }
+ relationshipName (i) {
+ if (i==0) return "Peace";
+ // if (i==1) return "WAR";
+ // return "war? (" + i.toString() + ")"; // don't know what else there is
+ return "WAR";
+ }
+ willTalkToName (i) {
+ if (i==0) return "Yes";
+ // if (i==1) return "No";
+ // return "No (" + i.toString() + ")"; // don't know what else there is
+ return "NO";
+ }
+ willTalk (e) {
+ if (e.atWar[this.player]) {
+ return this.willTalkToName(e.willTalkTo[this.player]);
+ } else {
+ return this.willTalkToName(0);
+ }
+ }
+}
+
+class CivsDev extends Cia3Element {
+ numFields = 111;
+ render() {
+ const player = 1;
+ // this.innerHTML = JSON.stringify(data.civs, null, ' ');
+ this.innerHTML = '';
+ const table = document.createElement('table');
+ const friendlyTable = document.createElement('table');
+ const diplomaticTable = document.createElement('table');
+ const hexDumps = document.createElement('div');
+ hexDumps.classList += "dump";
+ this.appendChild(friendlyTable);
+ this.appendChild(table);
+ this.appendChild(diplomaticTable);
+ // table.innerHTML = 'Player # RACE ID ' + '? '.repeat(this.numFields - 2) + ' ';
+ let headers = "";
+ for (let i = 2; i < this.numFields; i++) {
+ headers += `${i} 0x${(i*4).toString(16)} ${i*4} `
+ }
+ table.innerHTML = `First ${this.numFields} int32s / Player RACE ID ` + headers + ' ';
+ const attitudeFieldCount = 23;
+ headers = "";
+ for (let i = 0; i < attitudeFieldCount; i++) {
+ headers += `${i} 0x${(i*4).toString(16)} ${i*4} `
+ }
+ diplomaticTable.innerHTML = 'attitude/diplomacy? Player - Opponent ' + headers + ' ';
+ friendlyTable.innerHTML = `
+ Friendly Table / Player #
+ Civ Name
+ Contact with player ${player.toString()}
+ Player ${player.toString()} Contact With Them
+ Relationship with player ${player.toString()}
+ Player ${player.toString()} Relationship With Them
+ Will Talk to player ${player.toString()}
+ Government
+ Mobilization
+ Tiles Discovered
+ Era
+ Research Beakers
+ Current Research Tech
+ Current Research Turns
+ # Future Techs
+ # Armies
+ # Units
+ # Miltary Units
+ # Cities
+ `;
+ data.civs.filter(this.civsFilter, this).forEach((e, i) => {
+ const friendlyRow = document.createElement('tr');
+ friendlyRow.innerHTML += `${e.playerNumber[0]} `;
+ friendlyRow.innerHTML += `${data.race[e.raceId[0]].civName} `;
+ friendlyRow.innerHTML += `${this.contactWithName(e.contactWith[player])} `;
+ friendlyRow.innerHTML += `${this.contactWithName(data.civs[player].contactWith[e.playerNumber[0]])} `;
+ friendlyRow.innerHTML += `${this.relationshipName(e.atWar[player])} `;
+ friendlyRow.innerHTML += `${this.relationshipName(data.civs[player].atWar[e.playerNumber[0]])} `;
+ // Unsure of willTalkTo data location, and unsure if it's an int32[32] array. Only see it for player 1 so far
+ // Seems to be turns until they talk without battle impacts, but also counts down a few turns after making peace
+ // Maybe this prevents them from redeclaring war for a few turns, too?
+ friendlyRow.innerHTML += `${this.willTalkToName(e.willTalkTo[player])} `;
+ friendlyRow.innerHTML += `${data.governmentNames[e.governmentType[0]].name} `;
+ friendlyRow.innerHTML += `${e.mobilizationLevel[0]} `;
+ friendlyRow.innerHTML += `${e.tilesDiscovered[0]} `;
+ friendlyRow.innerHTML += `${data.eraNames[e.era[0]].name} `;
+ friendlyRow.innerHTML += `${e.researchBeakers[0]} `;
+ let currentResearchId = e.currentResearchId[0];
+ let currentResearchName;
+ if (currentResearchId < 0 ) {
+ currentResearchName = "-"
+ } else {
+ currentResearchName = data.techNames[currentResearchId].name
+ }
+ friendlyRow.innerHTML += `${currentResearchName} `;
+ friendlyRow.innerHTML += `${e.currentResearchTurns[0]} `;
+ friendlyRow.innerHTML += `${e.futureTechsCount[0]} `;
+ friendlyRow.innerHTML += `${e.armiesCount[0]} `;
+ friendlyRow.innerHTML += `${e.unitCount[0]} `;
+ friendlyRow.innerHTML += `${e.militaryUnitCount[0]} `;
+ friendlyRow.innerHTML += `${e.cityCount[0]} `;
+ // friendlyRow.innerHTML += `${} `;
+ // friendlyRow.innerHTML += `${} `;
+ // friendlyRow.innerHTML += `${} `;
+ // friendlyRow.innerHTML += `${} `;
+ friendlyTable.appendChild(friendlyRow);
+ const row = document.createElement('tr');
+ e.int32s.forEach((ee, ii) => {
+ const td = document.createElement('td');
+ switch (ii) {
+ case 1:
+ if (ee != -1) {
+ td.innerText = data.race[ee].civName;
+ break;
+ }
+ default:
+ td.innerText = ee;
+ }
+ row.appendChild(td);
+ });
+ table.appendChild(row);
+
+ for (let i = 1; i < 9; i++) {
+ const intOffset = 23 * i;
+ const diploRow = document.createElement('tr');
+ diploRow.style = "color: lightgray;";
+ diploRow.innerHTML += `${e.playerNumber[0].toString()} - ${i} `;
+ for (let i = 0; i < 23; i++) {
+ let myValue = e.attitudes[intOffset + i]
+ diploRow.innerHTML += `${myValue.toString()} `;
+ }
+ diplomaticTable.appendChild(diploRow);
+ }
+
+ if (this.oldCivsData != undefined) {
+ const hexDiff = document.createElement('div');
+ // let foo = Diff.diffWordsWithSpace(this.oldCivsData[e.int32s[0]].hexDump, data.civs[e.int32s[0]].hexDump);
+ let foo = Diff.createTwoFilesPatch("old", "new" ,this.oldCivsData[e.int32s[0]].hexDump, data.civs[e.int32s[0]].hexDump);
+ // foo.forEach(function(part){
+ // // green for additions, red for deletions
+ // // grey for common parts
+ // let color = part.added ? 'green' :
+ // part.removed ? 'red' : 'grey';
+ // let span = document.createElement('span');
+ // span.style.color = color;
+ // span.appendChild(document
+ // .createTextNode(part.value));
+ // hexDiff.appendChild(span);
+ // });
+ let diff2Html = Diff2Html.html(foo, {
+ drawFileList: true,
+ matching: 'words',
+ outputFormat: 'side-by-side',
+ });
+ hexDiff.innerHTML = '*****\n#' + e.int32s[0] + ' : ' + data.race[e.int32s[1]].civName + ' Diff\n*****\n' + diff2Html;
+ this.appendChild(hexDiff);
+ }
+ hexDumps.innerHTML += '*****\n#' + e.int32s[0] + ' : ' + data.race[e.int32s[1]].civName + '\n*****\n' +
+ e.hexDump.replace(/((?: 00)+)/g, '$1 ')
+ .replace(/(\.+)/g, '$1 ')
+ .replace(/((?: ff)+)/g, '$1 ')
+ ;
+ })
+ this.appendChild(hexDumps);
+ this.oldCivsData = data.civs
+ }
+ civsFilter (e) {
+ return e.raceId[0] > 0; // non-barb players
+ // return e.playerNumber[0] == 1; // first human player only
+ }
+ oldCivsData = undefined;
+ queryPart = `
+ civs {
+ int32s(offset:0, count: ${this.numFields})
+ hexDump
+ playerNumber: int32s(offset:0, count: 1)
+ raceId: int32s(offset:4, count: 1)
+ governmentType: int32s(offset:132, count: 1)
+ mobilizationLevel: int32s(offset:136, count: 1)
+ tilesDiscovered: int32s(offset:140, count: 1)
+ era: int32s(offset:252, count: 1)
+ researchBeakers: int32s(offset:220, count: 1)
+ currentResearchId: int32s(offset:224, count: 1)
+ currentResearchTurns: int32s(offset:228, count: 1)
+ futureTechsCount: int32s(offset:232, count: 1)
+ armiesCount: int32s(offset:364, count: 1)
+ unitCount: int32s(offset:368, count: 1)
+ militaryUnitCount: int32s(offset:372, count: 1)
+ cityCount: int32s(offset:376, count: 1)
+ attitudes: int32s(offset:404, count: 736)
+ atWar: bytes(offset:3348, count: 32)
+ willTalkTo: int32s(offset:2964, count: 32)
+ contactWith: int32s(offset:3732, count: 32)
+ }
+ race {
+ leaderName: string(offset:0, maxLength: 32)
+ leaderTitle: string(offset:32, maxLength: 24)
+ adjective: string(offset:88, maxLength: 40)
+ civName: string(offset:128, maxLength: 40)
+ objectNoun: string(offset:168, maxLength: 40)
+ }
+ governmentNames: listSection(target: "bic", section: "GOVT", nth: 1) { name: string(offset:24, maxLength: 64) }
+ eraNames: listSection(target: "bic", section: "ERAS", nth: 1) { name: string(offset:0, maxLength: 64) }
+ techNames: listSection(target: "bic", section: "TECH", nth: 1) { name: string(offset:0, maxLength: 32) }
+ `;
+ contactWithName (i) {
+ if (i==0) return "No";
+ // if (i==1) return "Yes";
+ // return i.toString(); // don't know what else there is
+ return "Yes";
+ }
+ relationshipName (i) {
+ if (i==0) return "Peace";
+ // if (i==1) return "WAR";
+ // return i.toString(); // don't know what else there is
+ return "WAR";
+ }
+ willTalkToName (i) {
+ if (i==0) return "Yes";
+ // if (i==1) return "No";
+ // return "No: " + i.toString(); // don't know what else there is
+ return "No";
+ }
+}
+
+class CivTech extends Cia3Element {
+ render () {
+ this.innerHTML = '';
+ // tech list offset 852 + 4*numContinents? Grab a bunch more "techs" to ensure have all of them
+ // SEEMS TO WORK! TODO: better handle the tech count and offset; right now I'm just picking enough to hopefully cover likely continents and default # of techs
+ // Also TODO: I'm grabbing numContinents from *after* the tech data, so the real authoritative count must be elsewhere.
+ let intOffset = data.numContinents[0];
+ console.log(intOffset);
+ data.techList.forEach((e, i) => {
+ if (data.techCivMask[i+intOffset] != 0) {
+ this.innerHTML += `${e.name} -`;
+ data.civs.forEach((ee, ii) => {
+ if ((2**ii & data.techCivMask[i+intOffset]) !=0) {
+ console.log(i, i+intOffset, ii);
+ this.innerHTML += ` ${data.race[ee.raceId[0]].civName}`;
+ }
+ });
+ this.innerHTML += ` `;
+ }
+ });
+ }
+ queryPart = `civs {
+ playerNumber: int32s(offset:0, count: 1)
+ raceId: int32s(offset:4, count: 1)
+ }
+ race {
+ civName: string(offset:128, maxLength: 40)
+ }
+ techCivMask: int32s(section: "GAME", nth: 2, offset: 852, count: 140)
+ techList: listSection(target: "bic", section: "TECH", nth: 1) {
+ name: string(offset:0, maxLength: 32)
+ }
+ numContinents: int16s(section:"WRLD", nth: 1, offset: 4, count: 1)
+ `;
+}
+
+class HexDiffAll extends Cia3Element {
+ render () {
+ this.innerHTML = '';
+
+ this.innerHTML += `${data.fileName} `;
+ if (this.oldData != undefined) {
+ const hexDiff = document.createElement('div');
+ let foo = Diff.createTwoFilesPatch(this.oldData.fileName, data.fileName ,this.oldData.hexDumpAll, data.hexDumpAll);
+ let diff2Html = Diff2Html.html(foo, {
+ drawFileList: true,
+ matching: 'none',
+ outputFormat: 'side-by-side',
+ });
+ hexDiff.innerHTML = diff2Html;
+ this.appendChild(hexDiff);
+ }
+ this.oldData = data;
+ }
+ oldData = undefined;
+ queryPart = `fileName hexDumpAll`;
+}
+
+class Trade extends Cia3Element {
+ player = 1;
+ render () {
+ this.innerHTML = '';
+ const friendlyTable = document.createElement('table');
+ this.appendChild(friendlyTable);
+ friendlyTable.innerHTML = `
+ Civ
+ Tech to Buy
+ Tech to Sell
+ `;
+ data.tradeCivs.filter(this.willTalk, this).forEach((e, i) => {
+ const friendlyRow = document.createElement('tr');
+ friendlyRow.innerHTML += `${data.tradeRace[e.raceId[0]].civName} `;
+ let techsToBuy = new Array();
+ let techsToSell = new Array();
+ data.techList.forEach((ee, ii) => {
+ const thisTech = data.techCivMask[ii];
+ // If mismatched truthiness, one player has it and the other doesn't
+ if (!!(thisTech & 2**e.playerNumber[0]) != !!(thisTech & 2**this.player)) {
+ if (!(thisTech & 2**this.player)) {
+ if (this.hasPreReq(ii, this.player)) {
+ techsToBuy.push(ee.name);
+ }
+ } else {
+ if (this.hasPreReq(ii, e.playerNumber[0])) {
+ techsToSell.push(ee.name);
+ }
+ }
+ }
+ });
+ friendlyRow.innerHTML += `${techsToBuy.join(", ")} `;
+ friendlyRow.innerHTML += `${techsToSell.join(", ")} `;
+ friendlyTable.appendChild(friendlyRow);
+ });
+ }
+ willTalk(e) {
+ // Non-barb, non-player civs that exist, has contact with player, and will talk (not atWar OR willTalkTo == 0)
+ return e.raceId[0] > 0
+ && this.player != e.playerNumber[0]
+ && data.tradeCivs[this.player].contactWith[e.playerNumber[0]]
+ && (
+ e.atWar[this.player] !=0
+ || e.willTalkTo[this.player] == 0
+ )
+ ;
+ }
+ // Check tech prereqs for player, and also era requirement. return boolean
+ hasPreReq(techIndex, playerNumber) {
+ return (data.tradeCivs[playerNumber].era[0] >= data.techList[techIndex].era[0]) &&
+ data.techList[techIndex].prereqs.every(e => {
+ if (e < 0) {
+ return true;
+ }
+ if (!!(data.techCivMask[e] & 2**playerNumber)) {
+ return true;
+ }
+ return false;
+ });
+ }
+ queryPart = `
+ tradeRace: race {
+ civName: string(offset:128, maxLength: 40)
+ }
+ techCivMask
+ techList: listSection(target: "bic", section: "TECH", nth: 1) {
+ name: string(offset:0, maxLength: 32)
+ era: int32s(offset: 68, count: 1)
+ prereqs: int32s(offset: 84, count: 4)
+ }
+ tradeCivs: civs {
+ playerNumber: int32s(offset:0, count: 1)
+ raceId: int32s(offset:4, count: 1)
+ atWar: bytes(offset:3348, count: 32)
+ willTalkTo: int32s(offset:2964, count: 32)
+ contactWith: int32s(offset:3732, count: 32)
+ era: int32s(offset:216, count: 1)
+ }
+`;
+}
+
+
+window.customElements.define('cia3-error', Error);
+window.customElements.define('cia3-filename', Filename);
+window.customElements.define('cia3-fullpath', Fullpath);
+window.customElements.define('cia3-difficulty', Difficulty);
+window.customElements.define('cia3-map', Map);
+window.customElements.define('cia3-tile', Tile);
+window.customElements.define('cia3-url', Url);
+window.customElements.define('cia3-hexdump', HexDump);
+window.customElements.define('cia3-mapx', MapX);
+window.customElements.define('cia3-mapy', MapY);
+window.customElements.define('cia3-worldsize', WorldSize);
+window.customElements.define('cia3-barbarians', Barbarians);
+window.customElements.define('cia3-worldseed', WorldSeed);
+window.customElements.define('cia3-landmass', LandMass);
+window.customElements.define('cia3-oceancoverage', OceanCoverage);
+window.customElements.define('cia3-climate', Climate);
+window.customElements.define('cia3-temperature', Temperature);
+window.customElements.define('cia3-age', Age);
+window.customElements.define('cia3-civs', Civs);
+window.customElements.define('cia3-civsdev', CivsDev);
+window.customElements.define('cia3-civstech', CivTech);
+window.customElements.define('cia3-hexdiffall', HexDiffAll);
+window.customElements.define('cia3-trade', Trade);
+pollNow();
diff --git a/cmd/cia3/html/errors.html b/cmd/cia3/html/errors.html
new file mode 100644
index 0000000..691afe6
--- /dev/null
+++ b/cmd/cia3/html/errors.html
@@ -0,0 +1,52 @@
+
+
+
+ CIA3 Errors
+
+
+
+
+
+ Main menu
+ This page requires JavaScript to run.
+ Errors from cia3.exe
+
+
+
\ No newline at end of file
diff --git a/cmd/cia3/html/index.html b/cmd/cia3/html/index.html
new file mode 100644
index 0000000..9a686c5
--- /dev/null
+++ b/cmd/cia3/html/index.html
@@ -0,0 +1,42 @@
+
+
+
+ Civ Intelligence Agency III
+
+
+
+
+
+
+ This page requires JavaScript to run.
+ Save File Info
+
+
+
+ Difficulty:
+ Map size: ✕
+
+ Available Trades
+
+ Diplomacy Report
+
+ World Map Generation Settings
+ If this map was randomly generated by the game and not edited , the map can be recreated by setting the following settings in the new game menu:
+
+ World seed:
+ World size:
+ Barbarians:
+ Land Mass:
+ Ocean Coverage:
+ Climate:
+ Temperature:
+ Age:
+
+ Menu
+
+
+
\ No newline at end of file
diff --git a/cmd/cia3/html/map.html b/cmd/cia3/html/map.html
new file mode 100644
index 0000000..19277f8
--- /dev/null
+++ b/cmd/cia3/html/map.html
@@ -0,0 +1,21 @@
+
+
+
+ Civ Intelligence Agency III
+
+
+
+
+
+
+ Example Map
+ This page requires JavaScript to run.
+
+
+
+
+
+
+ Main menu
+
+
diff --git a/cmd/cia3/http.go b/cmd/cia3/http.go
new file mode 100644
index 0000000..81ba932
--- /dev/null
+++ b/cmd/cia3/http.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/markbates/pkger"
+ "github.com/myjimnelson/c3sat/queryciv3"
+)
+
+const addr = "127.0.0.1"
+
+var httpUrlString string
+var httpPort = "8080"
+var httpPortTry = []string{
+ ":8080",
+ ":8000",
+ ":8888",
+ ":0",
+}
+
+func setHeaders(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Set Origin headers for CORS
+ // yoinked from http://stackoverflow.com/questions/12830095/setting-http-headers-in-golang Matt Bucci's answer
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
+ w.Header().Set("Access-Control-Allow-Headers",
+ "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
+ }
+ // Since we're dynamically setting origin, don't let it get cached
+ w.Header().Set("Vary", "Origin")
+ handler.ServeHTTP(w, r)
+ })
+}
+
+// Set Content-Type explicitly since net/http FileServer seems to use Win registry which on many systems has wrong info for js and css in particular
+func setContentTypeHeaders(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s := r.RequestURI
+ if len(s) > 3 && strings.ToLower(s[len(s)-3:]) == ".js" {
+ w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ } else if len(s) > 4 && strings.ToLower(s[len(s)-4:]) == ".css" {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ } else if len(s) > 5 && strings.ToLower(s[len(s)-5:]) == ".html" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ }
+ handler.ServeHTTP(w, r)
+ })
+}
+
+func server() {
+ gQlHandler, err := queryciv3.GraphQlHandler()
+ if err != nil {
+ log.Fatal(err)
+ }
+ staticFiles := http.FileServer(pkger.Dir("/cmd/cia3/html"))
+ http.Handle("/", setContentTypeHeaders(staticFiles))
+ http.Handle("/graphql", setHeaders(gQlHandler))
+ http.Handle("/events", setHeaders(http.Handler(http.HandlerFunc(longPoll.SubscriptionHandler))))
+ err = http.ListenAndServe(addr+":"+httpPort, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/cia3/main.go b/cmd/cia3/main.go
new file mode 100644
index 0000000..141cca6
--- /dev/null
+++ b/cmd/cia3/main.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "net"
+ "strconv"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/jcuga/golongpoll"
+)
+
+// build with `-ldflags "-X main.appVersion=myVersionName"` to set version at compile time
+var appVersion = "0.4.1"
+
+var savWatcher *fsnotify.Watcher
+var debounceTimer *time.Timer
+var longPoll *golongpoll.LongpollManager
+var listener net.Listener
+var errorChannel = make(chan error, 20)
+var watchList = new(watchListType)
+
+const debounceInterval = 300 * time.Millisecond
+
+func loadStartupFiles(civPath string) {
+ var err error
+ err = loadDefaultBiq(civPath + `\conquests.biq`)
+ if err != nil {
+ errorChannel <- err
+ }
+ lastSav, err := getLastSav(civPath)
+ if err != nil {
+ errorChannel <- err
+ } else {
+ err = loadNewSav(lastSav)
+ if err != nil {
+ errorChannel <- err
+ }
+ }
+}
+
+func main() {
+ var err error
+ savWatcher, err = fsnotify.NewWatcher()
+ if err != nil {
+ errorChannel <- err
+ }
+ defer savWatcher.Close()
+
+ // Set up file event handler
+ debounceTimer = time.NewTimer(debounceInterval)
+ go watchSavs()
+
+ // Initialize long poll manager
+ longPoll, err = golongpoll.StartLongpoll(golongpoll.Options{})
+ if err != nil {
+ errorChannel <- err
+ }
+ defer longPoll.Shutdown()
+
+ // Read Win registry for Civ3 Conquests path
+ civPath, err := findWinCivInstall()
+ if err == nil {
+ loadStartupFiles(civPath)
+ // Add Saves and Saves\Auto folder watches
+ err = watchList.addWatch(civPath + `\Saves`)
+ if err != nil {
+ errorChannel <- err
+ }
+ err = watchList.addWatch(civPath + `\Saves\Auto`)
+ if err != nil {
+ errorChannel <- err
+ }
+
+ } else {
+ errorChannel <- err
+ }
+
+ for i := 0; i < len(httpPortTry); i++ {
+ listener, err = net.Listen("tcp", httpPortTry[i])
+ if err == nil {
+ break
+ }
+ }
+ if listener == nil {
+ panic(err)
+ }
+
+ httpPort = strconv.Itoa(listener.Addr().(*net.TCPAddr).Port)
+ httpUrlString = "http://" + addr + ":" + httpPort + "/"
+
+ // Api server
+ go server()
+
+ go func() {
+ for {
+ select {
+ case err := <-errorChannel:
+ longPoll.Publish("exeError", err.Error())
+ }
+ }
+ }()
+
+ // fyne GUI with hyperlink
+ fyneUi()
+}
diff --git a/cmd/cia3/readme.md b/cmd/cia3/readme.md
new file mode 100644
index 0000000..8ade4fa
--- /dev/null
+++ b/cmd/cia3/readme.md
@@ -0,0 +1,12 @@
+Note: Must run `pkger -o /cmd/cia3` before building cmd/cia3 to create pkged.go
+in this directory.
+
+Also, pass the `-ldflags="-H windowsgui"` flag to `go build` to avoid having a
+console window open while the GUI is open with Windows.
+
+### Bundled Javascript packages
+
+I've included some js bundles in this repo. I'm currently using them for analyzing diffs between hex dumps in civs.html. I don't have a one-step build script; I just copied them over manually.
+
+- [jsdiff](https://github.com/kpdecker/jsdiff) - html/diff has the dist/ from this package
+- [diff2html](https://github.com/rtfpessoa/diff2html) - html/diff2html has some of the bundles/ files from this package
\ No newline at end of file
diff --git a/civ3sat/main.go b/cmd/civ3sat/main.go
similarity index 88%
rename from civ3sat/main.go
rename to cmd/civ3sat/main.go
index 379e56e..b620f47 100644
--- a/civ3sat/main.go
+++ b/cmd/civ3sat/main.go
@@ -8,8 +8,8 @@ import (
"os"
"text/tabwriter"
- "github.com/myjimnelson/c3sat/civ3satgql"
- "github.com/myjimnelson/c3sat/parseciv3"
+ "github.com/myjimnelson/c3sat/civ3decompress"
+ "github.com/myjimnelson/c3sat/queryciv3"
"github.com/urfave/cli"
)
@@ -42,7 +42,7 @@ func main() {
w := new(tabwriter.Writer)
defer w.Flush()
w.Init(os.Stdout, 0, 8, 0, '\t', 0)
- settings, err := civ3satgql.WorldSettings(saveFilePath)
+ settings, err := queryciv3.WorldSettings(saveFilePath)
if err != nil {
return cli.NewExitError(err, 1)
}
@@ -60,7 +60,7 @@ func main() {
pathFlag,
},
Action: func(c *cli.Context) error {
- filedata, _, err := parseciv3.ReadFile(saveFilePath)
+ filedata, _, err := civ3decompress.ReadFile(saveFilePath)
if err != nil {
return err
}
@@ -83,7 +83,7 @@ func main() {
pathFlag,
},
Action: func(c *cli.Context) error {
- filedata, _, err := parseciv3.ReadFile(saveFilePath)
+ filedata, _, err := civ3decompress.ReadFile(saveFilePath)
if err != nil {
return err
}
@@ -104,7 +104,7 @@ func main() {
// var gameData parseciv3.Civ3Data
var err error
query := c.Args().First()
- result, err := civ3satgql.Query(query, saveFilePath)
+ result, err := queryciv3.Query(query, saveFilePath)
if err != nil {
return cli.NewExitError(err, 1)
}
@@ -136,7 +136,7 @@ func main() {
fmt.Println("Starting API server for save file at " + saveFilePath)
fmt.Println("GraphQL at http://" + c.String("addr") + ":" + c.String("port") + "/graphql")
fmt.Println("Press control-C to exit")
- err = civ3satgql.Server(saveFilePath, c.String("addr"), c.String("port"))
+ err = queryciv3.Server(saveFilePath, c.String("addr"), c.String("port"))
if err != nil {
return cli.NewExitError(err, 1)
}
diff --git a/forpkger.go b/forpkger.go
new file mode 100644
index 0000000..f0834c9
--- /dev/null
+++ b/forpkger.go
@@ -0,0 +1,6 @@
+package foo
+
+// Adding this so I can `pkger -o /cmd/cia3` successfully`
+// see https://github.com/markbates/pkger/issues/44
+
+// However this seems to preclude running pkger without a dir arg
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4ddf724
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,17 @@
+module github.com/myjimnelson/c3sat
+
+go 1.13
+
+require (
+ fyne.io/fyne v1.2.2
+ github.com/fsnotify/fsnotify v1.4.7
+ github.com/fyne-io/examples v0.0.0-20200129224505-9a163725bd88
+ github.com/graphql-go/graphql v0.7.8
+ github.com/graphql-go/handler v0.2.3
+ github.com/jcuga/golongpoll v1.1.0
+ github.com/markbates/pkger v0.14.0
+ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
+ github.com/urfave/cli v1.22.2
+ golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c
+ golang.org/x/text v0.3.0
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..aa64683
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,85 @@
+fyne.io/fyne v1.2.2 h1:mf7EseASp3CAC5vLWVPLnsoKxvp/ARdu3Seh0HvAQak=
+fyne.io/fyne v1.2.2/go.mod h1:Ab+3DIB/FVteW0y4DXfmZv4N3JdnCBh2lHkINI02BOU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
+github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I=
+github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fyne-io/examples v0.0.0-20200129224505-9a163725bd88 h1:wHMue6TbV3zPoeqZSzPJ8600wBqvFR2NRLKggCkeAAs=
+github.com/fyne-io/examples v0.0.0-20200129224505-9a163725bd88/go.mod h1:rHPQoqSHVbwh5plHCbtCO2VLyi/6J2a1svL76LGo4fo=
+github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 h1:SCYMcCJ89LjRGwEa0tRluNRiMjZHalQZrVrvTbPh+qw=
+github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk=
+github.com/go-gl/glfw v0.0.0-20181213070059-819e8ce5125f h1:7MsFMbSn8Lcw0blK4+NEOf8DuHoOBDhJsHz04yh13pM=
+github.com/go-gl/glfw v0.0.0-20181213070059-819e8ce5125f/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
+github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
+github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff h1:W71vTCKoxtdXgnm1ECDFkfQnpdqAO00zzGXLA5yaEX8=
+github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff/go.mod h1:wfqRWLHRBsRgkp5dmbG56SA0DmVtwrF5N3oPdI8t+Aw=
+github.com/graphql-go/graphql v0.7.8 h1:769CR/2JNAhLG9+aa8pfLkKdR0H+r5lsQqling5WwpU=
+github.com/graphql-go/graphql v0.7.8/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI=
+github.com/graphql-go/handler v0.2.3 h1:CANh8WPnl5M9uA25c2GBhPqJhE53Fg0Iue/fRNla71E=
+github.com/graphql-go/handler v0.2.3/go.mod h1:leLF6RpV5uZMN1CdImAxuiayrYYhOk33bZciaUGaXeU=
+github.com/jackmordaunt/icns v0.0.0-20181231085925-4f16af745526/go.mod h1:UQkeMHVoNcyXYq9otUupF7/h/2tmHlhrS2zw7ZVvUqc=
+github.com/jcuga/golongpoll v1.1.0 h1:2CWYEd5sH23aOxzMZ9xw5yjiZC8XdNG5wa70+KBkJrQ=
+github.com/jcuga/golongpoll v1.1.0/go.mod h1:sM63poxLraGZiLM83pQwKL3GbcPxWaxWcD7Hj94CcBc=
+github.com/josephspurrier/goversioninfo v0.0.0-20190124120936-8611f5a5ff3f/go.mod h1:eJTEwMjXb7kZ633hO3Ln9mBUCOjX2+FlTljvpl9SYdE=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/markbates/pkger v0.14.0 h1:z6KCEBkr3zJTkAMz5SJzjA9Izo+Ipb6XXvOIjQEW+PU=
+github.com/markbates/pkger v0.14.0/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
+github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/srwiley/oksvg v0.0.0-20190829233741-58e08c8fe40e h1:LJUrNHytcMXWKxnULIHPe5SCb1jDpO9o672VB1x2EuQ=
+github.com/srwiley/oksvg v0.0.0-20190829233741-58e08c8fe40e/go.mod h1:afMbS0qvv1m5tfENCwnOdZGOF8RGR/FsZ7bvBxQGZG4=
+github.com/srwiley/rasterx v0.0.0-20181219215540-696f7edb7a7e h1:FFotfUvew9Eg02LYRl8YybAnm0HCwjjfY5JlOI1oB00=
+github.com/srwiley/rasterx v0.0.0-20181219215540-696f7edb7a7e/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo=
+github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/net v0.0.0-20191204025024-5ee1b9f4859a h1:+HHJiFUXVOIS9mr1ThqkQD1N8vpFCfCShqADBM12KTc=
+golang.org/x/net v0.0.0-20191204025024-5ee1b9f4859a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c h1:gUYreENmqtjZb2brVfUas1sC6UivSY8XwKwPo8tloLs=
+golang.org/x/sys v0.0.0-20200117145432-59e60aa80a0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
+gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/parseciv3/parseciv3.go b/parseciv3/parseciv3.go
index 33bdda1..370da93 100644
--- a/parseciv3/parseciv3.go
+++ b/parseciv3/parseciv3.go
@@ -1,6 +1,6 @@
package parseciv3
-// Copyright (c) 2016 Jim Nelson
+// Copyright (c) 2020 Jim Nelson
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
@@ -26,52 +26,10 @@ import (
"encoding/binary"
"encoding/hex"
"io"
- "io/ioutil"
- "os"
"github.com/myjimnelson/c3sat/civ3decompress"
)
-// ReadFile takes a filename and returns the decompressed file data or the raw data if it's not compressed. Also returns true if compressed.
-func ReadFile(path string) ([]byte, bool, error) {
- // Open file, hanlde errors, defer close
- file, err := os.Open(path)
- if err != nil {
- return nil, false, FileError{err}
- }
- defer file.Close()
-
- var compressed bool
- var data []byte
- header := make([]byte, 2)
- _, err = file.Read(header)
- if err != nil {
- return nil, false, FileError{err}
- }
- // reset pointer to parse from beginning
- _, err = file.Seek(0, 0)
- if err != nil {
- return nil, false, FileError{err}
- }
- switch {
- case header[0] == 0x00 && (header[1] == 0x04 || header[1] == 0x05 || header[1] == 0x06):
- compressed = true
- data, err = civ3decompress.Decompress(file)
- if err != nil {
- return nil, false, err
- }
- default:
- // log.Println("Not a compressed file. Proceeding with uncompressed stream.")
- // TODO: I'm sure I'm doing this in a terribly inefficient way. Need to refactor everything to pass around file pointers I think
- data, err = ioutil.ReadFile(path)
- if err != nil {
- return nil, false, FileError{err}
- }
- }
- return data, compressed, error(nil)
-
-}
-
// TODO: Do I really need or want rawFile in the struct?
// A: yes, while decoding, anyway. Or maybe I can include a lookahead dumping field instead of the entire file?
@@ -86,7 +44,7 @@ func NewCiv3Data(path string) (Civ3Data, error) {
civ3data.FileName = path
// Load file into struct for parsing
- rawFile, compressed, err := ReadFile(path)
+ rawFile, compressed, err := civ3decompress.ReadFile(path)
if err != nil {
return civ3data, err
}
diff --git a/queryciv3/lookups.go b/queryciv3/lookups.go
new file mode 100644
index 0000000..9df6233
--- /dev/null
+++ b/queryciv3/lookups.go
@@ -0,0 +1,84 @@
+package queryciv3
+
+import (
+ "errors"
+ "io/ioutil"
+ "strconv"
+ "strings"
+
+ "golang.org/x/text/encoding/charmap"
+)
+
+// to make calling functions readable
+const Signed = true
+const Unsigned = false
+
+//deprecating in favor of *saveGameType.readInt32()
+func ReadInt32(offset int, signed bool) int {
+ n := int(saveGame.data[offset]) +
+ int(saveGame.data[offset+1])*0x100 +
+ int(saveGame.data[offset+2])*0x10000 +
+ int(saveGame.data[offset+3])*0x1000000
+ if signed && n&0x80000000 != 0 {
+ n = -(n ^ 0xffffffff + 1)
+ }
+ return n
+}
+
+//deprecating in favor of *saveGameType.readInt16()
+func ReadInt16(offset int, signed bool) int {
+ n := int(saveGame.data[offset]) +
+ int(saveGame.data[offset+1])*0x100
+ if signed && n&0x8000 != 0 {
+ n = -(n ^ 0xffff + 1)
+ }
+ return n
+}
+
+//deprecating in favor of *saveGameType.readInt8()
+func ReadInt8(offset int, signed bool) int {
+ n := int(saveGame.data[offset])
+ if signed && n&0x80 != 0 {
+ n = -(n ^ 0xff + 1)
+ }
+ return n
+}
+
+//deprecating in favor of *saveGameType.sectionOffset()
+func SectionOffset(sectionName string, nth int) (int, error) {
+ var i, n int
+ for i < len(saveGame.sections) {
+ if saveGame.sections[i].name == sectionName {
+ n++
+ if n >= nth {
+ return saveGame.sections[i].offset + len(sectionName), nil
+ }
+ }
+ i++
+ }
+ return -1, errors.New("Could not find " + strconv.Itoa(nth) + " section named " + sectionName)
+}
+
+// CivString Finds null-terminated string and converts from Windows-1252 to UTF-8
+func CivString(b []byte) (string, error) {
+ var win1252 string
+ var i int
+ for i = 0; i < len(b); i++ {
+ if b[i] == 0 {
+ win1252 = string(b[:i])
+ break
+ }
+ }
+ if i == len(b) {
+ win1252 = string(b)
+ }
+ sr := strings.NewReader(win1252)
+ tr := charmap.Windows1252.NewDecoder().Reader(sr)
+
+ outUtf8, err := ioutil.ReadAll(tr)
+ if err != nil {
+ return "", err
+ }
+
+ return string(outUtf8), nil
+}
diff --git a/queryciv3/query.go b/queryciv3/query.go
new file mode 100644
index 0000000..73fe453
--- /dev/null
+++ b/queryciv3/query.go
@@ -0,0 +1,515 @@
+package queryciv3
+
+import (
+ "encoding/base64"
+ "encoding/hex"
+
+ "github.com/graphql-go/graphql"
+)
+
+var queryType = graphql.NewObject(graphql.ObjectConfig{
+ Name: "Query",
+ Fields: graphql.Fields{
+ "civ3": &graphql.Field{
+ Type: civ3Type,
+ Description: "Civ3 save data",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ wrldSection, err := SectionOffset("WRLD", 1)
+ if err != nil {
+ return nil, err
+ }
+ return worldData{worldOffset: wrldSection}, nil
+ },
+ },
+ "fullPath": &graphql.Field{
+ Type: graphql.String,
+ Description: "Save file path",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ return saveGame.path, nil
+ },
+ },
+ "fileName": &graphql.Field{
+ Type: graphql.String,
+ Description: "Save file name",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ return saveGame.fileName(), nil
+ },
+ },
+ "map": &graphql.Field{
+ Type: mapType,
+ Description: "Current Game Map",
+ Args: graphql.FieldConfigArgument{
+ "playerSpoilerMask": &graphql.ArgumentConfig{
+ Type: graphql.Int,
+ Description: "Bitmask of map tile per-player spoilers; default is 0x2 to show first human player. Set to 0 to return all map tiles.",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ var mdata mapData
+ if spoilerMask, ok := p.Args["playerSpoilerMask"].(int); ok {
+ mdata.playerSpoilerMask = int32(spoilerMask)
+ } else {
+ mdata.playerSpoilerMask = int32(0x2)
+ }
+ // Get 2nd WRLD offset
+ section, err := SectionOffset("WRLD", 2)
+ if err != nil {
+ return nil, err
+ }
+ // Reading 6 int32s at offset 8; first is height, last is Width
+ intList := make([]int, 6)
+ for i := 0; i < 6; i++ {
+ intList[i] = ReadInt32(section+8+4*i, Signed)
+ }
+ mdata.mapHeight = intList[0]
+ mdata.mapWidth = intList[5]
+ mdata.tilesOffset, err = SectionOffset("TILE", 1)
+ if err != nil {
+ return nil, err
+ }
+ var mapRowLength = mdata.mapWidth / 2
+ var mapTileCount = mapRowLength * mdata.mapHeight
+ mdata.mapTileOffsets = make([]int, mapTileCount)
+ var minY, maxY int
+ var mapXVisible = make([]bool, mapRowLength)
+ minY = mdata.mapHeight - 1
+ for i := 0; i < mapTileCount; i++ {
+ tileOffset := mdata.tilesOffset - 4 + (mdata.tileSetY+i/mapRowLength)*mapRowLength*tileBytes + (mdata.tileSetX+i%mapRowLength)*tileBytes
+ if mdata.spoilerFree(tileOffset) {
+ mdata.mapTileOffsets[i] = tileOffset
+ x := i % mapRowLength
+ mapXVisible[x] = true
+ y := i / mapRowLength
+ if y < minY {
+ minY = y
+ }
+ if y > maxY {
+ maxY = y
+ }
+ } else {
+ // tile elements will return null if offset <= 0
+ mdata.mapTileOffsets[i] = -1
+ }
+ }
+ // Need to ensure the first Y row is even because of how each odd row shifts a half tile right
+ if minY%2 != 0 {
+ minY -= 1
+ }
+ // See if it makes sense to wrap around X
+ var longestBlank int
+ var blankLength int
+ // loop through width twice to ensure find the longest run of blanks, if any
+ for i := 0; i < mapRowLength*2; i++ {
+ if mapXVisible[i%mapRowLength] {
+ if blankLength > 0 {
+ if blankLength > longestBlank {
+ longestBlank = blankLength
+ mdata.tileSetX = (i % mapRowLength) * 2
+ }
+ blankLength = 0
+ }
+ } else {
+ blankLength++
+ }
+ }
+ mdata.tileSetWidth = (mapRowLength - longestBlank) * 2
+ mdata.tileSetHeight = maxY - minY + 1
+ mdata.tileSetY = minY
+ var tileSetRowLength = mdata.tileSetWidth / 2
+ var tileSetCount = tileSetRowLength * mdata.tileSetHeight
+ var minX = mdata.tileSetX / 2
+ mdata.tileSetOffsets = make([]int, tileSetCount)
+ for i := 0; i < tileSetCount; i++ {
+ mdata.tileSetOffsets[i] = mdata.mapTileOffsets[(i/tileSetRowLength+minY)*mdata.mapWidth/2+(i%tileSetRowLength+minX)%(mdata.mapWidth/2)]
+ }
+ return mdata, nil
+ },
+ },
+ "bytes": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Byte array",
+ Args: graphql.FieldConfigArgument{
+ "target": &graphql.ArgumentConfig{
+ Type: graphql.String,
+ Description: "Target scope of the query. Can be game, bic, or file (default)",
+ DefaultValue: "file",
+ },
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of section",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of bytes to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ var target *saveGameType
+ targetArg, _ := p.Args["target"].(string)
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ switch targetArg {
+ case "game":
+ target = ¤tGame
+ case "bic":
+ target = ¤tBic
+ default:
+ target = &saveGame
+ }
+ savSection, err := target.sectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ return target.data[savSection+offset : savSection+offset+count], nil
+ },
+ },
+ "base64": &graphql.Field{
+ Type: graphql.String,
+ Description: "Base64-encoded byte array",
+ Args: graphql.FieldConfigArgument{
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of section",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of bytes to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ savSection, err := SectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ return base64.StdEncoding.EncodeToString(saveGame.data[savSection+offset : savSection+offset+count]), nil
+ },
+ },
+ "hexString": &graphql.Field{
+ Type: graphql.String,
+ Description: "Byte array in hex string format",
+ Args: graphql.FieldConfigArgument{
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of section",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of bytes to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ savSection, err := SectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ return hex.EncodeToString(saveGame.data[savSection+offset : savSection+offset+count]), nil
+ },
+ },
+ "hexDump": &graphql.Field{
+ Type: graphql.String,
+ Description: "Hex dump of data",
+ Args: graphql.FieldConfigArgument{
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of section",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of bytes to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ savSection, err := SectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ return hex.Dump(saveGame.data[savSection+offset : savSection+offset+count]), nil
+ },
+ },
+ "hexDumpAll": &graphql.Field{
+ Type: graphql.String,
+ Description: "Hex dump of all the data",
+ Args: graphql.FieldConfigArgument{
+ "target": &graphql.ArgumentConfig{
+ Type: graphql.String,
+ Description: "Target scope of the query. Can be game (default), bic, or file",
+ DefaultValue: "game",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ var target *saveGameType
+ targetArg, _ := p.Args["target"].(string)
+ switch targetArg {
+ case "file":
+ target = &saveGame
+ case "bic":
+ target = ¤tBic
+ default:
+ target = ¤tGame
+ }
+ return hex.Dump(target.data), nil
+ },
+ },
+ "int16s": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Int16 array",
+ Args: graphql.FieldConfigArgument{
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of section",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of int16s to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ savSection, err := SectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ intList := make([]int, count)
+ for i := 0; i < count; i++ {
+ intList[i] = ReadInt16(savSection+offset+2*i, Signed)
+ }
+ return intList, nil
+ },
+ },
+ "int32s": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Int32 array",
+ Args: graphql.FieldConfigArgument{
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of section",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of int32s to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ savSection, err := SectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ intList := make([]int, count)
+ for i := 0; i < count; i++ {
+ intList[i] = ReadInt32(savSection+offset+4*i, Signed)
+ }
+ return intList, nil
+ },
+ },
+ "allStrings": &graphql.Field{
+ Type: graphql.NewList(graphql.String),
+ Description: "All ASCII strings four bytes or longer",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ var i, count, offset int
+ var output = make([]string, 0)
+ for i < len(saveGame.data) {
+ if saveGame.data[i] < 0x20 || saveGame.data[i] > 0x7F {
+ if count > 3 {
+ s := string(saveGame.data[offset:i])
+ output = append(output, s)
+ }
+ count = 0
+ } else {
+ if count == 0 {
+ offset = i
+ }
+ count++
+ }
+ i++
+ }
+ return output, nil
+ },
+ },
+ "listSection": &graphql.Field{
+ Type: graphql.NewList(listSectionItem),
+ Description: "A list section has a 4-byte count of list items, and each item has a 4-byte length",
+ Args: graphql.FieldConfigArgument{
+ "target": &graphql.ArgumentConfig{
+ Type: graphql.String,
+ Description: "Target scope of the query. Can be game, bic, or file (default)",
+ DefaultValue: "file",
+ },
+ "section": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.String),
+ Description: "Four-character section name. e.g. TILE",
+ },
+ "nth": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "e.g. 2 for the second named section instance",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ var target *saveGameType
+ section, _ := p.Args["section"].(string)
+ nth, _ := p.Args["nth"].(int)
+ targetArg, _ := p.Args["target"].(string)
+ switch targetArg {
+ case "game":
+ target = ¤tGame
+ case "bic":
+ target = ¤tBic
+ default:
+ target = &saveGame
+ }
+ savSection, err := target.sectionOffset(section, nth)
+ if err != nil {
+ return nil, err
+ }
+ count := target.readInt32(savSection, Signed)
+ output := make([]saveAndOffsetType, count)
+ offset := 4
+ for i := 0; i < count; i++ {
+ output[i].offset = savSection + offset
+ output[i].save = target
+ length := target.readInt32(savSection+offset, Signed)
+ offset += 4 + length
+ }
+ return output, nil
+ },
+ },
+ "civs": &graphql.Field{
+ Type: graphql.NewList(gameLeadSectionType),
+ Description: "A list of 32 civilization/leader (LEAD) sections' data",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ // Conquests saves appear to always have 32 LEAD sections
+ const leadCount = 32
+ output := make([]int, leadCount)
+ for i := 0; i < leadCount; i++ {
+ savSection, err := SectionOffset("LEAD", i+1)
+ if err != nil {
+ return nil, err
+ }
+ output[i] = savSection
+ }
+ return output, nil
+ },
+ },
+ "race": &graphql.Field{
+ Type: graphql.NewList(raceSectionItemType),
+ Description: "A list of civilizations (RACE) from the BIQ. Note that offsets of sub items are from the end of the cities and military great leaders lists.",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ target := ¤tBic
+ savSection, err := target.sectionOffset("RACE", 1)
+ if err != nil {
+ return nil, err
+ }
+ count := target.readInt32(savSection, Signed)
+ output := make([]saveAndOffsetType, count)
+ offset := 4
+ for i := 0; i < count; i++ {
+ length := target.readInt32(savSection+offset, Signed)
+ output[i].offset2 = savSection + offset
+ cityNamesCount := target.readInt32(output[i].offset2+4, Signed)
+ // Each city name buffer is 24 bytes long
+ greatLeaderNamesCount := target.readInt32(output[i].offset2+4+4+24*cityNamesCount, Signed)
+ // Each leader name buffer is 32 bytes long
+ // I am really confused why another +4 isn't needed here for the great leader count int, but it's working as-is
+ output[i].offset = output[i].offset2 + 4 + 4 + 24*cityNamesCount + 32*greatLeaderNamesCount
+ output[i].save = target
+ offset += 4 + length
+ }
+ return output, nil
+ },
+ },
+ "techCivMask": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Int32 array, each is a bit mask to flag which players have the tech by index.",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ techSection, err := currentBic.sectionOffset("TECH", 1)
+ if err != nil {
+ return nil, err
+ }
+ gameSection, err := currentGame.sectionOffset("GAME", 1)
+ if err != nil {
+ return nil, err
+ }
+ wrldSection, err := currentGame.sectionOffset("WRLD", 1)
+ if err != nil {
+ return nil, err
+ }
+ techCount := currentBic.readInt32(techSection, Signed)
+ continentCount := currentGame.readInt16(wrldSection+4, Signed)
+ techCivMaskOffset := 852 + 4*continentCount
+ intList := make([]int, techCount)
+ for i := 0; i < techCount; i++ {
+ intList[i] = currentGame.readInt32(gameSection+techCivMaskOffset+4*i, Unsigned)
+ }
+ return intList, nil
+ },
+ },
+ },
+})
diff --git a/civ3satgql/civ3satgql.go b/queryciv3/queryciv3.go
similarity index 53%
rename from civ3satgql/civ3satgql.go
rename to queryciv3/queryciv3.go
index 0656e0e..064dd0c 100644
--- a/civ3satgql/civ3satgql.go
+++ b/queryciv3/queryciv3.go
@@ -1,34 +1,72 @@
-package civ3satgql
+package queryciv3
import (
"encoding/json"
- "log"
+ "errors"
"net/http"
"strconv"
"github.com/graphql-go/graphql"
"github.com/graphql-go/handler"
- "github.com/myjimnelson/c3sat/parseciv3"
+ "github.com/myjimnelson/c3sat/civ3decompress"
)
type sectionType struct {
name string
offset int
- length int
}
type saveGameType struct {
+ path string
data []byte
sections []sectionType
}
var saveGame saveGameType
+var defaultBic saveGameType
+var currentBic saveGameType
+var currentGame saveGameType
-func findSections() {
+// populates the structure given a path to a sav file
+func (sav *saveGameType) loadSave(path string) error {
+ var err error
+ sav.data, _, err = civ3decompress.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ sav.path = path
+ sav.populateSections()
+ // If this is a save game, populate BIQ and save vars
+ // This is still hackish
+ if string(sav.data[0:4]) == "CIV3" {
+ gameOff, err := sav.sectionOffset("GAME", 2)
+ if err != nil {
+ return nil
+ }
+ currentGame.data = sav.data[gameOff-4:]
+ currentGame.populateSections()
+ bicOff, err := sav.sectionOffset("VER#", 1)
+ if err != nil {
+ return nil
+ }
+ if sav.readInt32(bicOff+8, Unsigned) == 0xcdcdcdcd {
+ currentBic = defaultBic
+ } else {
+ currentBic.data = sav.data[bicOff-8 : gameOff]
+ currentBic.populateSections()
+ }
+ currentGame.data = saveGame.data[gameOff-4:]
+ }
+ return nil
+}
+
+// Find sections demarc'ed by 4-character ASCII headers and place into sections[]
+func (sav *saveGameType) populateSections() {
var i, count, offset int
- for i < len(saveGame.data) {
- // for i < 83000 {
- if saveGame.data[i] < 0x20 || saveGame.data[i] > 0x5a {
+ sav.sections = make([]sectionType, 0)
+ // find sections demarc'ed by 4-character ASCII headers
+ for i < len(sav.data) {
+ if sav.data[i] < 0x20 || sav.data[i] > 0x5a {
count = 0
} else {
if count == 0 {
@@ -41,11 +79,76 @@ func findSections() {
count = 0
s := new(sectionType)
s.offset = offset
- s.name = string(saveGame.data[offset:i])
- saveGame.sections = append(saveGame.sections, *s)
- // fmt.Println(string(saveGame.data[offset:i]) + " " + strconv.Itoa(offset))
+ s.name = string(sav.data[offset:i])
+ sav.sections = append(sav.sections, *s)
+ }
+ }
+}
+
+// returns just the filename part of the path assuming / or \ separators
+func (sav *saveGameType) fileName() string {
+ var o int
+ for i := 0; i < len(sav.path); i++ {
+ if sav.path[i] == 0x2f || sav.path[i] == 0x5c {
+ o = i
+ }
+ }
+ return sav.path[o+1:]
+}
+
+// Transitioning to this from the old SecionOffset stanalone function
+func (sav *saveGameType) sectionOffset(sectionName string, nth int) (int, error) {
+ var i, n int
+ for i < len(sav.sections) {
+ if sav.sections[i].name == sectionName {
+ n++
+ if n >= nth {
+ return sav.sections[i].offset + len(sectionName), nil
+ }
}
+ i++
}
+ return -1, errors.New("Could not find " + strconv.Itoa(nth) + " section named " + sectionName)
+}
+
+func (sav *saveGameType) readInt32(offset int, signed bool) int {
+ n := int(sav.data[offset]) +
+ int(sav.data[offset+1])*0x100 +
+ int(sav.data[offset+2])*0x10000 +
+ int(sav.data[offset+3])*0x1000000
+ if signed && n&0x80000000 != 0 {
+ n = -(n ^ 0xffffffff + 1)
+ }
+ return n
+}
+
+func (sav *saveGameType) readInt16(offset int, signed bool) int {
+ n := int(sav.data[offset]) +
+ int(sav.data[offset+1])*0x100
+ if signed && n&0x8000 != 0 {
+ n = -(n ^ 0xffff + 1)
+ }
+ return n
+}
+
+func (sav *saveGameType) readInt8(offset int, signed bool) int {
+ n := int(sav.data[offset])
+ if signed && n&0x80 != 0 {
+ n = -(n ^ 0xff + 1)
+ }
+ return n
+}
+
+// ChangeSavePath updates the package saveGame structure with save file data at
+func ChangeSavePath(path string) error {
+ err := saveGame.loadSave(path)
+ return err
+}
+
+// ChangeSavePath updates the package saveGame structure with save file data at
+func ChangeDefaultBicPath(path string) error {
+ err := defaultBic.loadSave(path)
+ return err
}
// Handler wrapper to allow adding headers to all responses
@@ -66,20 +169,14 @@ func setHeaders(handler http.Handler) http.Handler {
})
}
-func Server(path string, bindAddress, bindPort string) error {
- var err error
- saveGame.data, _, err = parseciv3.ReadFile(path)
- if err != nil {
- return err
- }
- findSections()
-
+// GraphQlHandler returns a GraphQL http handler
+func GraphQlHandler() (http.Handler, error) {
Schema, err := graphql.NewSchema(graphql.SchemaConfig{
Query: queryType,
// Mutation: MutationType,
})
if err != nil {
- return err
+ return nil, err
}
// create a graphl-go HTTP handler
@@ -91,21 +188,33 @@ func Server(path string, bindAddress, bindPort string) error {
// Playground provides fancier web browser query interface pulled from Internet
Playground: true,
})
+ return graphQlHandler, nil
+}
- http.Handle("/graphql", setHeaders(graphQlHandler))
- log.Fatal(http.ListenAndServe(bindAddress+":"+bindPort, nil))
+func Server(path string, bindAddress, bindPort string) error {
+ err := saveGame.loadSave(path)
+ if err != nil {
+ return err
+ }
+
+ gQlHandler, err := GraphQlHandler()
+ if err != nil {
+ return err
+ }
+
+ http.Handle("/graphql", setHeaders(gQlHandler))
+ http.ListenAndServe(bindAddress+":"+bindPort, nil)
+ if err != nil {
+ return err
+ }
return nil
}
func Query(query, path string) (string, error) {
- var err error
- saveGame.data, _, err = parseciv3.ReadFile(path)
+ err := saveGame.loadSave(path)
if err != nil {
return "", err
}
- findSections()
- // fmt.Println(saveGame.sections[len(saveGame.sections)-1])
- // saveGame.sections = []string{"hello", "there"}
Schema, err := graphql.NewSchema(graphql.SchemaConfig{
Query: queryType,
// Mutation: MutationType,
@@ -121,8 +230,6 @@ func Query(query, path string) (string, error) {
if err != nil {
return "", err
}
-
- // return hex.EncodeToString(saveGame[:4]), nil
return string(out[:]), nil
}
@@ -140,12 +247,10 @@ func WorldSettings(path string) ([][3]string, error) {
var temperature = [...]string{"Warm", "Temperate", "Cool", "Random"}
var age = [...]string{"3 Billion", "4 Billion", "5 Billion", "Random"}
var settings [][3]string
- var err error
- saveGame.data, _, err = parseciv3.ReadFile(path)
+ err := saveGame.loadSave(path)
if err != nil {
- return [][3]string{}, err
+ return nil, err
}
- findSections()
wrldSection, err := SectionOffset("WRLD", 1)
if err != nil {
return [][3]string{}, err
diff --git a/queryciv3/types.go b/queryciv3/types.go
new file mode 100644
index 0000000..cccff0a
--- /dev/null
+++ b/queryciv3/types.go
@@ -0,0 +1,493 @@
+package queryciv3
+
+import (
+ "encoding/hex"
+
+ "github.com/graphql-go/graphql"
+)
+
+const tileBytes = 212
+
+type worldData struct {
+ worldOffset int
+}
+
+type mapData struct {
+ mapWidth int
+ mapHeight int
+ tileSetWidth int
+ tileSetHeight int
+ tileSetX int
+ tileSetY int
+ playerSpoilerMask int32
+ tilesOffset int
+ tileSetOffsets []int
+ mapTileOffsets []int
+}
+
+func (m *mapData) spoilerFree(offset int) bool {
+ if m.playerSpoilerMask == 0 || int(m.playerSpoilerMask)&ReadInt32(offset+84, Unsigned) != 0 {
+ return true
+ }
+ return false
+}
+
+// To return to subqueries in GraphQL
+type saveAndOffsetType struct {
+ save *saveGameType
+ offset int
+ offset2 int
+}
+
+var gameLeadSectionType = graphql.NewObject(graphql.ObjectConfig{
+ Name: "gameLeadSection",
+ Fields: graphql.Fields{
+ "bytes": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Byte array",
+ Args: graphql.FieldConfigArgument{
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of item",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of int32s to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if itemOffset, ok := p.Source.(int); ok {
+ if itemOffset > 0 {
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ intList := make([]int, count)
+ for i := 0; i < count; i++ {
+ intList[i] = ReadInt8((itemOffset+4+offset)+i, Unsigned)
+ }
+ return intList, nil
+ }
+ }
+ return nil, nil
+ },
+ },
+ "int32s": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Int32 array",
+ Args: graphql.FieldConfigArgument{
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of item",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of int32s to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if itemOffset, ok := p.Source.(int); ok {
+ if itemOffset > 0 {
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ intList := make([]int, count)
+ for i := 0; i < count; i++ {
+ intList[i] = ReadInt32((itemOffset+4+offset)+4*i, Signed)
+ }
+ return intList, nil
+ }
+ }
+ return nil, nil
+ },
+ },
+ "hexDump": &graphql.Field{
+ Type: graphql.String,
+ Description: "Hex dump of the entire item",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if itemOffset, ok := p.Source.(int); ok {
+ if itemOffset > 0 {
+ length := ReadInt32(itemOffset, Signed)
+ return hex.Dump(saveGame.data[itemOffset+4 : itemOffset+4+length]), nil
+ }
+ }
+ return "", nil
+ },
+ },
+ "techHunt": &graphql.Field{
+ Type: graphql.String,
+ Description: "Hex dump after the LEAD section's length",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if itemOffset, ok := p.Source.(int); ok {
+ if itemOffset > 0 {
+ length := ReadInt32(itemOffset, Signed)
+ return hex.Dump(saveGame.data[itemOffset+4+length : itemOffset+4+length+5444]), nil
+ }
+ }
+ return "", nil
+ },
+ },
+ },
+})
+
+// intending to append fields for city lists and mgl lists, but not in a hurry
+var raceSectionItemType = listSectionItem
+
+var listSectionItem = graphql.NewObject(graphql.ObjectConfig{
+ Name: "listSectionItem",
+ Fields: graphql.Fields{
+ "bytes": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Byte array",
+ Args: graphql.FieldConfigArgument{
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of item",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of int32s to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if item, ok := p.Source.(saveAndOffsetType); ok {
+ if item.offset > 0 {
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ intList := make([]int, count)
+ for i := 0; i < count; i++ {
+ intList[i] = item.save.readInt8((item.offset+4+offset)+4*i, Unsigned)
+ }
+ return intList, nil
+ }
+ }
+ return nil, nil
+ },
+ },
+ "int32s": &graphql.Field{
+ Type: graphql.NewList(graphql.Int),
+ Description: "Int32 array",
+ Args: graphql.FieldConfigArgument{
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of item",
+ },
+ "count": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Number of int32s to return",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if item, ok := p.Source.(saveAndOffsetType); ok {
+ if item.offset > 0 {
+ offset, _ := p.Args["offset"].(int)
+ count, _ := p.Args["count"].(int)
+ intList := make([]int, count)
+ for i := 0; i < count; i++ {
+ intList[i] = item.save.readInt32((item.offset+4+offset)+4*i, Signed)
+ }
+ return intList, nil
+ }
+ }
+ return nil, nil
+ },
+ },
+ "hexDump": &graphql.Field{
+ Type: graphql.String,
+ Description: "Hex dump of the entire item",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if item, ok := p.Source.(saveAndOffsetType); ok {
+ if item.offset > 0 {
+ length := item.save.readInt32(item.offset, Signed)
+ return hex.Dump(saveGame.data[item.offset+4 : item.offset+4+length]), nil
+ }
+ }
+ return "", nil
+ },
+ },
+ "string": &graphql.Field{
+ Type: graphql.String,
+ Description: "Null-terminated string",
+ Args: graphql.FieldConfigArgument{
+ "offset": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Offset from start of item",
+ },
+ "maxLength": &graphql.ArgumentConfig{
+ Type: graphql.NewNonNull(graphql.Int),
+ Description: "Max length of string / the max number of bytes to consider",
+ },
+ },
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if item, ok := p.Source.(saveAndOffsetType); ok {
+ if item.offset > 0 {
+ offset, _ := p.Args["offset"].(int)
+ maxLength, _ := p.Args["maxLength"].(int)
+ stringBuffer := item.save.data[item.offset+4+offset : item.offset+4+offset+maxLength]
+ s, err := CivString(stringBuffer)
+ if err != nil {
+ return nil, err
+ }
+ return s, nil
+ }
+ }
+ return "", nil
+ },
+ },
+ },
+})
+
+var mapTileType = graphql.NewObject(graphql.ObjectConfig{
+ Name: "tile",
+ Fields: graphql.Fields{
+ "hexTerrain": &graphql.Field{
+ Type: graphql.String,
+ Description: "Byte value. High nybble is overlay, low nybble is base terrain",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if offset, ok := p.Source.(int); ok {
+ if offset > 0 {
+ return hex.EncodeToString(saveGame.data[offset+57 : offset+58]), nil
+ }
+ }
+ return nil, nil
+ },
+ },
+ "chopped": &graphql.Field{
+ Type: graphql.Boolean,
+ Description: "True if a forest has previously been harvested from this tile",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if offset, ok := p.Source.(int); ok {
+ if offset > 0 {
+ return ((ReadInt16(offset+62, Unsigned) & 0x1000) != 0), nil
+ }
+ }
+ return nil, nil
+ },
+ },
+ },
+})
+
+var mapType = graphql.NewObject(graphql.ObjectConfig{
+ Name: "map",
+ Fields: graphql.Fields{
+ "mapWidth": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Width of the game map in tiles",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.mapWidth, nil
+ }
+ // TODO: better logic error handling?
+ return -1, nil
+ },
+ },
+ "mapHeight": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Height of the game map in tiles",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.mapHeight, nil
+ }
+ // TODO: better logic error handling?
+ return -1, nil
+ },
+ },
+ "tileSetWidth": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Width of the currently visible map in tiles",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.tileSetWidth, nil
+ }
+ // TODO: better logic error handling?
+ return -1, nil
+ },
+ },
+ "tileSetHeight": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Height of the currently visible map in tiles",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.tileSetHeight, nil
+ }
+ // TODO: better logic error handling?
+ return -1, nil
+ },
+ },
+ "tileSetX": &graphql.Field{
+ Type: graphql.Int,
+ Description: "World map X coordinate of top-left tile set tile",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.tileSetX, nil
+ }
+ // TODO: better logic error handling?
+ return -1, nil
+ },
+ },
+ "tileSetY": &graphql.Field{
+ Type: graphql.Int,
+ Description: "World map Y coordinate of top-left tile set tile",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.tileSetY, nil
+ }
+ // TODO: better logic error handling?
+ return -1, nil
+ },
+ },
+ "tiles": &graphql.Field{
+ Type: graphql.NewList(mapTileType),
+ Description: "List of all visible tiles",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if mdat, ok := p.Source.(mapData); ok {
+ return mdat.tileSetOffsets, nil
+ }
+ return nil, nil
+ },
+ },
+ },
+})
+
+var civ3Type = graphql.NewObject(graphql.ObjectConfig{
+ Name: "civ3",
+ Fields: graphql.Fields{
+ "worldSeed": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Random seed of random worlds",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+170, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "climate": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Climate option chosen for random map generation",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+186, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "climateFinal": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Climate setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+190, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "barbarians": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Barbarians option chosen for random map generation",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+194, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "barbariansFinal": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Barbarians setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+198, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "landMass": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Land mass option chosen for random map generation",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+202, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "landMassFinal": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Land mass setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+206, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "oceanCoverage": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Ocean coverage option chosen for random map generation",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+210, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "oceanCoverageFinal": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Ocean coverage setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+214, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "temperature": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Temperature option chosen for random map generation",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+218, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "temperatureFinal": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Temperature setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+222, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "age": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Age option chosen for random map generation",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+226, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "ageFinal": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Age setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+230, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ "size": &graphql.Field{
+ Type: graphql.Int,
+ Description: "Size setting of map",
+ Resolve: func(p graphql.ResolveParams) (interface{}, error) {
+ if wdat, ok := p.Source.(worldData); ok {
+ return ReadInt32(wdat.worldOffset+234, Signed), nil
+ }
+ return -1, nil
+ },
+ },
+ },
+})
diff --git a/readme.md b/readme.md
index 379a5e5..e35ef3b 100644
--- a/readme.md
+++ b/readme.md
@@ -1,4 +1,46 @@
-## Civ3 Show-And-Tell
+# Civ3 Show-And-Tell
+
+This repo contains Go code to decompress and read save and BIQ files from Civilization III Conquests and Civilization III Complete v1.22.
+
+There are two executables, c3sat and cia3.
+
+The libraries implement a GraphQL API local web server to query the data, and the cia3 executable includes embedded JavaScript to
+query the saves and present useful information.
+
+### Current Features
+
+- Available tech trades
+- Map highlighting forest chopped squares
+- Contact, will-talk and war/peace status for opponent civs
+- Display of map generation settings, world seed, and difficulty
+- Should work with custom scenarios/conquests
+
+### Known Issues
+
+- Eliminated civs still show up in lists
+- At-war won't-talk civs will disappear from tech trades table until they're willing to talk
+
+## Civ Intelligence Agency III (CIA3)
+
+Civ Intelligence Agency III is intended to be a non-spoiling, non-cheating game assistant for single-human-player games of Sid Meier's Civilization III Complete and/or Conquests. Multiplayer non-spoiling support may be possible in the future.
+
+CIA3 watches save game folder locations for new .SAV files, then reads the file and updates the information. Every time you save your game or begin a new turn, a new save or autosave will be generated and trigger CIA3 to update its info. It does not update mid-turn unless you save your game during the turn.
+
+CIA3 currently reads the Windows registry to determine the default save and autosave folders for Civ3 and watches those. In the future I'll add the ability to watch specified folders and perhaps open files on demand.
+
+The information provided by CIA3 is either available in-game, was available earlier in-game or during map creation, or accepted as not cheating by Civ III competitions. For example, the player may not be able to determine in-game if forests have been previously chopped for shield production on newly-revealed map tiles, but previous assistants (CivAssist II) have shown this information and have been allowed in competitive games. Another example is that previous tools allowed exact map tile counts when at least 80% of the map is revealed to allow for determining how far the player is from winning by domination.
+
+Previous assistants such as CRpSuite's MapStat (download page) and CivAsist II (download page) worked well for many years, but neither of these seem to work in Windows 10.
+
+I, Jim Nelson, or Puppeteer on CivFanatics Forums , have been on-and-off since 2013 been working on a save game reader for other purposes with Civ3 Show-And-Tell (C3SAT). Until 2020 I explicitly did not want to recreate a game assistant, but now that the others aren't working and seem to be abandoned by their creators, I started working on CIA3 from the C3SAT code base. CIA3 is a different product from C3SAT, but they share a common code base for most functions.
+
+Release thread on CivFanatics Forums where new releases are announced. Discussion thread on CivFanatics Forums where I babble about development progress. CIA3 discussion begins on page 10
+
+
+## Civ3 Show-And-Tell (C3SAT)
+
+v0.4.1 Note: C3SAT has not changed functionality since v0.4.0, except that the new GraphQL queries would be available in API and query modes, but I don't
+intend to compile and release binaries, so either use CIA3's binary, grab the binary release from v0.4.0, or build from source.
Civ3 Show-And-Tell reads `sav` and `biq` files from Civilization III Conquests and Civilization III Complete v1.22. It can: