Map generator -verbose and -performance flags (#2721)

Resolves #2718

## Description:

Adds go-style error log levels, with an additional ALL log level.

- WARN/ERROR - Only success output
- INFO - Existing output
- DEBUG - New output
- ALL - New output (includes the logs from when removal/performance is
enabled)

In addition

- Add `-verbose` (`-v`), `-log-level`, `-log-removal`, and
`-log-performance` flags to map generator
- No changes to default behavior of `go run .` without the new flags
- excludes test maps from performance warnings (test maps already skip
the removal steps)
- updates readme with the different flags and how they impact the logger

Default run (matches existing)
`go run . >> output.txt 2>&1`

[output.txt](https://github.com/user-attachments/files/24365745/output.txt)

Default run w/ `-verbose` (log level DEBUG)
`go run . -v >> output.txt 2>&1`

[output.txt](https://github.com/user-attachments/files/24365812/output.txt)

Default run w/ `-log-performance`
`go run . -log-performance >> output.txt 2>&1`

[output.txt](https://github.com/user-attachments/files/24365971/output.txt)

Run of just africa w/ all new logging enabled
`go run . -maps=africa -log-level=all >> output.txt 2>&1`

[output.txt](https://github.com/user-attachments/files/24365724/output.txt)


## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

tidwell

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Aaron Tidwell
2026-01-15 17:40:45 -05:00
committed by GitHub
parent 01e682b576
commit 9cd87f8906
4 changed files with 326 additions and 40 deletions
+21
View File
@@ -41,6 +41,27 @@ To process a subset of maps, pass a comma-separated list:
- `../resources/maps/<map_name>/map16x.bin` - 1/16 scale (quarter dimensions) binary map data used for mini-maps.
- `../resources/maps/<map_name>/thumbnail.webp` - WebP image thumbnail of the map.
## Command Line Flags
- `--maps`: Optional comma-separated list of maps to process.
- ex: `go run . --maps=world,eastasia,big_plains`
### Logging
- `--log-level`: Explicitly sets the log level.
- ex: `go run . --log-level=debug`
- values: `ALL`, `DEBUG`, `INFO` (default), `WARN`, `ERROR`.
- `--verbose` or `-v`: Adds additional logging and prefixes logs with the `[mapname]`. Alias of `--log-level=DEBUG`.
- `--debug-performance`: Adds additional logging for performance-based recommendations, sets `--log-level=DEBUG`.
- `--debug-removal`: Adds additional logging of removed island and lake position/size, sets `--log-level=DEBUG`.
The Generator outputs logs using `slog` with standard log-levels, and an additional ALL level.
The `--verbose`, `-v`, `--debug-performance`, and `--debug-removal` flags all set the log level to `DEBUG`.
`debug-performance` and `debug-removal` are opt-in on top of the debug log level, as they can produce wordy output. You must pass the specific flag to see the corresponding logs if the `log-level` is set to `DEBUG`.
Setting `--log-level=ALL` will output all possible logs, including all `DEBUG` tiers, regardless of whether the specific flags are passed.
## Create image.png
The map-generator will process your input file at `assets/maps/<map_name>/image.png` to generate the map
+213
View File
@@ -0,0 +1,213 @@
// This is the custom logger providing the multi-level and flag-based logging for
// the map-generator. It uses slog.
package main
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync"
)
type LogFlags struct {
logLevel string // The log-level (most -> least wordy): ALL, DEBUG, INFO (default), WARN, ERROR
verbose bool // sets log-level=DEBUG
performance bool // opts-in to performance checks and sets log-level=DEBUG
removal bool // opts-in to island/lake removal logging and sets log-level=DEBUG
}
// LevelAll is a custom log Level that outputs all messages, regardless of other passed flags
const LevelAll = slog.Level(-8)
// PerformanceLogTag is a slog attribute used to tag performance-related log messages.
var PerformanceLogTag = slog.String("tag", "performance")
// RemovalLogTag is a slog attribute used to tag land/water removal-related log messages.
var RemovalLogTag = slog.String("tag", "removal")
// DetermineLogLevel determines the log level based on the LogFlags
// It prioritizes the log level flag over the default, and switches to debug if performance or removal flags are set.
func DetermineLogLevel(
logFlags LogFlags) slog.Level {
var level = slog.LevelInfo
if logFlags.verbose {
level = slog.LevelDebug
}
// switch to debug if any of the optional flags is enabled
if logFlags.performance || logFlags.removal {
level = slog.LevelDebug
}
// parse the log-level input string to the slog.Level type
if logFlags.logLevel != "" {
switch strings.ToLower(logFlags.logLevel) {
case "all":
level = LevelAll
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
fmt.Printf("invalid log level: %s, defaulting to info\n", logFlags.logLevel)
level = slog.LevelInfo
}
}
return level
}
// GeneratorLogger is a custom slog.Handler that outputs logs based on log level and additional LogFlags.
type GeneratorLogger struct {
opts slog.HandlerOptions
w io.Writer
mu *sync.Mutex
attrs []slog.Attr
prefix string
flags LogFlags
}
// NewGeneratorLogger creates a new GeneratorLogger.
// It initializes a handler with specific output, options, and flags
func NewGeneratorLogger(
out io.Writer,
opts *slog.HandlerOptions,
flags LogFlags) *GeneratorLogger {
h := &GeneratorLogger{
w: out,
mu: &sync.Mutex{},
flags: flags,
}
if opts != nil {
h.opts = *opts
}
if h.opts.Level == nil {
h.opts.Level = slog.LevelInfo
}
return h
}
// Enabled checks if a given log level is enabled for this handler.
func (h *GeneratorLogger) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.opts.Level.Level()
}
// Handle processes a log record.
// It decides whether to output each record based on log level, flags, and if the map is a test map
// On output, it formats the log message with any extra formatting
func (h *GeneratorLogger) Handle(_ context.Context, r slog.Record) error {
isPerformanceLog := false
isRemovalLog := false
isTestMap := false
var mapName string
findAttrs := func(a slog.Attr) {
if a.Equal(PerformanceLogTag) {
isPerformanceLog = true
}
if a.Equal(RemovalLogTag) {
isRemovalLog = true
}
if a.Key == "map" {
mapName = a.Value.String()
}
if a.Key == "isTest" {
isTestMap = a.Value.Bool()
}
}
// Check record attributes for performance tag and map name
r.Attrs(func(a slog.Attr) bool {
findAttrs(a)
return true
})
// Check handler's own attributes for performance tag and map name
for _, a := range h.attrs {
findAttrs(a)
}
// Don't log messages if the flags are not set
// If the log level is set to LevelAll, disregard
if h.opts.Level != LevelAll && isPerformanceLog && !h.flags.performance {
return nil
}
if h.opts.Level != LevelAll && (isRemovalLog && !h.flags.removal) {
return nil
}
// dont log performance messages for test maps
if isPerformanceLog && isTestMap {
return nil
}
buf := &bytes.Buffer{}
// Add map name as a prefix in log Level DEBUG and ALL
if (h.opts.Level == slog.LevelDebug || h.opts.Level == LevelAll) && mapName != "" {
mapName = strings.Trim(mapName, `"`)
fmt.Fprintf(buf, "[%s] ", mapName)
}
// Add prefix for performance messages
if isPerformanceLog {
fmt.Fprintf(buf, "[PERF] ")
}
if h.prefix != "" {
fmt.Fprintf(buf, "%s ", h.prefix)
}
fmt.Fprintln(buf, r.Message)
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.w.Write(buf.Bytes())
return err
}
// WithAttrs returns a new handler with the given attributes added.
func (h *GeneratorLogger) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandler := *h
newHandler.attrs = append(newHandler.attrs, attrs...)
return &newHandler
}
// WithGroup returns a new handler with the given group name.
// The group name is added as a prefix to subsequent log messages.
func (h *GeneratorLogger) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
newHandler := *h
if newHandler.prefix != "" {
newHandler.prefix += "."
}
newHandler.prefix += name
return &newHandler
}
type loggerKey struct{}
// LoggerFromContext retrieves the logger from the context.
// If no logger is found, it returns the default logger.
func LoggerFromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
// ContextWithLogger returns a new context with the provided logger.
func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}
+30 -6
View File
@@ -1,19 +1,18 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
)
// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument.
var mapsFlag string
// maps defines the registry of available maps to be processed.
// Each entry contains the folder name and a flag indicating if it's a test map.
//
@@ -75,6 +74,12 @@ var maps = []struct {
{Name: "world", IsTest: true},
}
// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument.
var mapsFlag string
// logFlags holds all the flags related to configuring the map-generator logging
var logFlags LogFlags
// outputMapDir returns the absolute path to the directory where generated map files should be written.
// It distinguishes between test and production output locations.
func outputMapDir(isTest bool) (string, error) {
@@ -104,7 +109,7 @@ func inputMapDir(isTest bool) (string, error) {
// processMap handles the end-to-end generation for a single map.
// It reads the source image and JSON, generates the terrain data, and writes the binary outputs and updated manifest.
func processMap(name string, isTest bool) error {
func processMap(ctx context.Context, name string, isTest bool) error {
outputMapBaseDir, err := outputMapDir(isTest)
if err != nil {
return fmt.Errorf("failed to get map directory: %w", err)
@@ -135,7 +140,7 @@ func processMap(name string, isTest bool) error {
}
// Generate maps
result, err := GenerateMap(GeneratorArgs{
result, err := GenerateMap(ctx, GeneratorArgs{
ImageBuffer: imageBuffer,
RemoveSmall: !isTest, // Don't remove small islands for test maps
Name: name,
@@ -230,7 +235,11 @@ func loadTerrainMaps() error {
mapItem := mapItem
go func() {
defer wg.Done()
if err := processMap(mapItem.Name, mapItem.IsTest); err != nil {
mapLogTag := slog.String("map", mapItem.Name)
testLogTag := slog.Bool("isTest", mapItem.IsTest)
logger := slog.Default().With(mapLogTag).With(testLogTag)
ctx := ContextWithLogger(context.Background(), logger)
if err := processMap(ctx, mapItem.Name, mapItem.IsTest); err != nil {
errChan <- err
}
}()
@@ -254,8 +263,23 @@ func loadTerrainMaps() error {
// It parses flags and triggers the map generation process.
func main() {
flag.StringVar(&mapsFlag, "maps", "", "optional comma-separated list of maps to process. ex: --maps=world,eastasia,big_plains")
flag.StringVar(&logFlags.logLevel, "log-level", "", "Explicitly sets the log level to one of: ALL, DEBUG, INFO (default), WARN, ERROR.")
flag.BoolVar(&logFlags.verbose, "verbose", false, "Adds additional logging and prefixes logs with the [mapname]. Alias of log-level=DEBUG.")
flag.BoolVar(&logFlags.verbose, "v", false, "-verbose shorthand")
flag.BoolVar(&logFlags.performance, "log-performance", false, "Adds additional logging for performance-based recommendations, sets log-level=DEBUG")
flag.BoolVar(&logFlags.removal, "log-removal", false, "Adds additional logging of removed island and lake position/size, sets log-level=DEBUG")
flag.Parse()
logger := slog.New(NewGeneratorLogger(
os.Stdout,
&slog.HandlerOptions{
Level: DetermineLogLevel(logFlags),
},
logFlags,
))
slog.SetDefault(logger)
if err := loadTerrainMaps(); err != nil {
log.Fatalf("Error generating terrain maps: %v", err)
}
+62 -34
View File
@@ -2,20 +2,25 @@ package main
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/png"
"log"
"math"
"github.com/chai2010/webp"
)
// The smallest a body of land or lake can be, all smaller are removed
const (
// The smallest a body of land or lake can be, all smaller are removed
minIslandSize = 30
minLakeSize = 200
// the recommended max area pixel size for input images
minRecommendedPixelSize = 2000000
maxRecommendedPixelSize = 3000000
// the recommended max number of land tiles in the output bin at full size
maxRecommendedLandTileCount = 3000000
)
// Holds raw RGBA image data for the thumbnail
@@ -94,7 +99,8 @@ type GeneratorArgs struct {
//
// Misc Notes
// - It normalizes map width/height to multiples of 4 for the mini map downscaling.
func GenerateMap(args GeneratorArgs) (MapResult, error) {
func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
logger := LoggerFromContext(ctx)
img, err := png.Decode(bytes.NewReader(args.ImageBuffer))
if err != nil {
return MapResult{}, fmt.Errorf("failed to decode PNG: %w", err)
@@ -107,7 +113,12 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
width = width - (width % 4)
height = height - (height % 4)
log.Printf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height)
logger.Info(fmt.Sprintf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height))
area := width * height
if area < minRecommendedPixelSize || area > maxRecommendedPixelSize {
logger.Debug(fmt.Sprintf("Map area %d pixels is outside recommended range (%d - %d)", area, minRecommendedPixelSize, maxRecommendedPixelSize), PerformanceLogTag)
}
// Initialize terrain grid
terrain := make([][]Terrain, width)
@@ -137,16 +148,16 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
}
}
removeSmallIslands(terrain, args.RemoveSmall)
processWater(terrain, args.RemoveSmall)
removeSmallIslands(ctx, terrain, args.RemoveSmall)
processWater(ctx, terrain, args.RemoveSmall)
terrain4x := createMiniMap(terrain)
processWater(terrain4x, false)
processWater(ctx, terrain4x, false)
terrain16x := createMiniMap(terrain4x)
processWater(terrain16x, false)
processWater(ctx, terrain16x, false)
thumb := createMapThumbnail(terrain4x, 0.5)
thumb := createMapThumbnail(ctx, terrain4x, 0.5)
webp, err := convertToWebP(ThumbData{
Data: thumb.Pix,
Width: thumb.Bounds().Dx(),
@@ -156,9 +167,20 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) {
return MapResult{}, fmt.Errorf("failed to save thumbnail: %w", err)
}
mapData, mapNumLandTiles := packTerrain(terrain)
mapData4x, numLandTiles4x := packTerrain(terrain4x)
mapData16x, numLandTiles16x := packTerrain(terrain16x)
mapData, mapNumLandTiles := packTerrain(ctx, terrain)
mapData4x, numLandTiles4x := packTerrain(ctx, terrain4x)
mapData16x, numLandTiles16x := packTerrain(ctx, terrain16x)
logger.Debug(fmt.Sprintf("Land Tile Count (1x): %d", mapNumLandTiles))
logger.Debug(fmt.Sprintf("Land Tile Count (4x): %d", numLandTiles4x))
logger.Debug(fmt.Sprintf("Land Tile Count (16x): %d", numLandTiles16x))
if mapNumLandTiles == 0 {
return MapResult{}, fmt.Errorf("Map has 0 land tiles")
}
if mapNumLandTiles > maxRecommendedLandTileCount {
logger.Debug(fmt.Sprintf("Map has more land tiles (%d) than recommended maximum (%d)", mapNumLandTiles, maxRecommendedLandTileCount), PerformanceLogTag)
}
return MapResult{
Map: MapInfo{
@@ -242,8 +264,9 @@ func createMiniMap(tm [][]Terrain) [][]Terrain {
// It marks Land tiles as shoreline if they neighbor Water, and Water tiles as
// shoreline if they neighbor Land.
// Returns a list of coordinates for all shoreline Water tiles found.
func processShore(terrain [][]Terrain) []Coord {
log.Println("Identifying shorelines")
func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
logger := LoggerFromContext(ctx)
logger.Info("Identifying shorelines")
var shorelineWaters []Coord
width := len(terrain)
height := len(terrain[0])
@@ -280,8 +303,9 @@ func processShore(terrain [][]Terrain) []Coord {
// processDistToLand calculates the distance of water tiles from the nearest land.
// It uses a Breadth-First Search (BFS) starting from the shoreline water tiles.
// The distance is stored in the Magnitude field of the Water tiles.
func processDistToLand(shorelineWaters []Coord, terrain [][]Terrain) {
log.Println("Setting Water tiles magnitude = Manhattan distance from nearest land")
func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][]Terrain) {
logger := LoggerFromContext(ctx)
logger.Info("Setting Water tiles magnitude = Manhattan distance from nearest land")
width := len(terrain)
height := len(terrain[0])
@@ -362,8 +386,9 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord {
// It finds all connected water bodies and marks the largest one as Ocean.
// If removeSmall is true, lakes smaller than minLakeSize are converted to Land.
// Finally, it triggers shoreline identification and distance-to-land calculations.
func processWater(terrain [][]Terrain, removeSmall bool) {
log.Println("Processing water bodies")
func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
logger := LoggerFromContext(ctx)
logger.Info("Processing water bodies")
visited := make(map[string]bool)
type waterBody struct {
@@ -408,13 +433,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
for _, coord := range largestWaterBody.coords {
terrain[coord.X][coord.Y].Ocean = true
}
log.Printf("Identified ocean with %d water tiles", largestWaterBody.size)
logger.Info(fmt.Sprintf("Identified ocean with %d water tiles", largestWaterBody.size))
if removeSmall {
// Remove small water bodies
log.Println("Searching for small water bodies for removal")
logger.Info("Searching for small water bodies for removal")
for w := 1; w < len(waterBodies); w++ {
if waterBodies[w].size < minLakeSize {
logger.Debug(fmt.Sprintf("Removing small lake at %d,%d (size %d)", waterBodies[w].coords[0].X, waterBodies[w].coords[0].Y, waterBodies[w].size), RemovalLogTag)
smallLakes++
for _, coord := range waterBodies[w].coords {
terrain[coord.X][coord.Y].Type = Land
@@ -422,15 +448,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) {
}
}
}
log.Printf("Identified and removed %d bodies of water smaller than %d tiles",
smallLakes, minLakeSize)
logger.Info(fmt.Sprintf("Identified and removed %d bodies of water smaller than %d tiles", smallLakes, minLakeSize))
}
// Process shorelines and distances
shorelineWaters := processShore(terrain)
processDistToLand(shorelineWaters, terrain)
shorelineWaters := processShore(ctx, terrain)
processDistToLand(ctx, shorelineWaters, terrain)
} else {
log.Println("No water bodies found in the map")
logger.Info("No water bodies found in the map")
}
}
@@ -465,7 +490,8 @@ func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord {
// removeSmallIslands identifies and removes small land masses from the terrain.
// If removeSmall is true, any removed bodies are converted to Water.
func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
func removeSmallIslands(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
logger := LoggerFromContext(ctx)
if !removeSmall {
return
}
@@ -501,6 +527,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
for _, body := range landBodies {
if body.size < minIslandSize {
logger.Debug(fmt.Sprintf("Removing small island at %d,%d (size %d)", body.coords[0].X, body.coords[0].Y, body.size), RemovalLogTag)
smallIslands++
for _, coord := range body.coords {
terrain[coord.X][coord.Y].Type = Water
@@ -509,8 +536,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
}
}
log.Printf("Identified and removed %d islands smaller than %d tiles",
smallIslands, minIslandSize)
logger.Info(fmt.Sprintf("Identified and removed %d islands smaller than %d tiles", smallIslands, minIslandSize))
}
// packTerrain serializes the terrain grid into a byte slice.
@@ -521,7 +547,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) {
// - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2).
//
// Returns the packed data and the count of land tiles.
func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) {
func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) {
width := len(terrain)
height := len(terrain[0])
packedData := make([]byte, width*height)
@@ -553,15 +579,16 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) {
}
}
logBinaryAsBits(packedData, 8)
logBinaryAsBits(ctx, packedData, 8)
return packedData, numLandTiles
}
// createMapThumbnail generates an RGBA image representation of the terrain.
// It scales the map dimensions based on the provided quality factor.
// Each pixel's color is determined by the terrain type and magnitude via getThumbnailColor.
func createMapThumbnail(terrain [][]Terrain, quality float64) *image.RGBA {
log.Println("Creating thumbnail")
func createMapThumbnail(ctx context.Context, terrain [][]Terrain, quality float64) *image.RGBA {
logger := LoggerFromContext(ctx)
logger.Info("Creating thumbnail")
srcWidth := len(terrain)
srcHeight := len(terrain[0])
@@ -664,7 +691,8 @@ func getThumbnailColor(t Terrain) RGBA {
// logBinaryAsBits logs the binary representation of the first 'length' bytes of data.
// It is a helper function for debugging packed terrain data.
func logBinaryAsBits(data []byte, length int) {
func logBinaryAsBits(ctx context.Context, data []byte, length int) {
logger := LoggerFromContext(ctx)
if length > len(data) {
length = len(data)
}
@@ -673,7 +701,7 @@ func logBinaryAsBits(data []byte, length int) {
for i := 0; i < length; i++ {
bits += fmt.Sprintf("%08b ", data[i])
}
log.Printf("Binary data (bits): %s", bits)
logger.Info(fmt.Sprintf("Binary data (bits): %s", bits))
}
// createCombinedBinary combines the info JSON, map data, and mini-map data into a single binary buffer.