~kota/mcoffline

8ee00fd90711810c3a031e5729c6e0930a56efc8 — Dakota Walsh 1 year, 4 months ago ac52985 main v2001.0.0
convert ops.json, stats, and advancements
4 files changed, 119 insertions(+), 64 deletions(-)

M Makefile
M README.md
M doc/mcoffline.1.scd
M main.go
M Makefile => Makefile +1 -1
@@ 3,7 3,7 @@
.POSIX:
.SUFFIXES:

VERSION = 2000.0.0
VERSION = 2001.0.0
GO = go
RM = rm
INSTALL = install

M README.md => README.md +9 -8
@@ 13,14 13,15 @@ it will print out the corresponding offline-mode UUID for that name.

You can instead run it with a path to a `whitelist.json` file. It will convert
the file, store the new offline-mode version as `whitelist.json.offline` in the
same directory. Then it will look for a `server.properties` file in that same
directory to discover your world name. Finally, it will enter the world
directory and create a copy of the `playerdata` folder named
`playerdata.offline` with each user renamed to the offline-mode version.

Once finished you can manually verify `whitelist.offline.json` and
`playerdata.offline`. If you like them you can backup your old online-mode
versions and rename the offline-mode versions to get rid of the suffix.
same directory and then the same for ops.json. Next, it will look for
a `server.properties` file in that same directory to discover your world name.
Finally, it will enter the world directory and create a copy of the `playerdata`
folder named `playerdata.offline` with each user renamed to the offline-mode
version and one after the other it will create offline suffixed versions of
stats and advancements aswell.

Once finished you can manually verify the files, backup the online versions and
finally rename the files and folders to remove the `.offline` suffix.

