---
name: solarus-test-map
description: Use whenever creating a new test map in tests/testing_quest, hand-editing a Solarus map .dat file, or registering a test map in the build system. Covers the full workflow: .dat format rules, .lua script patterns, project_db.dat registration, AddTestMaps.cmake registration, and how to run and verify. Prevents "Map 'x' differs from the original one after export" CI failures and missing-test pitfalls.
---

# Creating and registering a test map in Solarus

## 1. Where to put the map

New maps go under `tests/testing_quest/data/maps/`:

- **Bug regression tests** → `maps/bugs/<ticket_id>_<short_description>`
  (e.g. `bugs/1510_state_on_finished_game_over`, `bugs/1987_audio_api_invalid_context`).
  The ticket id is mandatory and must be the *real* tracker id. **If the user
  hasn't given you one, ask — never invent, guess, or omit it.**
  Multi-map repros nest further: `bugs/1015_crash_scrolling_to_invalid_layer/map_1`.

- **Feature tests** → `maps/features/<short_description>`.
  When a feature area has or is likely to gain more than one map, group under
  a thematic subdirectory: `features/audio/audio_api_tests` not
  `features/audio_api_tests`.

Each map needs two files: `<id>.dat` (map data) and `<id>.lua` (test script).

## 2. The `.dat` file

### Why it must be byte-for-byte canonical

`tests/src/tests/MapData.cpp` loads every map, re-exports it, and does a
byte-for-byte comparison. Any deviation — field order, blank lines, trailing
newline — fails CI even if the map runs fine.

### Field order (source of truth: `EntityData::export_to_lua` in `src/entities/EntityData.cpp`)

For every entity block, write fields in this exact order (omit a line when the condition is false):

1. `name = "...",` — only if named
2. `layer = ...,`
3. `x = ...,`
4. `y = ...,`
5. `enabled_at_start = false,` — only if NOT enabled at start
6. `locked = true,` — only if locked
7. `group = N,` — only if non-zero
8. `properties = { ... }` — only if user-defined properties exist
9. Entity-type-specific fields **in the order declared** in
   `entity_type_descriptions` in `EntityData.cpp`. Look up the type before
   hand-editing — never guess or copy from a different type.

### Blank lines

- Exactly **one** blank line between every two entity blocks.
- The file ends with a blank line after the last entity (last bytes: `}\n\n`).
  A missing final blank line is an easy, silent mistake.

### Minimal map template

Use this as a starting point — it matches the canonical exporter output exactly:

```
properties{
  x = 0,
  y = 0,
  width = 320,
  height = 240,
  min_layer = 0,
  max_layer = 2,
  tileset = "castle",
}

tile{
  layer = 0,
  x = 0,
  y = 0,
  width = 320,
  height = 240,
  pattern = "3",
}

destination{
  name = "destination",
  layer = 0,
  x = 24,
  y = 29,
  direction = 1,
}

```

(The trailing blank line after the last `}` is intentional and required.)

Swap `tileset` for another value if the test needs specific ground types
(`"overworld"` for outdoor terrain, etc.).

### Avoiding hand-edit mistakes

- **Prefer the Quest Editor** for adding/moving entities — it always saves in
  canonical form.
- When hand-editing, copy the full field order from another entity of the
  **same type** already in the file, never assume order from a different type.
- After editing, check the file tail for the trailing blank line.

## 3. The `.lua` test script

### Structure

```lua
local map = ...
local game = map:get_game()
local hero = map:get_hero()   -- only if needed

function map:on_opening_transition_finished()
  -- test body here
  -- end with sol.main.exit() (or let an assertion error fail the test)
end
```

`on_opening_transition_finished` is the standard entry point — the hero is
spawned, the map is fully running, and no transition animation is in progress.

### Assertions

The `main.lua` script in the quest provides these helpers (available globally):

```lua
assert(condition, "message")          -- standard Lua assert
assert_equal(actual, expected)        -- prints both values on failure
assert_equal_approx(actual, expected) -- for floats
assert_error({function() ... end})    -- expects the function to raise an error
```

Any uncaught `error()` fails the test with a non-zero exit code.

