Skip to content

Instantly share code, notes, and snippets.

@pgaskin
Last active December 30, 2020 08:31
Show Gist options
  • Select an option

  • Save pgaskin/129dafc5ad9fc15a3c4fa2723ecf4603 to your computer and use it in GitHub Desktop.

Select an option

Save pgaskin/129dafc5ad9fc15a3c4fa2723ecf4603 to your computer and use it in GitHub Desktop.
Save file reader implemented in Go for various games.
// Save file reader implemented in Go for various games.
// - Titanfall 2
// - Dragon Age: Inquisition
// - STAR WARS Jedi: Fallen Order
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
}
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
}
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