diff --git a/.gitignore b/.gitignore index 8643ad1..6b92e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /investigation /loader /orig +/view-map diff --git a/Makefile b/Makefile index 667e9c2..36a0ca7 100644 --- a/Makefile +++ b/Makefile @@ -3,3 +3,5 @@ srcfiles = $(shell find . -iname *.go) loader: $(srcfiles) go build -o loader ur.gs/chaos-gate/cmd/load +view-map: $(srcfiles) + go build -o view-map ur.gs/chaos-gate/cmd/view-map diff --git a/cmd/view-map/main.go b/cmd/view-map/main.go new file mode 100644 index 0000000..f8e7cf4 --- /dev/null +++ b/cmd/view-map/main.go @@ -0,0 +1,208 @@ +package main + +import ( + "flag" + "log" + "math" + "os" + "time" + + "github.com/faiface/pixel" + "github.com/faiface/pixel/imdraw" + "github.com/faiface/pixel/pixelgl" + "golang.org/x/image/colornames" + + "ur.gs/chaos-gate/internal/maps" +) + +var ( + gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation") + mapFile = flag.String("map", "", "Prefix path to a .map file, e.g. ./orig/Maps/Chapter01.MAP") + txtFile = flag.String("txt", "", "Prefix path to a .txt file, e.g. ./orig/Maps/Chapter01.txt") + + winX = flag.Int("win-x", 1280, "width of the view-map window") + winY = flag.Int("win-y", 1024, "height of the view-map window") +) + +func main() { + flag.Parse() + + if *gamePath == "" || *mapFile == "" || *txtFile == "" { + flag.Usage() + os.Exit(1) + } + + gameMap, err := maps.LoadGameMapByFiles(*mapFile, *txtFile) + if err != nil { + log.Fatalf("Couldn't load map file: %v", err) + } + + // The main thread now belongs to pixelgl + pixelgl.Run(func() { run(gameMap) }) +} + +type runState struct { + redraw bool + started time.Time + autoUpdate bool + gameMap *maps.GameMap + + zoom float64 + + zIdx int + cellIdx int +} + +func run(gameMap *maps.GameMap) { + cfg := pixelgl.WindowConfig{ + Title: "View Map " + *mapFile, + Bounds: pixel.R(0, 0, float64(*winX), float64(*winY)), + VSync: true, + } + + win, err := pixelgl.NewWindow(cfg) + if err != nil { + log.Fatal("Couldn't create window: %v", err) + } + + state := &runState{ + redraw: true, + started: time.Now(), + autoUpdate: true, + gameMap: gameMap, + zoom: 1.0, + } + + for !win.Closed() { + if state.redraw { + log.Printf("z=%d cellIdx=%d", state.zIdx, state.cellIdx) + presentFull(win, state) + } + + state = runStep(win, state) + + win.Update() + } +} + +// TODO: cut this down to showing just the viewport? +// The naive approach using gameMap.Width() / Height() cuts half the map off :/ +func presentFull(win *pixelgl.Window, state *runState) { + gameMap := state.gameMap + center := win.Bounds().Center() + sz := win.Bounds().Size() + imd := imdraw.New(nil) + + // Rotate everything 45' anticlockwise to get an isometric view with the + // lowest coordinates at the (now) top corner + imd.SetMatrix(pixel.IM.Rotated(center, -math.Pi/4)) + + xPerCell := sz.X / float64(maps.MaxWidth) + yPerCell := sz.Y / float64(maps.MaxLength) + + for y := 0; y < maps.MaxLength; y++ { + for x := 0; x < maps.MaxWidth; x++ { + min := pixel.Vec{X: float64(x) * xPerCell, Y: float64(y) * yPerCell} + max := pixel.Vec{X: min.X + xPerCell, Y: min.Y + yPerCell} + + cell := gameMap.Cells.At(int(x), int(y), int(state.zIdx)) + // TODO: represent the state of the cell *sensibly*, using colour. + // Need to understand the contents better first, so for now optimize + // for exploration + // + // CellIndex=3 shows walls & elevation changes + imd.Color = makeColour(cell, state.cellIdx) + imd.Push(min, max) + imd.Rectangle(0.0) + } + } + + cam := pixel.IM.Scaled(center, state.zoom) + win.SetMatrix(cam) + win.Clear(colornames.Black) + imd.Draw(win) +} + +func makeColour(cell maps.Cell, colIdx int) pixel.RGBA { + return pixel.RGB( + float64(cell[colIdx]), + float64(cell[colIdx]), + float64(cell[colIdx]), + ) +} + +func runStep(win *pixelgl.Window, state *runState) *runState { + nextState := *state + nextState.redraw = false + + // Enable / disable auto-update with the enter key + if win.JustPressed(pixelgl.KeyEnter) { + nextState.autoUpdate = !state.autoUpdate + log.Printf("autoUpdate=%v", nextState.autoUpdate) + + if nextState.autoUpdate { + nextState.started = time.Now() + } + } + + // Automatically cycle every second when auto-update is on + if nextState.autoUpdate && time.Now().Sub(nextState.started) > time.Second { + nextState.redraw = true + nextState.cellIdx = nextState.cellIdx + 1 + if nextState.cellIdx >= maps.CellSize { + nextState.cellIdx = 0 + nextState.zIdx = nextState.zIdx + 1 + } + + if nextState.zIdx >= maps.MaxHeight { + nextState.zIdx = 0 + } + + nextState.started = time.Now() + } + + if win.JustPressed(pixelgl.KeyDown) { + if nextState.zIdx <= 0 { + log.Printf("z index is already at minimum") + } else { + nextState.redraw = true + nextState.zIdx = nextState.zIdx - 1 + } + } + + if win.JustPressed(pixelgl.KeyUp) { + if nextState.zIdx >= maps.MaxHeight-1 { + log.Printf("z index is already at maximum") + } else { + nextState.redraw = true + nextState.zIdx = nextState.zIdx + 1 + } + } + + if win.JustPressed(pixelgl.KeyLeft) { + if nextState.cellIdx <= 0 { + log.Printf("cell index is already at minimum") + } else { + nextState.redraw = true + nextState.cellIdx = nextState.cellIdx - 1 + } + } + + if win.JustPressed(pixelgl.KeyRight) { + if nextState.cellIdx >= maps.CellSize-1 { + log.Printf("cell index is already at maximum") + } else { + nextState.redraw = true + nextState.cellIdx = nextState.cellIdx + 1 + } + } + + // Zoom in and out with the mouse wheel + nextState.zoom *= math.Pow(1.2, win.MouseScroll().Y) + if nextState.zoom != state.zoom { + log.Printf("zoom=%.2f", nextState.zoom) + nextState.redraw = true + } + + return &nextState +} diff --git a/doc/formats/img/cell-information-debug.png b/doc/formats/img/cell-information-debug.png new file mode 100644 index 0000000..e86a1c1 Binary files /dev/null and b/doc/formats/img/cell-information-debug.png differ diff --git a/doc/formats/img/chapter_01_cell_index_3.png b/doc/formats/img/chapter_01_cell_index_3.png new file mode 100644 index 0000000..5c2ef6b Binary files /dev/null and b/doc/formats/img/chapter_01_cell_index_3.png differ diff --git a/doc/formats/maps.md b/doc/formats/maps.md index 3659353..fe89f33 100644 --- a/doc/formats/maps.md +++ b/doc/formats/maps.md @@ -293,6 +293,8 @@ Total number of possible coordinates is 100x130x7 = 91,000 = 1,456,000 bytes. Uncompressed file size is always 1,458,915 bytes, leaving 2,915 bytes over for header and any trailing data. +How are the rows structured? `[z][y][x]`? + Looking at the data around 0x163890, we see: ``` @@ -328,6 +330,67 @@ So this looks like a good working theory. Wherever they start, these rows must refer to the object sets somehow. We also need to work out how they're arranged. +Disassembly of WH40K_TD.EXE suggests a file `Engine::CellInfo` at 0x4226F0. It +dumps the following information: + +* Mission exit: true / false +* Smoke: true / false +* Sprite: true / false +* Vehicle: true / false +* Net start: true / false +* Spell: true / false +* Trigger: true / false +* Breadcrumb: true / false +* Door: true / false +* Lock: true / false +* Reactor: true / false +* Objective: true / false +* Canister number %d +* Cell visible %d +* Tile visible %d +* N0 - N7: true / false (seems to be a bitfield) +* Object 0 SURFACE: true / false +* Object area %d +* Curr sprite %d +* Object 1 LEFT: true / false +* Object area %d +* Curr sprite %d +* Object 2 RIGHT: true / false +* Object area %d +* Curr sprite %d +* Object 3 CENTRE: true / false +* Object area %d +* Curr sprite %d + +Presumably at least some of these attributes come from the records identified +above. Will need to experiment to establish correlations. Items of interest: + +* A cell can have 4 objects in it - a surface, a left, a right, and a centre. +* Presumably a surface is what we show on the ground, and it will be set for + "ground level" data. + +Press "U" to get to the screen! + +![Debug screen](img/cell-information-debug.png) + +I've added a `view-map` command to explore the data graphically. Each of the 16 +bytes in a cell row must have a function; comparing a known map to how it looks +in WH40K_TD.exe can help me to unravel that function. + +Here's a side-by-side comparison of Chapter3.MAP, investigating CellIndex=3 and +Z index = 0 + +![Map comparison](img/chapter_01_cell_index_3.png) + +Incrementing the Z index shows the building and terrain progressively changing +as expected, so this is a good hint that the data is actually arranged in +`[z][y][x][16]` blocks and that I either have the right offset, or I'm out by a +well-aligned amount. + +Trying to constrict to the viewport currently cuts off much of this displayed +map, suggesting that I've got something wrong in the calculation somewhere. Need +to dig into that more. + ## Trailer Assuming the theory above is correct, we have trailer data starting at diff --git a/internal/maps/maps.go b/internal/maps/maps.go index a0bf864..a471dd4 100644 --- a/internal/maps/maps.go +++ b/internal/maps/maps.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "encoding/binary" "fmt" + "io" "io/ioutil" "log" "os" @@ -18,6 +19,17 @@ var ( notImplemented = fmt.Errorf("Not implemented") ) +const ( + MaxHeight = 7 // Z coordinate + MaxLength = 100 // Y coordinate + MaxWidth = 130 // X coordinate + + CellSize = 16 // seems to be + + cellDataOffset = 0x100 // tentatively + cellDataSize = MaxHeight * MaxLength * MaxWidth * CellSize +) + type Header struct { IsCampaignMap uint32 // Tentatively: 0 = no, 1 = yes MinWidth uint32 @@ -31,7 +43,37 @@ type Header struct { Magic [8]byte // "\x08\x00WHMAP\x00" Unknown5 uint32 Unknown6 uint32 - SetName [8]byte // Or is it a null-terminated string? + SetName [8]byte // Links to a filename in `/Sets/*.set` + // Need to investigate the rest of the header too +} + +func (h Header) Width() int { + return int(h.MaxWidth - h.MinWidth) +} + +func (h Header) Length() int { + return int(h.MaxLength - h.MinLength) +} + +func (h Header) Height() int { + return MaxHeight +} + +type Cell []byte // FIXME: need to deconstruct this into the various fields + +// Cells is always a fixed size; use At to get a cell according to x,y,z +type Cells []byte + +// FIXME: Ordering may be incorrect? I assume z,y,x for now... +func (c Cells) At(x, y, z int) Cell { + start := + (z * MaxLength * MaxWidth * CellSize) + + (y * MaxWidth * CellSize) + + (x * CellSize) + + end := start + CellSize + + return Cell(c[start:end:end]) } func (h Header) Check() []error { @@ -49,7 +91,7 @@ func (h Header) Check() []error { type GameMap struct { Header - + Cells // TODO: parse this into sections Text string } @@ -57,8 +99,8 @@ type GameMap struct { // A game map contains a .txt and a .map. If they're in the same directory, // just pass the directory + basename to load both func LoadGameMap(prefix string) (*GameMap, error) { - for _, mapExt := range []string{".MAP", ".map"} { - for _, txtExt := range []string{".TXT", ".txt"} { + for _, txtExt := range []string{".TXT", ".txt"} { + for _, mapExt := range []string{".MAP", ".map"} { out, err := LoadGameMapByFiles(prefix+mapExt, prefix+txtExt) if err != nil && !os.IsNotExist(err) { return nil, err @@ -146,5 +188,16 @@ func loadMapFile(filename string) (*GameMap, error) { return nil, fmt.Errorf("Error parsing %s: %v", filename, err) } + // no gzip.SeekReader, so discard unread header bytes for now + discardSize := int64(cellDataOffset - binary.Size(&out.Header)) + if _, err := io.CopyN(ioutil.Discard, zr, discardSize); err != nil { + return nil, err + } + + out.Cells = make(Cells, cellDataSize) + if err := binary.Read(zr, binary.LittleEndian, &out.Cells); err != nil { + return nil, fmt.Errorf("Error parsing %s: %v", filename, err) + } + return &out, nil }