Add the view-map command and some exploration of results

This commit is contained in:
2018-03-18 03:35:03 +00:00
parent 60b2a416c4
commit d572a19352
7 changed files with 331 additions and 4 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/investigation
/loader
/orig
/view-map

View File

@@ -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

208
cmd/view-map/main.go Normal file
View File

@@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -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

View File

@@ -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
}