diff --git a/cmd/loader/main.go b/cmd/loader/main.go index 80ccc80..41e7a49 100644 --- a/cmd/loader/main.go +++ b/cmd/loader/main.go @@ -123,5 +123,11 @@ func loadSets() { for key, mapSet := range mapSets { fmt.Printf(" * `%s`: Defs=%#v len(palette)=%d\n", key, mapSet.Defs, len(mapSet.Palette)) + if key == "map01.set" { + for i, objName := range mapSet.Palette { + fmt.Printf(" %d. %s\n", i, objName) + } + } + } } diff --git a/cmd/view-map/main.go b/cmd/view-map/main.go index f8e7cf4..7da9ae6 100644 --- a/cmd/view-map/main.go +++ b/cmd/view-map/main.go @@ -5,6 +5,7 @@ import ( "log" "math" "os" + "path/filepath" "time" "github.com/faiface/pixel" @@ -13,6 +14,7 @@ import ( "golang.org/x/image/colornames" "ur.gs/chaos-gate/internal/maps" + "ur.gs/chaos-gate/internal/sets" ) var ( @@ -24,6 +26,26 @@ var ( winY = flag.Int("win-y", 1024, "height of the view-map window") ) +type env struct { + gameMap *maps.GameMap + set sets.MapSet +} + +type runState struct { + env *env + + autoUpdate bool + started time.Time + + cam pixel.Matrix + camPos pixel.Vec + + zoom float64 + + zIdx int + cellIdx int +} + func main() { flag.Parse() @@ -37,23 +59,21 @@ func main() { log.Fatalf("Couldn't load map file: %v", err) } + setFile := filepath.Join(*gamePath, "Sets", gameMap.MapSetFilename()) + log.Println(setFile) + mapSet, err := sets.LoadSet(setFile) + if err != nil { + log.Fatalf("Couldn't load set file %s: %v", setFile, err) + } + + env := &env{gameMap: gameMap, set: mapSet} + // The main thread now belongs to pixelgl - pixelgl.Run(func() { run(gameMap) }) + pixelgl.Run(func() { run(env) }) } -type runState struct { - redraw bool - started time.Time - autoUpdate bool - gameMap *maps.GameMap - - zoom float64 - - zIdx int - cellIdx int -} - -func run(gameMap *maps.GameMap) { +func run(env *env) { + // WARE! 0,0 is the *bottom left* of the window cfg := pixelgl.WindowConfig{ Title: "View Map " + *mapFile, Bounds: pixel.R(0, 0, float64(*winX), float64(*winY)), @@ -66,74 +86,141 @@ func run(gameMap *maps.GameMap) { } state := &runState{ - redraw: true, - started: time.Now(), + env: env, autoUpdate: true, - gameMap: gameMap, - zoom: 1.0, + + camPos: pixel.V(0, float64(-*winY)), + + zoom: 8.0, } for !win.Closed() { - if state.redraw { - log.Printf("z=%d cellIdx=%d", state.zIdx, state.cellIdx) - presentFull(win, state) - } - + oldState := *state state = runStep(win, state) + if oldState != *state { + log.Printf("z=%d cellIdx=%d", state.zIdx, state.cellIdx) + present(win, state) + } + win.Update() } } +// Converts pixel coordinates to cell coordinates +func vecToCell(vec pixel.Vec) (int, int) { + x := int(vec.X) + y := int(vec.Y) + + if x < 0 { + x = 0 + } + + if x > maps.MaxWidth-1 { + x = maps.MaxWidth - 1 + } + + if y < 0 { + y = 0 + } + + if y > maps.MaxLength-1 { + y = maps.MaxLength - 1 + } + + return x, y +} + +func cellToVec(x, y int) pixel.Rect { + min := pixel.Vec{X: float64(x), Y: float64(y)} + max := pixel.Vec{X: min.X + 1, Y: min.Y + 1} + return pixel.Rect{Min: min, Max: max} +} + // 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() +func present(win *pixelgl.Window, state *runState) { + gameMap := state.env.gameMap 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} - + for y := gameMap.MinLength; y < gameMap.MaxLength; y++ { + for x := gameMap.MinWidth; x < gameMap.MaxWidth; x++ { + rect := cellToVec(int(x), int(y)) 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.Color = makeColour(&cell, state.cellIdx) + imd.Push(rect.Min, rect.Max) imd.Rectangle(0.0) } } - cam := pixel.IM.Scaled(center, state.zoom) - win.SetMatrix(cam) + // Draw the boundary + rect := pixel.R( + float64(gameMap.MinWidth)-1, float64(gameMap.MinLength)-1, + float64(gameMap.MaxWidth)+1, float64(gameMap.MaxLength)+1, + ) + + imd.Color = pixel.RGB(255, 0, 0) + imd.EndShape = imdraw.SharpEndShape + imd.Push(rect.Min, rect.Max) + imd.Rectangle(1.0) + + center := win.Bounds().Center() + + cam := pixel.IM + cam = cam.ScaledXY(pixel.ZV, pixel.Vec{1.0, -1.0}) // invert the Y axis + cam = cam.Scaled(pixel.ZV, state.zoom) // apply current zoom factor + cam = cam.Moved(center.Sub(state.camPos)) // Make it central + cam = cam.Rotated(center.Sub(state.camPos), -0.785) // Apply isometric angle + state.cam = cam + + win.SetMatrix(state.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 makeColour(cell *maps.Cell, colIdx int) pixel.RGBA { + var scale func(float64) float64 + + mult := func(factor float64) func(float64) float64 { + return func(in float64) float64 { return in * factor } + } + + // Different columns do better with different levels of greyscale. + + switch colIdx { + case 0: + scale = mult(0.004) + case 1: + scale = mult(0.1) + case 2: + scale = mult(1.0) + case 3: + scale = mult(0.1) + case 4: + scale = func(in float64) float64 { return mult(0.01)(in - 100) } + case 10: + scale = func(in float64) float64 { return mult(0.01)(in - 100) } + case 12: + scale = mult(0.004) + case 13: + scale = mult(0.004) + case 14: + scale = mult(0.004) + default: + scale = mult(0.01) // close to maximum resolution, low-value fields will be lost + + } + + col := scale(float64(cell.At(colIdx))) + return pixel.RGB(col, col, col) } 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) { @@ -146,8 +233,7 @@ func runStep(win *pixelgl.Window, state *runState) *runState { } // Automatically cycle every second when auto-update is on - if nextState.autoUpdate && time.Now().Sub(nextState.started) > time.Second { - nextState.redraw = true + if nextState.autoUpdate && time.Now().Sub(state.started) > 500*time.Millisecond { nextState.cellIdx = nextState.cellIdx + 1 if nextState.cellIdx >= maps.CellSize { nextState.cellIdx = 0 @@ -161,47 +247,61 @@ func runStep(win *pixelgl.Window, state *runState) *runState { 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.Pressed(pixelgl.KeyLeft) { + nextState.camPos.X -= 4 + } + + if win.Pressed(pixelgl.KeyRight) { + nextState.camPos.X += 4 + } + + if win.Pressed(pixelgl.KeyDown) { + nextState.camPos.Y -= 4 + } + + if win.Pressed(pixelgl.KeyUp) { + nextState.camPos.Y += 4 + } + + for i := 0; i <= 6; i++ { + if win.JustPressed(pixelgl.Key1 + pixelgl.Button(i)) { + nextState.zIdx = i } } - 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 + // Decrease the cell index + if win.JustPressed(pixelgl.KeyMinus) { + if nextState.cellIdx > 0 { + nextState.cellIdx -= 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 + // Increase the cell index + if win.JustPressed(pixelgl.KeyEqual) { + if nextState.cellIdx < maps.CellSize-1 { + 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 - } + // Show details of clicked-on cell in termal + if win.JustPressed(pixelgl.MouseButtonLeft) { + vec := state.cam.Unproject(win.MousePosition()) + x, y := vecToCell(vec) + log.Printf("%#v -> %d,%d", vec, x, y) + cell := state.env.gameMap.Cells.At(x, y, state.zIdx) + log.Printf( + "x=%d y=%d z=%d Object0SurfaceArea=%d Object3CenterArea=%d (%s) SquadRelated=%d", + x, y, state.zIdx, + cell.Object0SurfaceArea, cell.Object3CenterArea, state.env.set.Palette[int(cell.Object3CenterArea)], + cell.SquadRelated, + ) + log.Printf("CellIdx%d=%d. Full cell data: %#v", state.cellIdx, cell.At(state.cellIdx), cell) } // 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/chapter01_cell_index_9.png b/doc/formats/img/chapter01_cell_index_9.png new file mode 100644 index 0000000..7130773 Binary files /dev/null and b/doc/formats/img/chapter01_cell_index_9.png differ diff --git a/doc/formats/maps.md b/doc/formats/maps.md index fe89f33..6e28a7c 100644 --- a/doc/formats/maps.md +++ b/doc/formats/maps.md @@ -248,11 +248,11 @@ We're specifying a viewport on a statically-defined area with a width of 130, and a length of 100. The viewport is centered on the middle of that area and anything outside it is clipped away. -## Per-coordinate data +## Per-cell data All .MAP files are the same size once uncompressed, regardles of width and length parameters, suggesting that they each have fixed-sized records for every -possible coordinate, regardless. +possible cell. Skipping the header we know about, we have data like this: @@ -285,8 +285,10 @@ Skipping the header we know about, we have data like this: 000001F0 38 00 00 00 88 01 00 00 00 00 00 FF 00 00 00 00 8............... ``` -It would be very neat if the per-coordinate data started at 0x100 and took 16 -bytes per coordinate. +It would be very neat if the per-cell data started at 0x100 and took 16 bytes +per coordinate, but to get objects in my map to line up properly with cells in +WH40K_TD.exe, I've had to start parsing these rows at `0x0120` instead. Still +tentative! Total number of possible coordinates is 100x130x7 = 91,000 = 1,456,000 bytes. @@ -387,9 +389,55 @@ 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. +Investigation has so far suggested the following: + +* `Cell[0]` seems related to doors and canisters. Observed: + * Imperial crate: 0x28 + * Door: 0xB8 +* `Cell[1]` seems related to special placeables (but not triggers). Bitfield. Observed: + * 0x01: Reactor + * 0x20: Door or door lock? + * 0x40: Animated object +* `Cell[2]` hasn't been seen with a value > 0 yet +* `Cell[3]` Object 0 (Surface) Area (Sets/*.set lookup) +* `Cell[4]` Unsure at present, but it varies between a narrow range of values. + I've seen 128 - 147. Broadly matches the terrain layout. +* `Cell[5]` Object 1 (Left) Area (Sets/*.set lookup) **ASSUMED**. + * It's in the right place, and there seems to be correspondence, but not as + neatly as the other 3 columns. Often off-by-1 +* `Cell[6]` Wide range of values, 0 - 161 observed. Seems to have identity with + some blood splatters, etc +* `Cell[7]` Object 2 (Right) Area (Sets/*.set lookup) +* `Cell[8]` Wide range of values, 0 - 159 observed. +* `Cell[9]` Object 3 (Center) Area (Sets/*.set lookup) +* `Cell[10]` Varies from 0 - 248. Unclear what for, broadly follows terrain +* `Cell[11]` all 255? +* `Cell[12]` all 0? +* `Cell[13]` all 0? +* `Cell[14]` all 0? +* `Cell[15]` shows squad positions, MP start positions, etc, as 0x04 + +Mapping the altar in Chapter01 to the map01 set suggests it's a palette entry +lookup, 0-indexed. `U` debug in WH40K_TD.exe says the cell's `Object 3-Center` +has `Area 67` and `Sprite 1`. In `Sets/map01.set`, entry 67, zero-indexed and +ignoring non-palette data, is an altar. + +The data of that cell is: + +``` +maps.Cell{ + 0x18, 0x0, 0x0, 0x2, 0x99, 0x1, 0x0, 0x0, + 0x0, 0x43, 0x81, 0xff, 0x0, 0x0, 0x0, 0x0 +} +``` + +So `CellIdx == 9` points to the center object's Area, looked up in the set file! + +![Pinning down cell index 9](img/chapter01_cell_index_9.png + +I still see weird artifacts on middle Z-layers making me think I'm off the +stride or perhaps there's a data block partway through or something. Or maybe +the data's just born that way. ## Trailer @@ -459,4 +507,4 @@ Around 001841A0: mission objectives! ``` Since all the files are exactly the same length uncompressed, I'm going to -assume these are all a fixed number of fixed-size records when looking into it. \ No newline at end of file +assume these are all a fixed number of fixed-size records when looking into it. diff --git a/internal/maps/maps.go b/internal/maps/maps.go index a471dd4..ff9f5d7 100644 --- a/internal/maps/maps.go +++ b/internal/maps/maps.go @@ -26,8 +26,8 @@ const ( CellSize = 16 // seems to be - cellDataOffset = 0x100 // tentatively - cellDataSize = MaxHeight * MaxLength * MaxWidth * CellSize + cellDataOffset = 0x120 // tentatively + cellCount = MaxHeight * MaxLength * MaxWidth ) type Header struct { @@ -59,21 +59,79 @@ func (h Header) Height() int { return MaxHeight } -type Cell []byte // FIXME: need to deconstruct this into the various fields +func (h Header) MapSetFilename() string { + idx := bytes.IndexByte(h.SetName[:], 0) + if idx < 0 { + idx = 8 // all 8 bytes are used + } + + return string(h.SetName[0:idx:idx]) + ".set" +} + +type Cell struct { + DoorAndCanisterRelated byte + DoorLockAndReactorRelated byte + Unknown2 byte + Object0SurfaceArea byte + Unknown4 byte + Object1LeftArea byte + Unknown6 byte + Object2RightArea byte + Unknown8 byte + Object3CenterArea byte + Unknown10 byte + Unknown11 byte + Unknown12 byte + Unknown13 byte + Unknown14 byte + SquadRelated byte +} + +func (c *Cell) At(n int) byte { + switch n { + case 0: + return c.DoorAndCanisterRelated + case 1: + return c.DoorLockAndReactorRelated + case 2: + return c.Unknown2 + case 3: + return c.Object0SurfaceArea + case 4: + return c.Unknown4 + case 5: + return c.Object1LeftArea + case 6: + return c.Unknown6 + case 7: + return c.Object2RightArea + case 8: + return c.Unknown8 + case 9: + return c.Object3CenterArea + case 10: + return c.Unknown10 + case 11: + return c.Unknown11 + case 12: + return c.Unknown12 + case 13: + return c.Unknown13 + case 14: + return c.Unknown14 + case 15: + return c.SquadRelated + } + + return 0 +} // Cells is always a fixed size; use At to get a cell according to x,y,z -type Cells []byte +type Cells []Cell // 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]) + return c[(z*MaxLength*MaxWidth)+(y*MaxWidth)+x] } func (h Header) Check() []error { @@ -194,9 +252,9 @@ func loadMapFile(filename string) (*GameMap, error) { return nil, err } - out.Cells = make(Cells, cellDataSize) + out.Cells = make(Cells, cellCount) if err := binary.Read(zr, binary.LittleEndian, &out.Cells); err != nil { - return nil, fmt.Errorf("Error parsing %s: %v", filename, err) + return nil, fmt.Errorf("Error parsing cells for %s: %v", filename, err) } return &out, nil