C# to Go: a beginner's journey

How do you learn a new language? Books, practice and a decent chunk of pair programming with someone who knows what they're doing, if available. Those are my favoured options, and when it comes to practice there's nothing like converting an existing piece of code from another language to get an idea of where you have different idioms and more (or less) capable standard libraries.

I decided to translate and update my Transrender OpenTTD sprite creation tool (https://github.com/mattkimber/openttd_transrender) which adds another dimension as Transrender is not particularly clean or good object-oriented code, so there's an opportunity here to clean it up a bit. This won't be me pretending to be a Go expert, more writing down my thoughts both as I both encounter a new language and pick apart some old and hastily-written code to see what can being improved.

Importing Voxels

Pick a starting point, any starting point. I'm going to start by moving the MagicaVoxel voxel file importer across. Partly because Transrender can't do much without voxels to render, but also because file I/O will introduce us to a lot of language concepts: is it sync or async, and how are errors handled?

In the C# code, the voxel loader is a separate project, comprising a class for the voxel elements and a class for the reader. These are not native concepts in Go, but the file reader does feel like it ought to be a package, much like the standard library's gif package (https://golang.org/src/image/gif/).

The convention used by the standard libraries for images seems like a good starting point, so for now I'm going to stick all my voxel object related stuff under src/voxelobject, with the MagicaVoxel reader in src/voxelobject/vox. For now we need reader.go and reader_test.go. The C# version of Transrender doesn't have tests but that was a bad decision which ultimately slowed development, so let's correct it for the Go version.

(Also I'll have to learn how to use Go's built-in test framework from the start. Nice!)

The MagicaVoxel format is a simple chunked data format. The header consists only of the 4-byte magic string "VOX ", followed by a 32-bit version number and a series of chunks. Each chunk has a 12-byte header consisting of the following:

  • 4 bytes for the type of chunk, in ASCII.
  • A 64-bit integer indicating the length of the chunk (not including the header) Two 32-bit integers indicating the length of the chunk and the length of any child chunks respectively. Not reading the documentation before starting caused a bug later down the line when I first tried loading a real file.

This gives us a handy property that we can ignore chunks we don't understand or need data from, because the header tells us how many bytes to skip. In fact, for Transrender's purposes there are only two types of chunk we need to care about:

  • SIZE which tells us the dimensions of the voxel object (length/width/height)
  • XYZI which is a list of 1-byte x,y,z locations together with a 1-byte colour index (i).

I want to make these modular and testable (Transrender has a single monolithic function) so I'm going to create the following functions:

  • isHeaderValid()
  • getSizeFromChunk()
  • getPointDataFromChunk()

Go's own libraries look like it's normal to pass a handle to the binary stream for these kind of chunk reading tasks. This jars with my functional soul, but we are now in a systems language where we care about things like allocating a whole new array just to pass binary data around without side effects. Let's do it the Go way.

isHeaderValid()

I'm going to need to figure out a lot just to get to the point where I've tested a single function from a package, so I'm going to start with isHeaderValid() as that has the least logic. But first, I need a screen break to read Chapter 11 of The Go Programming Language to get a better idea of what I'm doing.

I've now learnt that Go's test framework is convention-based, and so a function called TestIsHeaderValid inside a file called reader_test.go will be treated as a test and not a build artifact. I need to pass t *testing.T to it, which is a pointer to some test framework methods. The GoLand IDE (https://www.jetbrains.com/go/) helpfully reminds me that I should import "testing" if I'm going to do this. Looks like it could be £69 well spent, once my trial period is up.

Here's my first version of reader_test.go:

package vox

import (
	"strings"
	"testing"
)

func TestIsHeaderValid(t *testing.T) {
	var testCases = []struct {
		input string
		expected bool
	}{
		{"VOX ", true},
		{"BLAH", false},
		{"A", false},
	}

	for _, testCase := range testCases {
		result := isHeaderValid(strings.NewReader(testCase.input))
		if result != testCase.expected {
			t.Errorf("Magic string %s expected %v, got %v", testCase.input, testCase.expected, result)
		}
	}
}

We're already seeing a few Go-isms. range is our iterator function, but it returns two things! The first element of the tuple (which I've discarded using the convention _) is the index, something I've oft found myself wanting when doing these for...of type constructs. Note that because I haven't specified a size, this is technically a slice (a view on to an array) rather than an array. This would be an even longer article if I got into the details, but https://blog.golang.org/slices-intro is a good primer if you're interested.

I haven't written isHeaderValid yet, but since this is a new language going through a proper red/green/refactor cycle is advisable in case there are any gotchas or opportunities for the test framework not to run the way I expect it. After an unexpected error where Go complains it can't find any source files, I learn how to test a folder recursively ( go test ./...) and we're up and running... well, up and red, at least:

D:\projects\gorender\src>go test ./...
--- FAIL: TestIsHeaderValid (0.00s)
    reader_test.go:21: Magic string VOX  expected true, got false
FAIL
FAIL    _/D_/projects/gorender/src/voxelobject/vox      0.162s
FAIL

Let's now implement that method. I know roughly what I want to do here:

  • Attempt to read 4 bytes using the io.Reader passed in (https://golang.org/pkg/io/#Reader)
  • Handle the error case of fewer than 4 bytes
  • Find some way of comparing those 4 bytes to the string "VOX "

The first surprise is io.Reader will just read everything. (Technically it will read up to the length of the buffer you provide it, which I discovered later and provides some opportunities to clean up and refactor the code in this article). In a move reminiscent of the ideas in Elegant Objects, I actually need an io.LimitReader to read only 4 bytes. A quick search around what feels like some clunky creation of byte slices reveals the ioutil package and ioutil.ReadAll, which gives us this as the first version of reader.go:

package vox

import (
	"io"
	"io/ioutil"
)

const magic = "VOX "

func isHeaderValid(handle io.Reader) bool {
	limitedReader := io.LimitReader(handle, 4)
	result, err := ioutil.ReadAll(limitedReader)
	return err == nil && string(result) == magic
}

This is sufficient to pass the test. While writing this I also realise I want to check that we advanced the reader 4 bytes, so go back and add that to the test:

expectedLength := len(testCase.input) - 4
if expectedLength < 0 {
	expectedLength = 0
}

if reader.Len() != expectedLength {
	t.Errorf("Did not read 4 bytes of string %s, %d remaining of %d", testCase.input, reader.Len(), len(testCase.input))
}

A couple of surprises for me here:

  • Go has no ternary operator (cond ? v_if_true : v_if_false) - they have their reasons but I will miss it.
  • Using math.Max for that expression ended in type conversion soup. There's no integer equivalent - the Go approach seems to be "this function is simple, you can write it". Not entirely sure how I feel about this but I guess we won't have any leftpad disasters with this attitude.

The good news is we're still green so I feel I can commit and move on.

Point structures

I now need to implement those other two chunk readers, but these will need some types to represent xyz and xyzi data. Those feel like concerns of the voxel object - I'm not totally certain about this but it's a good time to start creating that package and I can always move them later.

We don't have objects in Go, so these are going to be structs:

package voxelobject

type Point struct {
	X, Y, Z byte
}

type PointWithColour struct {
	Point Point
	Colour byte
}

This feels reasonable so far. It's not a million miles from MagicaVoxelElement in the C# version, although we've split out a separate struct for xyz data that can be used for things like size.

getSizeFromChunk

Speaking of which, let's implement size! I'm going to assume selecting which chunk reader to use is handled by a different function, so the signature looks like this:

func getSizeFromChunk(handle io.Reader) (voxelobject.Point, error) {

}

Here I run into my first environment-specific problem, which is that I didn't set my GOPATH variable properly. Without that set up right Go isn't able to find the voxelobject package. Setting it to D:\projects\gorender\src at a project level fixes the problem and we're back up and running.

My test cases struct is a bit more interesting this time:

struct {
	input []byte
	expected voxelobject.Point
}

I need to learn a few more things about Go:

  • How to initialise a byte slice
  • How to initialise a struct
  • How to compare two structs

The answers, in order:

  • Like any other slice, b := []byte{0x00, 0x01, 0x02}
  • We can pass the fields in order, e.g. p := voxelobject.Point{1,2,3}, but GoLand helpfully suggests p := voxelobject.Point{X: 1, Y: 2, Z: 3} might be less error-prone.
  • If each field is comparable, the struct is trivially comparable using ==

I like that last one!

With that in mind, a test with one simple case can look like this:

func TestGetSizeFromChunk(t *testing.T) {
	var testCases = []struct {
		input    []byte
		expected voxelobject.Point
	}{
		{[]byte{12, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0},
			voxelobject.Point{X: 1, Y: 2, Z: 3}},
	}

	for _, testCase := range testCases {
		reader := bytes.NewReader(testCase.input)
		result, _ := getSizeFromChunk(reader)
		if result != testCase.expected {
			t.Errorf("Byte array %v expected %v, got %v", testCase.input, testCase.expected, result)
		}
	}
}

(We could use 0x1a syntax for the byte array, but this is a bit more readable. Note that MagicalVoxel files are little-endian.)

My test output now looks like this:

D:\projects\gorender\src>go test ./...
?       voxelobject     [no test files]
--- FAIL: TestGetSizeFromChunk (0.00s)
    reader_test.go:53: Byte array [12 0 0 0 0 0 0 0 1 0 0 0 2 0 0 0 3 0 0 0] expected {1 2 3}, got {0 0 0}
FAIL
FAIL    voxelobject/vox 0.166s
FAIL

Which is exactly what I expect, given I haven't implemented the chunk reader. Let's do that now:

func getSizeFromChunk(handle io.Reader) (voxelobject.Point, error) {
	limitedReader := io.LimitReader(handle, 8)
	size, err := ioutil.ReadAll(limitedReader)

	if err != nil {
		return voxelobject.Point{}, err
	}

	parsedSize := int64(binary.LittleEndian.Uint64(size))
	if parsedSize < 12 {
		return voxelobject.Point{}, fmt.Errorf("chunk size %d is less than 12", size)
	}

	limitedReader = io.LimitReader(handle, parsedSize)
	dimensions, err := ioutil.ReadAll(limitedReader)

	if err != nil {
		return voxelobject.Point{}, err
	}

	return voxelobject.Point{
		X: byte(binary.LittleEndian.Uint32(dimensions[0:4])),
		Y: byte(binary.LittleEndian.Uint32(dimensions[4:8])),
		Z: byte(binary.LittleEndian.Uint32(dimensions[8:12])),
	}, nil
}

I feel like I've run into my first major source of Go clunkiness, which is the IO library not having any nice options for reading the next element of a given type from a stream. In C# I have BinaryReader.ReadInt32() which is a lot nicer than the conversion soup I'm having to go through above! This feels like a target for future refactoring, but at least my test passes.

I now add some tests to ensure we return errors for the relevant inputs and that we always read to the end of the data we're given. This turns up a couple of corner cases after which point I have my second chunk reader function implemented.

getPointDataFromChunk

Time for the last of the three chunk reader methods. This one is perhaps more interesting as we're not going to return just a single element of point data - we're going to return a slice of several elements. I'm feeling like I might start bumping up against that IO ugliness again and end up refactoring some of the mechanics of reading integers into their own place, but let's not prematurely optimise.

As before, I start with a simple test. These size declarations are getting a bit annoying, so I'll refactor my test class with the following function:

func getSizedByteSlice(size int64, slice []byte) []byte {
	result := make([]byte, 8)
	binary.LittleEndian.PutUint64(result, uint64(size))
	result = append(result, slice...)
	return result
}

Which now means my calls look a bit cleaner:

getSizedByteSlice(1, []byte{1})

The test still ends up being relatively verbose even for the simple case, but that's because it's not really that "simple" - we need to compare two slices and there's no built-in for this. Some people suggest using reflect.DeepEqual() but as a veteran C# developer I get very nervous when reflection starts getting tossed around as the solution for a simple problem and a short trawl through the source (https://golang.org/src/reflect/deepequal.go) reveals that would be doing a lot more than my 10 lines of comparison code:

func arePointWithColourSlicesEqual(a []voxelobject.PointWithColour, b []voxelobject.PointWithColour) bool {
	if len(a) != len(b) {
		return false
	}

	for i, p := range a {
		if p != b[i] {
			return false
		}
	}

	return true
}

By now I'm noting that I regularly trip up on Go's argument definitions where the variable name and type are reversed from what I expect - it's not a big thing but having the IDE is definitely helping with these little transitional gotchas.

With this in place, my test looks like this:

func TestGetPointDataFromChunk(t *testing.T) {
	var testCases = []struct {
		input    []byte
		expected []voxelobject.PointWithColour
	}{
		{getSizedByteSlice(4, []byte{1, 2, 3, 64}),
			[]voxelobject.PointWithColour{{voxelobject.Point{X: 1, Y: 2, Z: 3}, 64}}},
		{getSizedByteSlice(8, []byte{1, 2, 3, 64, 4, 5, 6, 128}),
			[]voxelobject.PointWithColour{
				{voxelobject.Point{X: 1, Y: 2, Z: 3}, 64},
				{voxelobject.Point{X: 4, Y: 5, Z: 6}, 128},
			}},
	}

	for _, testCase := range testCases {
		reader := bytes.NewReader(testCase.input)
		result, _ := getPointDataFromChunk(reader)

		if !arePointWithColourSlicesEqual(result, testCase.expected) {
			t.Errorf("Byte array %v expected %v, got %v", testCase.input, testCase.expected, result)
		}
	}
}

It's red in exactly the way I'd expect, so onward to implement the function:

func getPointDataFromChunk(handle io.Reader) ([]voxelobject.PointWithColour, error) {
	limitedReader := io.LimitReader(handle, 8)
	size, err := ioutil.ReadAll(limitedReader)
	parsedSize := int64(binary.LittleEndian.Uint64(size))

	if err != nil {
		return getNilValueForPointDataFromChunk(), err
	}

	// Still need to read to the end even if the size
	// is invalid
	limitedReader = io.LimitReader(handle, parsedSize)
	data, err := ioutil.ReadAll(limitedReader)

	if parsedSize %4 != 0 {
		return getNilValueForPointDataFromChunk(), fmt.Errorf("invalid chunk size for xyzi")
	}

	result := make([]voxelobject.PointWithColour, 0)

	for i := 0; i < len(data); i+= 4 {
		point := voxelobject.PointWithColour{
			Point: voxelobject.Point{X: data[i], Y: data[i+1], Z: data[i+2]}, Colour: data[i+3],
		}

		result = append(result, point)
	}

	return result, nil
}

func getNilValueForPointDataFromChunk() []voxelobject.PointWithColour {
	return []voxelobject.PointWithColour{}
}

I'm glad I didn't prematurely refactor as my core loop is rather different to the integer one! In fact the thing which is repeated and needs to be extracted is the size wrangling, so let's do that:

func getSize(handle io.Reader) int64 {
	limitedReader := io.LimitReader(handle, 8)
	size, err := ioutil.ReadAll(limitedReader)

	if err != nil {
		return 0
	}

	parsedSize := int64(binary.LittleEndian.Uint64(size))
	return parsedSize
}

Discarding the error is a bit naughty, I'm relying on everything upstream currently treating a size of 0 as an error case. Refactoring this to its own function has been helpful, though: there are some similar-looking patterns in getting the data that become more obvious now that's extracted away. So let's create a function for that:

func getChunkData(handle io.Reader, minSize int64) ([]byte, error) {
	parsedSize := getSize(handle)

	// Still need to read to the end even if the size
	// is invalid
	limitedReader := io.LimitReader(handle, parsedSize)
	data, err := ioutil.ReadAll(limitedReader)

	if parsedSize < minSize || parsedSize%4 != 0 {
		return nil, fmt.Errorf("invalid chunk size for xyzi")
	}

	if int64(len(data)) < parsedSize {
		return nil, fmt.Errorf("chunk size declared %d but was %d", parsedSize, len(data))
	}

	return data, err
}

And now our two chunk handlers look a lot better. getSizeFromChunk is positively svelte! My initial worries about conversion soup were unfounded as this is looking quite readable and obvious about what's going on:

func getSizeFromChunk(handle io.Reader) (voxelobject.Point, error) {
	data, err := getChunkData(handle, 12)

	if err != nil {
		return voxelobject.Point{}, err
	}

	return voxelobject.Point{
		X: byte(binary.LittleEndian.Uint32(data[0:4])),
		Y: byte(binary.LittleEndian.Uint32(data[4:8])),
		Z: byte(binary.LittleEndian.Uint32(data[8:12])),
	}, nil
}

func getPointDataFromChunk(handle io.Reader) ([]voxelobject.PointWithColour, error) {
	data, err := getChunkData(handle, 4)

	if err != nil {
		return getNilValueForPointDataFromChunk(), err
	}

	result := make([]voxelobject.PointWithColour, len(data)/4)

	for i := 0; i < len(data); i += 4 {
		point := voxelobject.PointWithColour{
			Point: voxelobject.Point{X: data[i], Y: data[i+1], Z: data[i+2]}, Colour: data[i+3],
		}

		result[i/4] = point
	}

	return result, nil
}

I also took the chance to remove the repeated append from getPointDataFromChunk as we know how large the slice needs to be before starting. Having got this far in Go it feels like taking a "systems-y" approach where we care about what we allocate and create is the right thing to do.

Ignored chunks

There's a third type of chunk we need to handle, which is "everything else". I'll spare you the details of the test, but with our refactoring implementing the method is trivial:

func skipUnhandledChunk(handle io.Reader) {
	_, _ = getChunkData(handle, 0)
}

At this point I'll add a few error cases to the existing tests and commit.

Getting voxel objects

I can now read all three types of chunk the C# version is capable of, so let's turn the disassociated readers into something useful. This means defining the voxel object type. Transrender's internal voxel objects are a little different to the representation in the MagicaVoxel file. Instead of point locations and colours in a sparse array, we have a dense array which serves as a three-dimensional block of colour values.

There are two variants of voxel object - one with just the colours from the file, and one with extra details such as lighting normals and the like. We'll take advantage of Go's type safety by creating structs for both these "raw" and "processed" objects, but let's start for now with the raw object:

type RawVoxelObject [][][]byte

This is a "simplest thing that could possibly work" approach. I now need to decide whose responsibility converting xyzi data to this format is. My decision is that xyzi doesn't really exist outside of MagicaVoxel for us, so it should be part of the .vox reader. I'm now feeling refactoring pressure around whether anything else will use Point (possibly) or PointWithColour (less likely) but I don't mind leaving those where they are until we find out.

Next up is to write the test. I'm expecting a function that takes both the size and xyzi data I'll be retrieving, so let's assume that:

func TestGetRawVoxelDataFromXyzi(t *testing.T) {
	size := voxelobject.Point{X: 2, Y: 2, Z: 2}
	data := []voxelobject.PointWithColour{{voxelobject.Point{X: 1, Y: 1, Z: 1}, 255}}

	result := getRawVoxelDataFromXyzi(size, data)
	
	if len(result) != 2 {
		t.Error("x dimension not correctly sized")
	}
	if len(result[0]) != 2 {
		t.Error("y dimension not correctly sized")
	}

	if len(result[0][0]) != 2 {
		t.Error("z dimension not correctly sized")
	}
	
	if result[1][1][1] != 253 {
		t.Error("Point at (1,1,1) was not set")
	}

	if result[0][0][0] != 0 {
		t.Error("Point at (0,0,0) was not left unset")
	}
}

This isn't a complex test and it's a bit on the boilerplate side, but it will serve the purpose. Another Go note: those parentheses-less if statements are another habit from other languages that's tough to break. Luckily it's one of many minor things that's taken care of by running go fmt, the built-in formatting tool.

Note some strange behaviour: Transrender corrects for a palette deficiency in the source files by subtracting 2 from every entry. Maybe there's an opportunity to fix this at source so we don't have that weirdness...

Implementing this is simple enough:

func getRawVoxelDataFromXyzi(size voxelobject.Point, data []voxelobject.PointWithColour) voxelobject.RawVoxelObject {
	result := make([][][]byte, size.X)

	for x := range result {
		result[x] = make([][]byte, size.Y)
		for y := range result[x] {
			result[x][y] = make([]byte, size.Z)
		}
	}

	for _, p := range data {
		if p.Point.X < size.X && p.Point.Y < size.Y && p.Point.Z < size.Z {
			result[p.Point.X][p.Point.Y][p.Point.Z] = p.Colour - 2
		}
	}

	return result
}

Now all that's left is to glue together our chunk readers with this function.

Making it functional

I now need to create the final piece: a function which will take a Reader representing the whole MagicaVoxel file, and return a new RawVoxelObject. This will be called GetRawVoxels with a capital, as I want to export this function from my vox package for other code to use. (Go's convention is that things named with an uppercase initial character get exported, ones with a lower case character don't and are effectively private)

As ever, the first stop is the test. As I want to test mostly the same things as the I did in TestGetRawVoxelDataFromXyzi, I did a bit of refactoring of my tests so they can both share the same testRawVoxelObject function:

func TestGetRawVoxels() {
	testData := []byte{'V','O','X',' '}
	testData = append(testData, []byte{'S','I','Z','E'}...)
	testData = append(testData, getSizedByteSlice(12, []byte{2,0,0,0, 2,0,0,0, 2,0,0,0})...)
	testData = append(testData, []byte{'X','Y','Z','I'}...)
	testData = append(testData, getSizedByteSlice(12, []byte{1,1,1,255,0,1,1,1,20,31,11,1})...)
	testData = append(testData, []byte{'U','N','K',' '}...)
	testData = append(testData, getSizedByteSlice(2, []byte{1,2})...)

	reader := bytes.NewReader(testData)
	result := GetRawVoxels(reader)

	if err != nil {
		t.Errorf("Encountered error %v", err)
	}

	testRawVoxelObject(result, t)
}

func testRawVoxelObject(object voxelobject.RawVoxelObject, t *testing.T) {
	if len(object) != 2 {
		t.Error("x dimension not correctly sized")
        return
	}
	if len(object[0]) != 2 {
		t.Error("y dimension not correctly sized")
        return
	}

	if len(object[0][0]) != 2 {
		t.Error("z dimension not correctly sized")
        return
	}

	if object[1][1][1] != 253 {
		t.Error("Point at (1,1,1) was not set")
	}

	if object[0][1][1] != 255 {
		t.Error("Point at (0,1,1) was not set")
	}

	if object[0][0][0] != 0 {
		t.Error("Point at (0,0,0) was not left unset")
	}
}

Now I have tests, let's make them green. I note that the magic string is almost identical to a chunk identifier, so first I'll refactor that into something a little more reusable:

func isHeaderValid(handle io.Reader) bool {
	result, err := getChunkHeader(handle)
	return err == nil && result == magic
}

func getChunkHeader(handle io.Reader) (string, error) {
	limitedReader := io.LimitReader(handle, 4)
	result, err := ioutil.ReadAll(limitedReader)
	return string(result), err
}

Then use it to implement the function:

func GetRawVoxels(handle io.Reader) (voxelobject.RawVoxelObject, error) {
	if !isHeaderValid(handle) {
		return nil, fmt.Errorf("header not valid")
	}

	size := voxelobject.Point{}
	pointData := make([]voxelobject.PointWithColour, 0)

	for {
		chunkType, err := getChunkHeader(handle)

		if err != nil {
			return nil, fmt.Errorf("error reading chunk header: %v", err)
		}

		if chunkType == "" {
			break
		}

		switch chunkType {
		case "SIZE":
			data, err := getSizeFromChunk(handle)
			if err != nil {
				return nil, fmt.Errorf("error reading size chunk: %v", err)
			}

			// We only expect one SIZE chunk, but use the last value
			size = data
		case "XYZI":
			data, err := getPointDataFromChunk(handle)
			if err != nil {
				return nil, fmt.Errorf("error reading size chunk: %v", err)
			}

			pointData = append(pointData, data...)
		default:
			skipUnhandledChunk(handle)
		}
	}

	if size.X == 0 || size.Y == 0 || size.Z == 0 {
		return nil, fmt.Errorf("invalid size %v", size)
	}

	rawVoxels := getRawVoxelsFromPointData(size, pointData)
	return rawVoxels, nil
}

There are a couple of newly encountered control structures in this implementation of GetRawVoxels. Firstly is Go's empty for {} loop, the equivalent of a while(true) in other languages. Also note that case statements don't fall through. Go's error handling is kind of annoying me - "argh, everything has to decide what to do with every error at every depth". This is apparently normal for newcomers to the language and I'm supposed to appreciate it once I've spent a bit more time refactoring. I can already see it's a lot more obvious about what's going on than having exceptions fly invisibly by, but I still miss the ability to have a top-level "your voxel file is broken, good luck with that" without code to propagate errors at every stage of the journey.

I also did a little bit of renaming to close up my inconsistent usage of both PointData and xyzi to mean the same thing.

Whatever I think of error handling, I now have the equivalent of Transrender's VoxelLoader project in Go. Excluding tests it's 174 lines of code compared to the C# version's 106, but that's not quite a fair comparison as the Go version is a lot more structured and returns sensible errors for most conditions rather than just crashing out with whatever exception happens to occur deep down in the framework. I also suspect that more experience with the IO library will open up some opportunities to streamline that code.

Time to commit and decide what to port across next. Comments and suggestions welcome, I am still far from a Go expert at this stage and correcting existing stuff is just as useful as writing more from a learning point of view.