# Install
```

M doc/mcoffline.1.scd => doc/mcoffline.1.scd +10 -9
@@ 18,15 18,16 @@ There are two ways to use *mcoffline*. You can simply run it with a username and
it will print out the corresponding offline-mode UUID for that name.

You can instead run it with a path to a *whitelist.json* file. It will convert
the file, store the new offline-mode version as *whitelist.offline.json* in the
same directory. Then it will look for a *server.properties* file in that same
directory to discover your world name. Finally, it will enter the world
directory and create a copy of the *playerdata* folder named *playerdata.offline*
with each user renamed to the offline-mode version.

Once finished you can manually verify *whitelist.offline.json*
and *playerdata.offline*. If you like them you can backup your old online-mode
versions and rename the offline-mode versions to get rid of the suffix.
the file, store the new offline-mode version as *whitelist.json.offline* in the
same directory and then the same for ops.json. Next, it will look for
a *server.properties* file in that same directory to discover your world name.
Finally, it will enter the world directory and create a copy of the *playerdata*
folder named *playerdata.offline* with each user renamed to the offline-mode
version and one after the other it will create offline suffixed versions of
stats and advancements aswell.

Once finished you can manually verify the files, backup the online versions and
finally rename the files and folders to remove the *.offline* suffix.

# AUTHORS


M main.go => main.go +99 -46
@@ 2,6 2,7 @@ package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"errors"
	"flag"


@@ 18,15 19,17 @@ import (
// OfflineSuffix is the suffix added to whitelist.json and playerdata.
const OfflineSuffix = ".offline"

// Version is set at build time in the Makefile.
var Version string

// User represents a user in whitelist.json
// User represents a user in whitelist.json or ops.json
type User struct {
	UUID string `json:"uuid"`
	Name string `json:"name"`
	Name              string `json:"name"`
	UUID              string `json:"uuid"`
	Level             int    `json:"level,omitempty"`
	BypassPlayerLimit bool   `json:"bypassPlayerLimit,omitempty"`
}

// Version is set at build time in the Makefile.
var Version string

func main() {
	log.SetPrefix("mcoffline " + Version + ": ")
	log.SetFlags(0)


@@ 38,7 41,7 @@ func main() {
		arg = "whitelist.json"
	}

	in, err := os.Open(arg)
	users, err := loadWhitelist(arg)
	if errors.Is(err, os.ErrNotExist) && flag.Arg(0) != "" {
		// Assume the argument was a username if the file does not exist, but an
		// argument was given.


@@ 47,65 50,115 @@ func main() {
	} else if err != nil {
		log.Fatalf("failed opening whitelist: %v\n", err)
	}
	defer in.Close()
	basePath := filepath.Dir(arg)

	out, err := os.Create(arg + OfflineSuffix)
	opsPath := filepath.Join(basePath, "ops.json")
	in, err := os.Open(opsPath)
	if err != nil {
		log.Fatalf("failed creating offline-mode whitelist file: %v\n", err)
		log.Fatalf("failed opening ops.json: %v\n", err)
	}
	defer out.Close()
	defer in.Close()

	if err := convertWhitelist(in, out); err != nil {
		log.Fatalf("failed parsing whitelist: %v\n", err)
	src, err := io.ReadAll(in)
	if err != nil {
		log.Fatalf("failed reading ops.json: %v\n", err)
	}

	// Reset file seek so we can read it again later to make a map of the UUIDs.
	if _, err := in.Seek(0, io.SeekStart); err != nil {
		log.Fatalf("failed reading whitelist: %v\n", err)
	if err = createOfflineJSON(src, opsPath+OfflineSuffix); err != nil {
		log.Fatalf("failed to create ops.json.offline: %v\n", err)
	}

	basePath := filepath.Dir(arg)
	worldName, err := getLevelName(filepath.Join(basePath, "server.properties"))
	if err != nil {
		log.Fatalf("failed reading server.properties to get world name: %v\n", err)
	}
	playerDataPath := filepath.Join(basePath, worldName, "playerdata")

	users, err := mapUsers(in)
	if err != nil {
		log.Fatalln("failed mapping users to UUIDs")
	advancementsPath := filepath.Join(basePath, worldName, "advancements")
	if err := convertDirectory(users, advancementsPath); err != nil {
		log.Fatalf("failed creating offline advancements: %v\n", err)
	}

	if err := convertPlayerdata(users, playerDataPath); err != nil {
	playerdataPath := filepath.Join(basePath, worldName, "playerdata")
	if err := convertDirectory(users, playerdataPath); err != nil {
		log.Fatalf("failed creating offline playerdata: %v\n", err)
	}

	statsPath := filepath.Join(basePath, worldName, "stats")
	if err := convertDirectory(users, statsPath); err != nil {
		log.Fatalf("failed creating offline stats: %v\n", err)
	}
}

// convertWhitelist reads an online-mode whitelist.json file from a reader and
// writes an equivalent offline-mode whitelist.json to it's writer. The output
// is indented with 2 spaces to match the format Mojang seems to like.
func convertWhitelist(in io.Reader, out io.Writer) error {
// loadWhitelist reads an online-mode whitelist file by path, creates an
// offline mode whitelist in the same directory, and returns a map of
// online-mode UUIDs to usernames.
func loadWhitelist(path string) (map[string]User, error) {
	in, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer in.Close()

	src, err := io.ReadAll(in)
	if err != nil {
		return nil, err
	}

	if err = createOfflineJSON(src, path+OfflineSuffix); err != nil {
		return nil, err
	}

	// Reset file seek so we can read it again to make a map of the UUIDs.
	if _, err := in.Seek(0, io.SeekStart); err != nil {
		log.Fatalf("failed reading whitelist: %v\n", err)
	}
	return mapUsers(in)
}

// createOfflineJSON reads online-mode .json data (whitelist or ops) and writes
// an equivalent offline-mode .json file to the path specified.
func createOfflineJSON(src []byte, path string) error {
	in := bytes.NewReader(src)
	out, err := os.Create(path)
	if err != nil {
		return err
	}
	defer out.Close()

	if err := convertJSON(in, out); err != nil {
		return fmt.Errorf("failed converting json file: %w\n", err)
	}
	return nil
}

// convertJSON reads online-mode .json (whitelist or ops) from a reader and
// writes an equivalent offline-mode .json to it's writer. The output is
// indented with 2 spaces to match the format Mojang seems to like.
func convertJSON(in io.Reader, out io.Writer) error {
	var users []User
	d := json.NewDecoder(in)
	err := d.Decode(&users)
	dec := json.NewDecoder(in)
	err := dec.Decode(&users)
	if err != nil {
		return err
	}

	offlineUsers := make([]User, len(users))
	for i, user := range users {
		var offlineUser User
		offlineUser.UUID = offline.NameToUUID(user.Name).String()
		offlineUser.Name = user.Name
		users[i] = offlineUser
		// Do some type checking as a treat (and to avoid crashing if the json
		// is corrupted).
		offlineUsers[i].UUID = offline.NameToUUID(user.Name).String()
		offlineUsers[i].Name = user.Name
		offlineUsers[i].Level = user.Level
		offlineUsers[i].BypassPlayerLimit = user.BypassPlayerLimit
	}

	e := json.NewEncoder(out)
	e.SetIndent("", "  ") // MC uses 2 space indented json.
	return e.Encode(&users)
	return e.Encode(&offlineUsers)
}

// mapUsers reads a whitelist and creates a map of UUIDs to usernames.
func mapUsers(in io.Reader) (map[string]string, error) {
// mapUsers reads a whitelist and creates a map of online-mode UUIDs to Users.
func mapUsers(in io.Reader) (map[string]User, error) {
	var users []User
	d := json.NewDecoder(in)
	err := d.Decode(&users)


@@ 113,9 166,9 @@ func mapUsers(in io.Reader) (map[string]string, error) {
		return nil, err
	}

	m := make(map[string]string)
	m := make(map[string]User, len(users))
	for _, user := range users {
		m[user.UUID] = user.Name
		m[user.UUID] = user
	}
	return m, nil
}


@@ 149,15 202,15 @@ func getLevelName(path string) (string, error) {
	return "", fmt.Errorf("level-name not found in %v", path)
}

// convertPlayerdata takes a map of online-mode UUIDs to player named and the
// path to a playerdata folder. It creates a playerdata.offline directory.
// Then, For each file in the input directory, a hard link is make to the
// offline directory named with the offline-mode UUID instead of it's previous
// online-mode UUID.
func convertPlayerdata(users map[string]string, path string) error {
// convertDirectory takes a map of online-mode UUIDs to player names and the
// path to a folder with player data (stats, advancements, or playerdata). It
// creates a new directory with a .offline suffix. Then, For each file in the
// input directory, a hard link is made to the offline directory named with the
// offline-mode UUID instead of it's previous online-mode UUID.
func convertDirectory(users map[string]User, path string) error {
	files, err := os.ReadDir(path)
	if err != nil {
		return fmt.Errorf("failed reading playerdata folder: %w", err)
		return fmt.Errorf("failed reading folder: %w", err)
	}

	offlineDir := filepath.Clean(path) + OfflineSuffix


@@ 176,7 229,7 @@ func convertPlayerdata(users map[string]string, path string) error {

		ext := filepath.Ext(file.Name())
		uuid := strings.TrimSuffix(file.Name(), ext)
		name, ok := users[uuid]
		user, ok := users[uuid]
		if !ok {
			fmt.Printf("skipping non-whitelisted player: %v\n", onlinePath)
			continue


@@ 184,7 237,7 @@ func convertPlayerdata(users map[string]string, path string) error {

		offlinePath := filepath.Join(
			offlineDir,
			offline.NameToUUID(name).String()+ext,
			offline.NameToUUID(user.Name).String()+ext,
		)

		// Using a hardlink to avoid pointless disk writing. Dunno if this works