### Async / deferred checks

```lua
-- Wait one engine frame (100ms simulation time) before continuing:
later(function()
  -- code here
end)

-- Wait a specific duration:
sol.timer.start(map, 500, function()
  assert_equal(hero:get_state(), "free")
  sol.main.exit()
end)
```

`later()` is defined in `main.lua` and schedules a 100ms timer that is not
suspended with the map.

### Ending the test

- **Pass**: call `sol.main.exit()` when all assertions have passed.
- **Fail**: let an `assert` / `error()` propagate — the engine exits with a
  non-zero code and the test runner marks it as failed.
- Do **not** leave the test running forever — always have a code path that
  reaches `sol.main.exit()` or an assertion failure.

### Checking callbacks that fire during shutdown

Some callbacks (e.g. `state:on_finished()`, `game:on_finished()`) only fire
during teardown. The pattern:

```lua
local flag = false

function state:on_finished()
  flag = true
end

function game:on_finished()
  assert(flag, "callback was not called")
  -- no sol.main.exit() needed here — game is already ending
end

sol.timer.start(map, 100, function()
  sol.main.exit()   -- triggers the full shutdown sequence
end)
```

`game:on_finished()` fires after `state:on_finished()` during `Game::stop()`,
so it is the right place to assert flags set by state/entity teardown callbacks.

## 4. Registering the map

A map that is not registered in both files below will never run in CI.

### `tests/testing_quest/data/project_db.dat`

Add two chunks in alphabetical order by map id:

**In the `map{...}` section** (sorted by id):
```
map{ id = "bugs/1510_state_on_finished_game_over", description = "#1510: state:on_finished() not called when the game finishes" }
```

**In the `file{...}` section** (sorted by path):
```
file{ path = "maps/bugs/1510_state_on_finished_game_over.dat", author = "YourName", license = "CC BY-SA 4.0" }
file{ path = "maps/bugs/1510_state_on_finished_game_over.lua", author = "YourName", license = "GPL v3" }
```

Use `sed` to insert at the right alphabetical position, then verify the
surrounding lines to confirm placement.

### `tests/cmake/AddTestMaps.cmake`

Add the map id to the appropriate list (sorted alphabetically within the list):

- `LUA_TEST_MAPS` — standard test; engine exits with non-zero code on any error
- `LUA_TEST_MAPS_NON_FATAL` — test is expected to produce errors (use `-fatal-errors=no`)
- `LUA_TEST_MAPS_REQUIRE_WINDOW` — test needs a real window (no `-no-video`)

```cmake
  "bugs/1510_state_on_finished_game_over"
```

## 5. Verifying

### Round-trip `.dat` format test

Always run this after creating or editing a `.dat` file:

```bash
./build/tests/bin/solarus-test-map-data -no-audio -no-video -turbo=yes tests/testing_quest
```

Stops at the first mismatch and prints both the original and re-exported text.

**Windows-only caveat:** `.dat` files are checked out with CRLF locally, but
the exporter writes LF — this causes spurious failures on the very first map.
Test against an LF-normalized copy instead:

```bash
rm -rf /tmp/lf_quest && cp -r tests/testing_quest /tmp/lf_quest
find /tmp/lf_quest -type f \( -name "*.dat" -o -name "*.lua" \) -exec sed -i 's/\r$//' {} \;
./build/tests/bin/solarus-test-map-data -no-audio -no-video -turbo=yes /tmp/lf_quest
```

### Running a single test map

```bash
./build/tests/bin/solarus-test-lua-map -no-audio -no-video -turbo=yes \
  "-map=bugs/1510_state_on_finished_game_over" tests/testing_quest
```

A clean run prints `Simulation finished` and exits 0. An assertion failure
prints the error and exits non-zero.

### Diagnosing a persistent `.dat` mismatch

If CI still reports a diff after you believe you have matched the order,
extract the `*** Original map file:` and `*** Exported map file:` sections
from the job log verbatim and `diff` them directly — do not eyeball. The log's
`std::cerr` dump appends one extra blank line after each section for
formatting; what matters is whether *both* sections have the *same* number of
extra blank lines relative to each other.
