Add the view-map command and some exploration of results
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/investigation
|
/investigation
|
||||||
/loader
|
/loader
|
||||||
/orig
|
/orig
|
||||||
|
/view-map
|
||||||
|
2
Makefile
2
Makefile
@@ -3,3 +3,5 @@ srcfiles = $(shell find . -iname *.go)
|
|||||||
loader: $(srcfiles)
|
loader: $(srcfiles)
|
||||||
go build -o loader ur.gs/chaos-gate/cmd/load
|
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
208
cmd/view-map/main.go
Normal 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
|
||||||
|
}
|
BIN
doc/formats/img/cell-information-debug.png
Normal file
BIN
doc/formats/img/cell-information-debug.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
BIN
doc/formats/img/chapter_01_cell_index_3.png
Normal file
BIN
doc/formats/img/chapter_01_cell_index_3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
@@ -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
|
Uncompressed file size is always 1,458,915 bytes, leaving 2,915 bytes over for
|
||||||
header and any trailing data.
|
header and any trailing data.
|
||||||
|
|
||||||
|
How are the rows structured? `[z][y][x]`?
|
||||||
|
|
||||||
Looking at the data around 0x163890, we see:
|
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
|
Wherever they start, these rows must refer to the object sets somehow. We also
|
||||||
need to work out how they're arranged.
|
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!
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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
|
## Trailer
|
||||||
|
|
||||||
Assuming the theory above is correct, we have trailer data starting at
|
Assuming the theory above is correct, we have trailer data starting at
|
||||||
|
@@ -5,6 +5,7 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -18,6 +19,17 @@ var (
|
|||||||
notImplemented = fmt.Errorf("Not implemented")
|
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 {
|
type Header struct {
|
||||||
IsCampaignMap uint32 // Tentatively: 0 = no, 1 = yes
|
IsCampaignMap uint32 // Tentatively: 0 = no, 1 = yes
|
||||||
MinWidth uint32
|
MinWidth uint32
|
||||||
@@ -31,7 +43,37 @@ type Header struct {
|
|||||||
Magic [8]byte // "\x08\x00WHMAP\x00"
|
Magic [8]byte // "\x08\x00WHMAP\x00"
|
||||||
Unknown5 uint32
|
Unknown5 uint32
|
||||||
Unknown6 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 {
|
func (h Header) Check() []error {
|
||||||
@@ -49,7 +91,7 @@ func (h Header) Check() []error {
|
|||||||
|
|
||||||
type GameMap struct {
|
type GameMap struct {
|
||||||
Header
|
Header
|
||||||
|
Cells
|
||||||
// TODO: parse this into sections
|
// TODO: parse this into sections
|
||||||
Text string
|
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,
|
// A game map contains a .txt and a .map. If they're in the same directory,
|
||||||
// just pass the directory + basename to load both
|
// just pass the directory + basename to load both
|
||||||
func LoadGameMap(prefix string) (*GameMap, error) {
|
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)
|
out, err := LoadGameMapByFiles(prefix+mapExt, prefix+txtExt)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -146,5 +188,16 @@ func loadMapFile(filename string) (*GameMap, error) {
|
|||||||
return nil, fmt.Errorf("Error parsing %s: %v", filename, err)
|
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
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user