Last active
December 30, 2020 08:31
-
-
Save pgaskin/129dafc5ad9fc15a3c4fa2723ecf4603 to your computer and use it in GitHub Desktop.
Save file reader implemented in Go for various games.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Save file reader implemented in Go for various games. | |
| // - Titanfall 2 | |
| // - Dragon Age: Inquisition | |
| // - STAR WARS Jedi: Fallen Order |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package dai | |
| import ( | |
| "encoding/binary" | |
| "fmt" | |
| "io" | |
| "strconv" | |
| "strings" | |
| "time" | |
| ) | |
| // SaveMeta is the metadata from a DAI save file. | |
| type SaveMeta struct { | |
| Date time.Time | |
| Player struct { | |
| ID string | |
| Name string | |
| Race string | |
| Gender string | |
| Class string | |
| Level int | |
| } | |
| World struct { | |
| Time time.Duration | |
| Location string | |
| Position string | |
| } | |
| Game struct { | |
| PatchLevel int | |
| DLCs []string | |
| } | |
| } | |
| // ParseSaveMeta parses the metadata from a DAI save file. | |
| func ParseSaveMeta(r io.Reader) (*SaveMeta, error) { | |
| var magic [10]byte | |
| if err := bin(r, false, "Magic", &magic); err != nil { | |
| return nil, err | |
| } else if magic != [10]byte{'F', 'B', 'C', 'H', 'U', 'N', 'K', 'S', '\x01', '\x00'} { | |
| return nil, fmt.Errorf("invalid fbchunk magic: %#v", magic) | |
| } | |
| var headerSize uint32 | |
| if err := bin(r, false, "Header Size", &headerSize); err != nil { | |
| return nil, err | |
| } | |
| var dataSize uint32 | |
| if err := bin(r, false, "Data Size", &dataSize); err != nil { | |
| return nil, err | |
| } | |
| var headerChecksum [4]byte | |
| if err := bin(r, false, "Header Checksum", &headerChecksum); err != nil { | |
| return nil, err | |
| } | |
| var headerMagic [10]byte | |
| if err := bin(r, false, "Header Magic", &headerMagic); err != nil { | |
| return nil, err | |
| } else if headerMagic != [10]byte{'F', 'B', 'H', 'E', 'A', 'D', 'E', 'R', '\x00', '\x01'} { | |
| return nil, fmt.Errorf("invalid fbheader magic: %#v", headerMagic) | |
| } | |
| var headerEntries uint32 | |
| if err := bin(r, true, "Header Entries", &headerEntries); err != nil { | |
| return nil, err | |
| } | |
| var meta SaveMeta | |
| for i := uint32(0); i < headerEntries; i++ { | |
| var entryType uint32 | |
| if err := bin(r, true, "- Entry Type", &entryType); err != nil { | |
| return nil, err | |
| } | |
| //fmt.Printf(" 0x%X\n", entryType) | |
| var entryLength uint16 | |
| if err := bin(r, true, " Entry Length", &entryLength); err != nil { | |
| return nil, err | |
| } | |
| entryData := make([]byte, entryLength) | |
| if err := bin(r, false, " Entry Data", &entryData); err != nil { | |
| return nil, err | |
| } | |
| //fmt.Printf(" %#v\n", string(entryData)) | |
| switch entryType { | |
| case 0x92796772: // str Player Name | |
| meta.Player.Name = string(entryData) | |
| case 0x926E6FA0: // int Race (Human, Elf, Dwarf, Qunari) | |
| switch entryData[0] { | |
| case '0': | |
| meta.Player.Race = "Human" | |
| case '1': | |
| meta.Player.Race = "Elf" | |
| case '2': | |
| meta.Player.Race = "Dwarf" | |
| case '3': | |
| meta.Player.Race = "Qunari" | |
| default: | |
| meta.Player.Race = "Unknown" | |
| } | |
| case 0x06AE718A: // int Gender (Male, Female) | |
| switch entryData[0] { | |
| case '0': | |
| meta.Player.Gender = "Male" | |
| case '1': | |
| meta.Player.Gender = "Female" | |
| default: | |
| meta.Player.Race = "Unknown" | |
| } | |
| case 0x979CAD3D: // float World Play Time (seconds) | |
| if secs, err := strconv.ParseFloat(string(entryData), 64); err != nil { | |
| return nil, fmt.Errorf("error parsing play time %#v as float: %w", string(entryData), err) | |
| } else { | |
| meta.World.Time = time.Duration(float64(time.Second) * secs) | |
| } | |
| case 0xB615BDD8: // str Character ID (guid) | |
| meta.Player.ID = string(entryData) | |
| case 0xE17097FB: // int Class (Warrior, Rogue, Mage) | |
| switch entryData[0] { | |
| case '1': | |
| meta.Player.Class = "Warrior" | |
| case '2': | |
| meta.Player.Class = "Rogue" | |
| case '3': | |
| meta.Player.Class = "Mage" | |
| default: | |
| meta.Player.Class = "Unknown" | |
| } | |
| case 0xE1CA2F03: // int Level | |
| if level, err := strconv.ParseInt(string(entryData), 10, 64); err != nil { | |
| return nil, fmt.Errorf("error parsing level %#v as int: %w", string(entryData), err) | |
| } else { | |
| meta.Player.Level = int(level) | |
| } | |
| case 0xA521BDF0: // str Position | |
| meta.World.Position = string(entryData) | |
| case 0x2F852FB8: // str Location | |
| meta.World.Location = string(entryData) | |
| case 0x39AA8AB0: // int Save Time (since 0001-01-01 00:00) | |
| if ts, err := strconv.ParseInt(string(entryData), 10, 64); err != nil { | |
| return nil, fmt.Errorf("error parsing save time %#v as int: %w", string(entryData), err) | |
| } else { | |
| epochDeltaUTC := int64(2440588 * 24 * 60 * 60) | |
| unixUTC := ts - epochDeltaUTC | |
| meta.Date = time.Unix(unixUTC, 0) | |
| } | |
| case 0x0346EAF1: // int Patch Level (1+) | |
| if patch, err := strconv.ParseInt(string(entryData), 10, 64); err != nil { | |
| return nil, fmt.Errorf("error parsing patch level %#v as int: %w", string(entryData), err) | |
| } else { | |
| meta.Game.PatchLevel = int(patch) | |
| } | |
| case 0x4D86FB47: // []str DLCs (~) | |
| meta.Game.DLCs = strings.Split(strings.Trim(string(entryData), "~"), "~") | |
| default: | |
| } | |
| } | |
| return &meta, nil | |
| } | |
| func bin(r io.Reader, bigEndian bool, what string, out interface{}) error { | |
| var e binary.ByteOrder | |
| if bigEndian { | |
| e = binary.BigEndian | |
| } else { | |
| e = binary.LittleEndian | |
| } | |
| if err := binary.Read(r, e, out); err != nil { | |
| err = fmt.Errorf("error parsing %s: %w", strings.ToLower(what), err) | |
| } | |
| return err | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package swjfo | |
| import ( | |
| "bytes" | |
| "encoding/binary" | |
| "fmt" | |
| "io" | |
| "io/ioutil" | |
| "time" | |
| ) | |
| type SaveMeta struct { | |
| TimeSaved time.Time | |
| PlanetRegion string | |
| } | |
| func ParseSaveMeta(r io.Reader) (*SaveMeta, error) { | |
| buf, err := ioutil.ReadAll(r) | |
| if err != nil { | |
| return nil, err | |
| } | |
| var meta SaveMeta | |
| if pbuf, err := find(buf, "PlanetRegion", "StrProperty"); err != nil { | |
| return nil, fmt.Errorf("could not find PlanetRegion: %w", err) | |
| } else if pbuf, err = unpack(pbuf, 5); err != nil { // there are 5 extra nulls before the inner value | |
| return nil, fmt.Errorf("could not unpack PlanetRegion property wrapper: %w", err) | |
| } else if pbuf, err = unpack(pbuf[5:], 0); err != nil { | |
| return nil, fmt.Errorf("could not unpack PlanetRegion property value: %w", err) | |
| } else if meta.PlanetRegion, err = cstr(pbuf); err != nil { | |
| return nil, fmt.Errorf("could not unpack PlanetRegion property value as c string: %w", err) | |
| } | |
| if pbuf, err := find(buf, "TimeSaved", "StructProperty"); err != nil { | |
| return nil, fmt.Errorf("could not find TimeSaved: %w", err) | |
| } else if pbuf, err = unpack(pbuf, -1); err != nil { // the length included only includes the next length, not the value too | |
| return nil, fmt.Errorf("could not unpack TimeSaved struct wrapper: %w", err) | |
| } else if pbuf, err = find(pbuf, "DateTime"); err != nil { | |
| return nil, fmt.Errorf("could not unpack TimeSaved struct field DateTime: %w", err) | |
| } else if meta.TimeSaved, err = datetime(pbuf); err != nil { | |
| return nil, fmt.Errorf("could not unpack TimeSaved struct field value as time ticks: %w", err) | |
| } | |
| return &meta, nil | |
| } | |
| func datetime(buf []byte) (time.Time, error) { | |
| var ticks int64 | |
| if len(buf) < 17+8 { | |
| return time.Time{}, fmt.Errorf("buf too short for DateTime (expected 17 bytes of junk + 8 for ticks int64)") | |
| } else if err := binary.Read(bytes.NewReader(buf[17:]), binary.LittleEndian, &ticks); err != nil { | |
| return time.Time{}, fmt.Errorf("could not read ticks int64: %w", err) | |
| } | |
| base := time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC).Unix() // ticks since January 1, 0001 00:00:00 UTC | |
| return time.Unix(ticks/10_000_000+base, ticks%10_000_000), nil // 100 ns/tick or 0.1 microseconds/tick, 1_000_000 seconds/microseconds | |
| } | |
| // cstr unpacks a c string. | |
| func cstr(buf []byte) (string, error) { | |
| if len(buf) < 1 { | |
| return "", fmt.Errorf("buf too short for c string") | |
| } else if buf[len(buf)-1] != '\x00' { | |
| return "", fmt.Errorf("last byte of buf not null terminator") | |
| } | |
| return string(buf[:len(buf)-1]), nil | |
| } | |
| // unpack unpacks a len+data value from buf. If extraLen is negative, the buf is | |
| // not cut off after the length. Otherwise, extraLen is added to the length read. | |
| func unpack(buf []byte, extraLen int) ([]byte, error) { | |
| var plen uint32 | |
| if err := binary.Read(bytes.NewReader(buf), binary.LittleEndian, &plen); err != nil { | |
| return nil, fmt.Errorf("error reading len: %w", err) | |
| } else { | |
| buf = buf[binary.Size(plen):] | |
| } | |
| if extraLen < 0 { | |
| if len(buf) < int(plen) { | |
| return nil, fmt.Errorf("remaining bytes %d bytes shorter than expected %d", len(buf), plen) | |
| } | |
| } else { | |
| if tlen := int(plen) + extraLen; len(buf) < tlen { | |
| return nil, fmt.Errorf("remaining bytes %d bytes shorter than expected %d (%d+%d)", len(buf), tlen, plen, extraLen) | |
| } else { | |
| buf = buf[:tlen] | |
| } | |
| } | |
| return buf, nil | |
| } | |
| // find finds the first occurence of a set of names in buf. | |
| func find(buf []byte, names ...string) ([]byte, error) { | |
| nbuf := bytes.NewBuffer(nil) | |
| for _, name := range names { | |
| if err := nameEntry(nbuf, name); err != nil { | |
| return nil, fmt.Errorf("error generating header: %w", err) | |
| } | |
| } | |
| if idx := bytes.Index(buf, nbuf.Bytes()); idx < 1 { | |
| return nil, fmt.Errorf("could not find header %X", nbuf.Bytes()) | |
| } else { | |
| buf = buf[idx+nbuf.Len():] | |
| } | |
| return buf, nil | |
| } | |
| // nameEntry creates a length+name pair for a name. | |
| func nameEntry(w io.Writer, name string) error { | |
| if err := binary.Write(w, binary.LittleEndian, uint32(len(name)+1)); err != nil { | |
| return err | |
| } else if _, err = io.WriteString(w, name); err != nil { | |
| return err | |
| } else if _, err = w.Write([]byte{'\x00'}); err != nil { | |
| return err | |
| } | |
| return nil | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package tf2 | |
| import ( | |
| "bufio" | |
| "encoding/binary" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "strconv" | |
| ) | |
| // SaveMeta is the metadata from a Titanfall 2 save file. Currently, only basic | |
| // metadata is supported. | |
| type SaveMeta struct { | |
| MapName string | |
| LevelTitle string | |
| } | |
| // ParseSaveMeta parses the metadata from a Titanfall 2 save file. | |
| func ParseSaveMeta(r io.Reader) (*SaveMeta, error) { | |
| var magic [6]byte | |
| if err := binary.Read(r, binary.LittleEndian, &magic); err != nil { | |
| return nil, fmt.Errorf("error reading magic: %w", err) | |
| } else if magic != [6]byte{'J', 'S', 'A', 'V', '\x87', '\x00'} { | |
| return nil, fmt.Errorf("invalid magic %#v", magic) | |
| } | |
| var junk [34]byte | |
| if err := binary.Read(r, binary.LittleEndian, &junk); err != nil { | |
| return nil, fmt.Errorf("error discarding junk: %w", err) | |
| } | |
| var meta SaveMeta | |
| for i := 64; i > 0; i-- { | |
| var char byte | |
| if err := binary.Read(r, binary.LittleEndian, &char); err != nil { | |
| return nil, fmt.Errorf("error reading level name char: %w", err) | |
| } else if char == '\x00' { | |
| break | |
| } else if i == 1 { | |
| return nil, errors.New("error reading level name: string end not found after 64 chars") | |
| } | |
| meta.MapName += string(rune(char)) | |
| } | |
| meta.LevelTitle = levelTitle(meta.MapName) | |
| return &meta, nil | |
| } | |
| // GetUnlockedMission gets the last unlocked mission from profile.cfg. | |
| func GetUnlockedMission(r io.Reader) (string, error) { | |
| buf := bufio.NewReader(r) | |
| var key, val string | |
| var inValue bool | |
| for { | |
| r, _, err := buf.ReadRune() | |
| if err == io.EOF { | |
| return "", nil // none unlocked | |
| } else if err != nil { | |
| return "", err | |
| } | |
| switch r { | |
| case ' ', '\t', '\r', '\n', '\f': | |
| continue | |
| default: | |
| switch inValue { | |
| case false: | |
| switch r { | |
| case '"': | |
| val = "" | |
| inValue = true | |
| default: | |
| key += string(r) | |
| } | |
| case true: | |
| switch r { | |
| case '"': | |
| switch key { | |
| case "sp_unlockedMission": | |
| if n, err := strconv.ParseInt(val, 10, 64); err != nil { | |
| return "", err | |
| } else if n < 1 { | |
| return mission(0), nil | |
| } else if n > 9 { | |
| return mission(9), nil | |
| } else { | |
| return mission(int(n)), nil | |
| } | |
| } | |
| key = "" | |
| inValue = false | |
| default: | |
| val += string(r) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| func mission(num int) string { | |
| switch num { | |
| case 1: | |
| return "The Pilot's Gauntlet" | |
| case 2: | |
| return "BT-7274" | |
| case 3: | |
| return "Blood and Rust" | |
| case 4: | |
| return "Into the Abyss" | |
| case 5: | |
| return "Effect and Cause" | |
| case 6: | |
| return "The Beacon" | |
| case 7: | |
| return "Trial By Fire" | |
| case 8: | |
| return "The Ark" | |
| case 9: | |
| return "The Fold Weapon" | |
| default: | |
| return "Unknown" | |
| } | |
| } | |
| func levelTitle(mapName string) string { | |
| switch mapName { | |
| case "sp_training": | |
| return mission(1) | |
| case "sp_crashsite": | |
| return mission(2) | |
| case "sp_sewers1": | |
| return mission(3) | |
| case "sp_boomtown_start": | |
| return mission(4) + " - Part 1" | |
| case "sp_boomtown": | |
| return mission(4) + " - Part 2" | |
| case "sp_boomtown_end": | |
| return mission(4) + " - Part 2" | |
| case "sp_hub_timeshift": | |
| return mission(5) + " - Part 1 or 3" | |
| case "sp_timeshift_spoke02": | |
| return mission(5) + " - Part 2" | |
| case "sp_beacon": | |
| return mission(6) + " - Part 1 or 3" | |
| case "sp_beacon_spoke0": | |
| return mission(6) + " - Part 2" | |
| case "sp_tday": | |
| return mission(7) | |
| case "sp_s2s": | |
| return mission(8) | |
| case "sp_skyway_v1": | |
| return mission(9) | |
| } | |
| return mapName | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment