phantasia

Phantasia - 2D SDL3 RPG prototype.
git clone git://git.beep.wimdupont.com/phantasia.git
Log | Files | Refs | README | LICENSE

commit 8c44e7b0b3f77d80ec5e4c5553736f9c53c69dab
parent 725cd8c35c7be914c4a27db62336bfd915a00749
Author: beep <beep@wimdupont.com>
Date:   Tue, 27 Oct 2026 11:47:30 +0000

Add content preflight validation and optional debug overlay

Diffstat:
MMakefile | 17++++++++++++-----
MREADME.adoc | 38++++++++++++++++++++++++++++++++++++--
MTODO.adoc | 4----
Mconfig.def.h | 8+++++++-
Mphantasia.6 | 3+++
Msrc/engine/render.c | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/engine/render.h | 3+++
Asrc/engine/validate.c | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/engine/validate.h | 12++++++++++++
Msrc/engine/world.c | 7+++++--
Msrc/engine/world.h | 4+++-
Msrc/game/content.h | 1+
Msrc/game/main.c | 29++++++++++++++++++++++++++---
Msrc/game/presentation.c | 25++++++++++++++++++++++++-
Msrc/game/presentation.h | 12+++++++++++-
Asrc/game/validate.c | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/render.c | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/tests/smoke.c | 30+++++++++++++++++++++++++++++-
Asrc/tests/validate.c | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 821 insertions(+), 47 deletions(-)

diff --git a/Makefile b/Makefile @@ -1,6 +1,7 @@ BIN = bin/phantasia SMOKE = bin/ph-smoke GAME_SMOKE = bin/ph-game-smoke +CONTENT_CHECK = bin/ph-content-check ENGINE_LIB = bin/libph-engine.a EXAMPLE = bin/ph-courier RENDER_SMOKE = bin/ph-render-smoke @@ -32,8 +33,8 @@ AUDIO = \ assets/vendor/audio/rpg-sound-pack/trade.wav ENGINE_SRC = src/engine/world.c src/engine/area.c src/engine/battle.c src/engine/save.c \ - src/engine/audio.c src/engine/render.c src/engine/clock.c -GAME_SRC = src/game/content.c src/game/game.c + src/engine/audio.c src/engine/render.c src/engine/clock.c src/engine/validate.c +GAME_SRC = src/game/content.c src/game/game.c src/game/validate.c SRC = $(GAME_SRC) src/game/main.c MAPC_SRC = src/tools/mapc.c src/game/content.c CONFIG = config.h @@ -44,7 +45,7 @@ HEADLESS_GAME_OBJ = $(GAME_SRC:src/%.c=$(OBJDIR)/headless/%.o) RENDER_OBJ = $(filter-out $(OBJDIR)/game/main.o,$(OBJ)) $(OBJDIR)/tests/render.o DEP = $(ENGINE_OBJ:.o=.d) $(OBJ:.o=.d) $(HEADLESS_GAME_OBJ:.o=.d) \ $(OBJDIR)/tests/render.d $(OBJDIR)/headless/tests/smoke.d \ - $(OBJDIR)/headless/tests/game.d $(OBJDIR)/headless/examples/courier.d \ + $(OBJDIR)/headless/tests/game.d $(OBJDIR)/headless/tests/validate.d $(OBJDIR)/headless/examples/courier.d \ $(OBJDIR)/headless/game/presentation.d SDL3_CFLAGS = $(shell pkg-config --cflags sdl3 2>/dev/null) @@ -63,7 +64,7 @@ else CPPFLAGS += -DPH_USE_SDL=0 endif -.PHONY: all clean render-smoke smoke game-smoke engine-example check +.PHONY: all clean render-smoke smoke game-smoke engine-example content-check check all: $(BIN) $(MAPBIN) $(TILESET_BMP) $(HERO_BMP) $(ITEMS_BMP) $(CRITTERS_BMP) @@ -75,6 +76,9 @@ $(SMOKE): $(HEADLESS_GAME_OBJ) $(OBJDIR)/headless/tests/smoke.o $(ENGINE_LIB) @mkdir -p $(dir $@) $(CC) $^ -o $@ -lm +$(CONTENT_CHECK): $(HEADLESS_GAME_OBJ) $(OBJDIR)/headless/tests/validate.o $(ENGINE_LIB) + $(CC) $^ -o $@ -lm + $(ENGINE_LIB): $(ENGINE_OBJ) @mkdir -p $(dir $@) $(AR) rcs $@ $^ @@ -138,7 +142,10 @@ game-smoke: $(MAPBIN) $(GAME_SMOKE) engine-example: $(EXAMPLE) $(EXAMPLE) --smoke -check: smoke game-smoke engine-example render-smoke $(OBJDIR)/headless/game/presentation.o +content-check: $(MAPBIN) $(CONTENT_CHECK) + $(CONTENT_CHECK) + +check: content-check smoke game-smoke engine-example render-smoke $(OBJDIR)/headless/game/presentation.o render-smoke: $(RENDER_SMOKE) SDL_AUDIODRIVER=dummy SDL_VIDEODRIVER=dummy $(RENDER_SMOKE) diff --git a/README.adoc b/README.adoc @@ -92,6 +92,15 @@ Maps own placement, while content assigns semantics to their markers. Adding an instance should normally change only a map; adding content should change the catalog and map rather than engine code. +Before loading a save or starting a game, a headless preflight validates catalogs +and every configured map. It checks IDs, starting equipment, rewards, spells, +shops and interactions, tile indices, marker placements, spawn limits, and portal +arrivals. Diagnostics identify the offending catalog entry or area, with map +coordinates where applicable (zero-based). Maps are loaded one at a time and +released after inspection; validation does not populate world state. Asset +references are checked against the catalogs; media decoding remains the backend's +responsibility. + Vendored media is grouped by role under `assets/vendor/graphics` and `assets/vendor/audio`. Each upstream asset directory keeps its own source and license record. @@ -117,8 +126,16 @@ Exercise SDL rendering in a headless environment: make render-smoke ---- -Run all checks, including headless menu/battle flow, the independent example, -SDL-free presentation compilation, and media pixel/lifecycle checks: +Validate content and exercise invalid-data regression fixtures without SDL: + +[source,sh] +---- +make content-check +---- + +Run all checks, including content validation, headless menu/battle flow, the +independent example, SDL-free presentation compilation, and media pixel/lifecycle +checks: [source,sh] ---- @@ -153,6 +170,23 @@ persisted. Starting combat creates a stable pre-combat checkpoint. Exiting during combat does not overwrite it with a partial battle; victory saves the finalized result. +== Debugging + +Press F3 (`PH_KEY_DEBUG`) to toggle the debug overlay in any mode. In the world, +red outlines mark blocking tiles, green circles show actor collision radii, and +blue circles show reserved movement destinations. Blue squares label non-player +movement targets with their active entity index. These use simulation positions +and the same camera as world drawing; collision uses center distance, not sprite +bounds. Colors and the toggle key are configured in `config.h`. + +The header shows the previous completed frame's duration (including frame pacing), +update and draw times in milliseconds, and executed simulation ticks. Update time +includes tick work, audio, and autosaves; draw time includes presentation. It also +shows current world-event queue occupancy and a cumulative count of events lost +to a full queue. The loss counter survives queue draining and area travel, resets +when a world starts or loads, and is not saved. The overlay starts disabled and +never advances simulation or consumes events. + == Maps Sources live in `data/maps/*.txt`. `make` compiles them with `bin/ph-mapc` into diff --git a/TODO.adoc b/TODO.adoc @@ -8,10 +8,6 @@ that improve the game or demonstrate reuse over speculative framework features. The shared engine library, independent Parcel Run example, SDL-independent game flow and media interfaces, and fixed simulation ticks provide the foundation. -* Validate content catalogs and map references before play. Report broken IDs, - destinations, and definitions early, with enough context to fix the data. -* Add a small debug overlay for collision bounds, AI destinations, frame timings, - and event overflow. Make gameplay and performance problems easier to diagnose. * Record and replay input per simulation tick with a known random seed. Turn reproducible gameplay bugs into regression tests; do not assume cross-platform floating-point determinism. diff --git a/config.def.h b/config.def.h @@ -1,7 +1,7 @@ #ifndef CONFIG_H #define CONFIG_H -#define PH_CONFIG_VERSION 16 +#define PH_CONFIG_VERSION 17 #define PH_VIEW_W 320 #define PH_VIEW_H 240 @@ -49,10 +49,16 @@ #define PH_MENU_TEXT_COLOR 232, 236, 244, 255 #define PH_MENU_BONUS_COLOR 126, 220, 146, 255 #define PH_MENU_DISABLED_COLOR 104, 110, 124, 255 +#define PH_DEBUG_BLOCK_COLOR 240, 92, 92, 255 +#define PH_DEBUG_ACTOR_COLOR 100, 240, 140, 255 +#define PH_DEBUG_TARGET_COLOR 100, 200, 255, 255 +#define PH_DEBUG_BG_COLOR 12, 16, 24, 255 +#define PH_DEBUG_TEXT_COLOR 240, 240, 240, 255 #define PH_BATTLE_BG_COLOR 44, 52, 68, 255 #define PH_BATTLE_GROUND_COLOR 72, 84, 82, 255 #if PH_USE_SDL +#define PH_KEY_DEBUG SDL_SCANCODE_F3 #define PH_KEY_LEFT_1 SDL_SCANCODE_LEFT #define PH_KEY_LEFT_2 SDL_SCANCODE_A #define PH_KEY_LEFT_3 SDL_SCANCODE_H diff --git a/phantasia.6 b/phantasia.6 @@ -29,6 +29,9 @@ Open or close the character sheet. Open or close the inventory. .It O Open or close the magick book. +.It F3 +Toggle debug collision bounds, movement targets, frame timings, and event +overflow counts. .It Arrow keys / HJKL Move through inventory items, item and spell actions, battle commands, learned spells, and combat targets. diff --git a/src/engine/render.c b/src/engine/render.c @@ -1,6 +1,7 @@ #include "engine/render.h" #include <math.h> +#include <stdio.h> #include "config.h" @@ -236,3 +237,66 @@ ph_render_world(PhRenderer *renderer, ph_draw_items(renderer, world, camera); ph_draw_entities(renderer, world, camera); } + +static void +ph_debug_radius(PhRenderer *renderer, PhVec2 pos, const PhCamera *camera) +{ + float x = ph_world_pixel(pos.x, camera->pos.x); + float y = ph_world_pixel(pos.y, camera->pos.y); + int radius = PH_ENTITY_BLOCK_RADIUS; + int offset; + + if (x + radius < 0 || y + radius < 0 || + x - radius >= camera->viewport_w || y - radius >= camera->viewport_h) return; + /* The collision test uses distance between centers, not sprite rectangles. */ + for (offset = -radius; offset <= radius; ++offset) { + float edge = floorf(sqrtf((float)(radius * radius - offset * offset))); + + ph_render_fill(renderer, (PhRect){ x - edge, y + offset, 1, 1 }); + ph_render_fill(renderer, (PhRect){ x + edge, y + offset, 1, 1 }); + ph_render_fill(renderer, (PhRect){ x + offset, y - edge, 1, 1 }); + ph_render_fill(renderer, (PhRect){ x + offset, y + edge, 1, 1 }); + } +} + +void +ph_render_world_debug(PhRenderer *renderer, const PhWorld *world, + const PhCamera *camera) +{ + int x, y, i; + + ph_render_color(renderer, (PhColor){ PH_DEBUG_BLOCK_COLOR }); + for (y = (int)(camera->pos.y / PH_TILE_SIZE); y < world->area.height && + y * PH_TILE_SIZE < camera->pos.y + camera->viewport_h; ++y) + for (x = (int)(camera->pos.x / PH_TILE_SIZE); x < world->area.width && + x * PH_TILE_SIZE < camera->pos.x + camera->viewport_w; ++x) + if (ph_area_tile_blocked(&world->area, x, y)) + ph_render_outline(renderer, (PhRect){ + ph_world_pixel((float)(x * PH_TILE_SIZE), camera->pos.x), + ph_world_pixel((float)(y * PH_TILE_SIZE), camera->pos.y), + PH_TILE_SIZE, PH_TILE_SIZE }); + for (i = 0; i < world->entity_count; ++i) { + const PhEntity *entity = &world->entities[i]; + const PhEntityDef *def = ph_world_entity_def(world, entity->type_id); + int blocks; + + if (!entity->active || entity->area_id != world->area_id || !def) continue; + blocks = def->blocks_movement || i == world->player_index; + if (blocks) { + ph_render_color(renderer, (PhColor){ PH_DEBUG_ACTOR_COLOR }); + ph_debug_radius(renderer, entity->pos, camera); + } + ph_render_color(renderer, (PhColor){ PH_DEBUG_TARGET_COLOR }); + if (blocks && entity->motion.moving) ph_debug_radius(renderer, entity->motion.target, camera); + if (i != world->player_index) { + float tx = ph_world_pixel(entity->motion.target.x, camera->pos.x); + float ty = ph_world_pixel(entity->motion.target.y, camera->pos.y); + char label[16]; + + if (tx < 0 || ty < 0 || tx >= camera->viewport_w || ty >= camera->viewport_h) continue; + ph_render_outline(renderer, (PhRect){ tx - 2, ty - 2, 5, 5 }); + snprintf(label, sizeof(label), "%d", i); + ph_render_text(renderer, tx + 4, ty - 4, label); + } + } +} diff --git a/src/engine/render.h b/src/engine/render.h @@ -39,4 +39,7 @@ void ph_camera_follow_player(PhCamera *camera, const PhWorld *world); void ph_render_world(PhRenderer *renderer, const PhWorld *world, const PhCamera *camera); +void ph_render_world_debug(PhRenderer *renderer, const PhWorld *world, + const PhCamera *camera); + #endif diff --git a/src/engine/validate.c b/src/engine/validate.c @@ -0,0 +1,238 @@ +#include "engine/validate.h" + +#include <stdarg.h> +#include <stdio.h> + +static int +ph_invalid(char *error, size_t size, const char *format, ...) +{ + va_list args; + + va_start(args, format); + if (error && size) vsnprintf(error, size, format, args); + va_end(args); + return -1; +} + +#define FAIL(...) return ph_invalid(error, size, __VA_ARGS__) + +static int +ph_tile_symbol(const PhAreaCatalog *catalog, unsigned char symbol) +{ + int i; + + for (i = 0; i < catalog->tile_count; ++i) + if (catalog->tiles[i].symbol == symbol) return i; + return -1; +} + +static int +ph_validate_map(const PhWorldSetup *setup, const PhAreaDef *def, + char *error, size_t size) +{ + const PhAreaCatalog *catalog = setup->areas; + PhArea area; + int players = 0, entities = 0, items = 0; + int i, j, result = -1; + size_t tile; + + if (ph_area_catalog_load(&area, catalog, def->id) < 0) + FAIL("area %d (%s): cannot load map", def->id, def->path); + for (tile = 0; tile < (size_t)area.width * area.height; ++tile) { + if (area.tiles[tile] < catalog->tile_count) continue; + ph_invalid(error, size, "area %d (%s), tile (%zu,%zu): invalid tile index %u", + def->id, def->path, tile % area.width, tile / area.width, area.tiles[tile]); + goto done; + } + for (i = 0; i < area.marker_count; ++i) { + const PhAreaMarker *marker = &area.markers[i]; + const PhMarkerDef *entry = ph_area_catalog_marker(catalog, def->id, marker->symbol); + const char *reason = NULL; + + if (!entry) reason = "undefined marker"; + else if (area.tiles[(size_t)marker->tile_y * area.width + marker->tile_x] != + ph_tile_symbol(catalog, entry->tile_symbol)) reason = "terrain differs from definition"; + else if (ph_area_tile_blocked(&area, marker->tile_x, marker->tile_y)) + reason = "marker is on blocked terrain"; + for (j = 0; j < i && !reason; ++j) { + if (area.markers[j].tile_x == marker->tile_x && + area.markers[j].tile_y == marker->tile_y) reason = "overlapping markers"; + if (entry->kind == PH_MARKER_ARRIVAL && area.markers[j].symbol == marker->symbol) + reason = "ambiguous arrival marker"; + } + if (reason) { + ph_invalid(error, size, "area %d (%s), marker '%c' at (%d,%d): %s", + def->id, def->path, marker->symbol, marker->tile_x, marker->tile_y, reason); + goto done; + } + players += entry->kind == PH_MARKER_PLAYER; + entities += entry->kind == PH_MARKER_ENTITY; + items += entry->kind == PH_MARKER_ITEM; + } + if (players != (def->id == setup->start_area_id) || + entities + 1 > PH_MAX_ENTITIES || items > PH_MAX_GROUND_ITEMS) { + ph_invalid(error, size, "area %d (%s): invalid spawn counts (players=%d actors=%d items=%d)", + def->id, def->path, players, entities, items); + goto done; + } + /* Check incoming portals against actual placements, not just definitions. */ + for (i = 0; i < catalog->marker_count; ++i) { + const PhMarkerDef *portal = &catalog->markers[i]; + int found = 0; + + if (portal->kind != PH_MARKER_PORTAL || portal->destination_area_id != def->id) continue; + for (j = 0; j < area.marker_count; ++j) + found += area.markers[j].symbol == portal->destination_symbol; + if (found == 1) continue; + ph_invalid(error, size, "area %d portal '%c': destination area %d (%s) needs one arrival '%c' (found %d)", + portal->area_id, portal->symbol, def->id, def->path, portal->destination_symbol, found); + goto done; + } + result = 0; +done: + ph_area_free(&area); + return result; +} + +int +ph_validate_world(const PhWorldSetup *setup, char *error, size_t size) +{ + const PhAreaCatalog *catalog; + PhWorld lookup = { 0 }; + int i, j; + + if (error && size) *error = '\0'; + if (!setup || !setup->areas) FAIL("world: missing setup or area catalog"); + catalog = setup->areas; + lookup.content = setup->content; + if (setup->content.entity_count < 1 || setup->content.entity_count > PH_MAX_ENTITY_TYPES || + !setup->content.entities || setup->content.item_count < 0 || + setup->content.item_count > PH_MAX_ITEM_TYPES || + (setup->content.item_count && !setup->content.items) || + setup->equipment_count < 0 || (setup->equipment_count && !setup->equipment)) + FAIL("world: invalid content counts or missing arrays"); + if (catalog->area_count < 1 || !catalog->areas || catalog->tile_count < 1 || + catalog->tile_count > 256 || !catalog->tiles || catalog->marker_count < 0 || + (catalog->marker_count && !catalog->markers)) + FAIL("world: invalid area catalog counts or missing arrays"); + for (i = 0; i < setup->content.item_count; ++i) { + const PhItemDef *item = &setup->content.items[i]; + + if (item->id <= 0 || !item->name || !item->description || + item->equip_slot < PH_EQUIP_NONE || item->equip_slot >= PH_EQUIP_COUNT || + item->use < PH_ITEM_USE_NONE || item->use > PH_ITEM_USE_RESTORE_MP || + item->buy_value < 0 || item->sell_value < 0 || item->use_power < 0) + FAIL("item %d: invalid definition", item->id); + for (j = 0; j < i; ++j) + if (setup->content.items[j].id == item->id) FAIL("item %d: duplicate ID", item->id); + } + for (i = 0; i < setup->content.entity_count; ++i) { + const PhEntityDef *entity = &setup->content.entities[i]; + + if (entity->id <= 0 || !entity->name || entity->kind < PH_ENTITY_PLAYER || + entity->kind > PH_ENTITY_NPC || entity->stats.max_hp < 0 || + (entity->kind != PH_ENTITY_NPC && entity->stats.max_hp == 0) || + entity->stats.max_mp < 0 || entity->move_speed < 0 || entity->vision < 0 || + entity->animation_frames < 0 || entity->animation_frames > PH_ANIMATION_FRAMES || + entity->starting_gold < 0 || entity->reward.xp < 0 || entity->reward.amount < 0) + FAIL("entity %d: invalid definition", entity->id); + for (j = 0; j < i; ++j) + if (setup->content.entities[j].id == entity->id) FAIL("entity %d: duplicate ID", entity->id); + if ((entity->reward.item_id && (!ph_world_item_def(&lookup, entity->reward.item_id) || + entity->reward.amount <= 0)) || (!entity->reward.item_id && entity->reward.amount)) + FAIL("entity %d: invalid reward item %d or amount", entity->id, entity->reward.item_id); + } + for (i = 0; i < setup->equipment_count; ++i) { + const PhItemDef *item; + + if (!setup->equipment[i]) continue; + item = ph_world_item_def(&lookup, setup->equipment[i]); + if (!item || item->equip_slot == PH_EQUIP_NONE) + FAIL("starting equipment entry %d: invalid item %d", i, setup->equipment[i]); + for (j = 0; j < i; ++j) { + const PhItemDef *other = ph_world_item_def(&lookup, setup->equipment[j]); + if (other && other->equip_slot == item->equip_slot) + FAIL("starting equipment entry %d: duplicate slot %d", i, item->equip_slot); + } + } + for (i = 0; i < catalog->tile_count; ++i) { + const PhTileDef *tile = &catalog->tiles[i]; + + if (!tile->symbol || tile->autotile < PH_TILE_AUTOTILE_NONE || + tile->autotile > PH_TILE_AUTOTILE_FIXED || + (tile->autotile == PH_TILE_AUTOTILE_FIXED && + (tile->autotile_width <= 0 || tile->autotile_height <= 0))) + FAIL("tile '%c' (index %d): invalid definition", tile->symbol, i); + for (j = 0; j < i; ++j) + if (catalog->tiles[j].symbol == tile->symbol) FAIL("tile '%c': duplicate symbol", tile->symbol); + } + for (i = 0; i < catalog->area_count; ++i) { + const PhAreaDef *area = &catalog->areas[i]; + + if (area->id <= 0 || !area->path || !*area->path) FAIL("area %d: invalid definition", area->id); + for (j = 0; j < i; ++j) + if (catalog->areas[j].id == area->id) FAIL("area %d: duplicate ID", area->id); + } + if (!ph_area_catalog_area(catalog, setup->start_area_id)) + FAIL("world: unknown start area %d", setup->start_area_id); + for (i = 0; i < catalog->marker_count; ++i) { + const PhMarkerDef *marker = &catalog->markers[i]; + const PhEntityDef *entity; + const PhMarkerDef *arrival; + + if (!ph_area_catalog_area(catalog, marker->area_id) || !marker->symbol || + ph_tile_symbol(catalog, marker->tile_symbol) < 0 || marker->territory_radius < 0 || + marker->kind < PH_MARKER_ENTITY || marker->kind > PH_MARKER_ARRIVAL) + FAIL("area %d marker '%c': invalid definition", marker->area_id, marker->symbol); + for (j = 0; j < i; ++j) + if (catalog->markers[j].area_id == marker->area_id && catalog->markers[j].symbol == marker->symbol) + FAIL("area %d marker '%c': duplicate definition", marker->area_id, marker->symbol); + if (marker->kind == PH_MARKER_ENTITY || marker->kind == PH_MARKER_PLAYER) { + entity = ph_world_entity_def(&lookup, marker->content_id); + if (!entity || (marker->kind == PH_MARKER_PLAYER && entity->kind != PH_ENTITY_PLAYER)) + FAIL("area %d marker '%c': invalid entity %d", marker->area_id, marker->symbol, marker->content_id); + } else if (marker->kind == PH_MARKER_ITEM) { + if (!ph_world_item_def(&lookup, marker->content_id) || marker->amount <= 0) + FAIL("area %d marker '%c': invalid item %d or amount", marker->area_id, marker->symbol, marker->content_id); + } else if (marker->kind == PH_MARKER_PORTAL) { + arrival = ph_area_catalog_marker(catalog, marker->destination_area_id, marker->destination_symbol); + if (!arrival || arrival->kind != PH_MARKER_ARRIVAL) + FAIL("area %d portal '%c': invalid destination %d marker '%c'", marker->area_id, + marker->symbol, marker->destination_area_id, marker->destination_symbol); + } + } + for (i = 0; i < catalog->area_count; ++i) + if (ph_validate_map(setup, &catalog->areas[i], error, size) < 0) return -1; + return 0; +} + +int +ph_validate_spells(const PhSpellBook *spells, char *error, size_t size) +{ + int i, j; + + if (error && size) *error = '\0'; + if (!spells || spells->def_count < 0 || spells->def_count > PH_SPELL_MAX || + (spells->def_count && !spells->defs) || spells->learned_count < 0 || + spells->learned_count > PH_SPELL_MAX) FAIL("spellbook: invalid counts or missing definitions"); + for (i = 0; i < spells->def_count; ++i) { + const PhSpellDef *spell = &spells->defs[i]; + + if (spell->id <= 0 || !spell->name || !spell->description || spell->mp_cost < 0 || + spell->power < 0 || spell->buy_value < 0 || spell->delay_percent <= 0 || + spell->battle_use < PH_SPELL_BATTLE_NONE || spell->battle_use > PH_SPELL_BATTLE_HEAL || + spell->field_use < PH_SPELL_FIELD_NONE || spell->field_use > PH_SPELL_FIELD_HEAL || + spell->target < PH_SPELL_TARGET_NONE || spell->target > PH_SPELL_TARGET_ALLY || + (spell->battle_use != PH_SPELL_BATTLE_NONE && spell->target == PH_SPELL_TARGET_NONE) || + spell->accuracy.value < 0 || spell->accuracy.value > 100 || spell->accuracy.turns < 0) + FAIL("spell %d: invalid definition", spell->id); + for (j = 0; j < i; ++j) + if (spells->defs[j].id == spell->id) FAIL("spell %d: duplicate ID", spell->id); + } + for (i = 0; i < spells->learned_count; ++i) { + if (!ph_spellbook_def(spells, spells->learned[i])) FAIL("spellbook: unknown learned spell %d", spells->learned[i]); + for (j = 0; j < i; ++j) + if (spells->learned[j] == spells->learned[i]) FAIL("spellbook: duplicate learned spell %d", spells->learned[i]); + } + return 0; +} diff --git a/src/engine/validate.h b/src/engine/validate.h @@ -0,0 +1,12 @@ +#ifndef PH_ENGINE_VALIDATE_H +#define PH_ENGINE_VALIDATE_H + +#include "engine/area.h" +#include "engine/battle.h" + +/* Read-only preflight. Returns 0 or -1, with the first error in the optional + * caller buffer. World validation loads and releases every configured map. */ +int ph_validate_world(const PhWorldSetup *setup, char *error, size_t size); +int ph_validate_spells(const PhSpellBook *spells, char *error, size_t size); + +#endif diff --git a/src/engine/world.c b/src/engine/world.c @@ -100,8 +100,11 @@ ph_world_emit_event(PhWorld *world, PhWorldEventKind kind, PhVec2 pos, { int tail; - if (!world || kind <= PH_WORLD_EVENT_NONE || - world->event_count >= PH_MAX_WORLD_EVENTS) return -1; + if (!world || kind <= PH_WORLD_EVENT_NONE) return -1; + if (world->event_count >= PH_MAX_WORLD_EVENTS) { + if (world->event_dropped < UINT_MAX) ++world->event_dropped; + return -1; + } tail = (world->event_head + world->event_count) % PH_MAX_WORLD_EVENTS; world->events[tail] = (PhWorldEvent){ kind, pos, entity_index }; ++world->event_count; diff --git a/src/engine/world.h b/src/engine/world.h @@ -3,7 +3,7 @@ #include <stddef.h> -#define PH_CONFIG_API_VERSION 16 +#define PH_CONFIG_API_VERSION 17 enum { PH_MAP_MAGIC = 0x50484d50, @@ -248,6 +248,8 @@ typedef struct { PhWorldEvent events[PH_MAX_WORLD_EVENTS]; int event_head; int event_count; + /* Diagnostic only: rejected events since initialization; not persisted. */ + unsigned int event_dropped; } PhWorld; typedef struct { diff --git a/src/game/content.h b/src/game/content.h @@ -126,6 +126,7 @@ extern const PhWorldSetup ph_game_world; extern const PhShopDef ph_shop_defs[PH_SHOP_COUNT]; extern const PhInteractionDef ph_interaction_defs[PH_INTERACTION_COUNT]; +int ph_game_validate_content(char *error, size_t size); const PhShopDef *ph_game_shop_def(int shop_id); const PhInteractionDef *ph_game_interaction_def(int interaction_id); int ph_game_area_music(int area_id); diff --git a/src/game/main.c b/src/game/main.c @@ -154,6 +154,7 @@ ph_run_game(void) PhBattle battle = { 0 }; PhSpellBook spells = ph_player_spellbook; PhAudio *audio = NULL; + PhDebugView debug = { 0 }; PhGameContext context = { .world = &world, .inventory = &inventory_ui, @@ -170,6 +171,12 @@ ph_run_game(void) int running = 1; int interact_pending = 0; int save_status; + char content_error[256]; + + if (ph_game_validate_content(content_error, sizeof(content_error)) < 0) { + fprintf(stderr, "content: %s\n", content_error); + return 1; + } if (!SDL_Init(SDL_INIT_VIDEO)) { fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); @@ -210,7 +217,9 @@ ph_run_game(void) last_ticks = SDL_GetTicksNS(); while (running) { SDL_Event event; - Uint64 now_ticks; + Uint64 now_ticks, update_start, draw_start, draw_end; + Uint64 frame_start = SDL_GetTicksNS(); + int executed = 0; int steps, step; PhInput input; int effect = PH_AUDIO_NONE; @@ -219,7 +228,13 @@ ph_run_game(void) if (event.type == SDL_EVENT_QUIT) { running = 0; } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat) { - int request = ph_game_input(&context, ph_sdl_action(event.key.scancode)); + int request; + + if (event.key.scancode == PH_KEY_DEBUG) { + debug.enabled = !debug.enabled; + continue; + } + request = ph_game_input(&context, ph_sdl_action(event.key.scancode)); if (context.menu == PH_MENU_NONE && game == PH_GAME_WORLD && (event.key.scancode == PH_KEY_INTERACT_1 || @@ -246,10 +261,12 @@ ph_run_game(void) steps = ph_game_steps(&context, now_ticks - last_ticks); last_ticks = now_ticks; input = ph_read_input(SDL_GetKeyboardState(NULL)); + update_start = SDL_GetTicksNS(); ph_game_audio(&context, audio, effect); for (step = 0; step < steps; ++step) { PhInput tick_input = input; + ++executed; tick_input.interact |= interact_pending; interact_pending = 0; if (ph_game_update(&context, tick_input, 1.0f / PH_SIMULATION_HZ, PH_SAVE_PATH) && @@ -264,8 +281,14 @@ ph_run_game(void) } } - ph_present_frame(renderer, &context, &labels); + draw_start = SDL_GetTicksNS(); + ph_present_frame(renderer, &context, &labels, &debug); + draw_end = SDL_GetTicksNS(); SDL_Delay(PH_FRAME_DELAY_MS); + debug.frame_ms = (SDL_GetTicksNS() - frame_start) / 1000000.0; + debug.update_ms = (draw_start - update_start) / 1000000.0; + debug.draw_ms = (draw_end - draw_start) / 1000000.0; + debug.steps = executed; } if (audio) ph_audio_destroy(audio); diff --git a/src/game/presentation.c b/src/game/presentation.c @@ -706,9 +706,28 @@ ph_draw_battle_end(PhRenderer *renderer, const PhWorld *world, PH_VIEW_H * 0.5f + 44.0f, "ENTER TO CONTINUE"); } +static void +ph_draw_debug_stats(PhRenderer *renderer, const PhWorld *world, + const PhDebugView *debug) +{ + char text[96]; + + if (!debug || !debug->enabled) return; + ph_render_color(renderer, (PhColor){ PH_DEBUG_BG_COLOR }); + ph_render_fill(renderer, (PhRect){ 0, 0, PH_VIEW_W, 40 }); + ph_render_color(renderer, (PhColor){ PH_DEBUG_TEXT_COLOR }); + snprintf(text, sizeof(text), "FRAME %.2f MS TICKS %d", debug->frame_ms, debug->steps); + ph_render_text(renderer, 4, 2, text); + snprintf(text, sizeof(text), "UPDATE %.2f DRAW %.2f MS", debug->update_ms, debug->draw_ms); + ph_render_text(renderer, 4, 14, text); + snprintf(text, sizeof(text), "EVENTS %d/%d LOST %u", world->event_count, + PH_MAX_WORLD_EVENTS, world->event_dropped); + ph_render_text(renderer, 4, 26, text); +} + void ph_present_frame(PhRenderer *renderer, - const PhGameContext *context, const PhControlLabels *labels) + const PhGameContext *context, const PhControlLabels *labels, const PhDebugView *debug) { const PhWorld *world = context->world; const PhBattle *battle = context->battle; @@ -719,6 +738,7 @@ ph_present_frame(PhRenderer *renderer, ph_draw_battle(renderer, world, battle); if (context->menu == PH_MENU_MAIN) ph_draw_options(renderer, context->options); + ph_draw_debug_stats(renderer, world, debug); ph_render_present(renderer); return; } @@ -726,6 +746,7 @@ ph_present_frame(PhRenderer *renderer, ph_draw_battle_end(renderer, world, battle, game); if (context->menu == PH_MENU_MAIN) ph_draw_options(renderer, context->options); + ph_draw_debug_stats(renderer, world, debug); ph_render_present(renderer); return; } @@ -734,6 +755,7 @@ ph_present_frame(PhRenderer *renderer, ph_render_clear(renderer); ph_camera_follow_player(&camera, world); ph_render_world(renderer, world, &camera); + if (debug && debug->enabled) ph_render_world_debug(renderer, world, &camera); if (context->menu == PH_MENU_CHARACTER) { ph_draw_character_sheet(renderer, world, labels); } else if (context->menu == PH_MENU_INVENTORY) { @@ -770,5 +792,6 @@ ph_present_frame(PhRenderer *renderer, ph_render_fill(renderer, top); ph_render_fill(renderer, bottom); } + ph_draw_debug_stats(renderer, world, debug); ph_render_present(renderer); } diff --git a/src/game/presentation.h b/src/game/presentation.h @@ -14,7 +14,17 @@ typedef struct { const char *spellbook; } PhControlLabels; +/* Host measurements from the previous completed frame, in milliseconds. + * Frame includes pacing; update includes tick work/audio/save, draw includes present. */ +typedef struct { + int enabled; + int steps; + double frame_ms; + double update_ms; + double draw_ms; +} PhDebugView; + void ph_present_frame(PhRenderer *renderer, const PhGameContext *context, - const PhControlLabels *labels); + const PhControlLabels *labels, const PhDebugView *debug); #endif diff --git a/src/game/validate.c b/src/game/validate.c @@ -0,0 +1,81 @@ +#include "game/content.h" +#include "engine/validate.h" + +#include <math.h> +#include <stdio.h> + +#define FAIL(...) do { \ + if (error && size) snprintf(error, size, __VA_ARGS__); \ + return -1; \ +} while (0) + +static int +ph_asset_valid(int id) +{ + return id >= 0 && id < PH_ASSET_COUNT && + (id == PH_ASSET_NONE || (ph_asset_defs[id].path && *ph_asset_defs[id].path)); +} + +int +ph_game_validate_content(char *error, size_t size) +{ + PhWorld lookup = { .content = ph_game_world.content }; + int i, j; + + if (ph_validate_world(&ph_game_world, error, size) < 0 || + ph_validate_spells(&ph_player_spellbook, error, size) < 0) return -1; + for (i = 1; i < PH_AUDIO_COUNT; ++i) + if (!ph_audio_defs[i].path || !*ph_audio_defs[i].path || + !isfinite(ph_audio_defs[i].gain) || ph_audio_defs[i].gain < 0) + FAIL("audio %d: invalid path or gain", i); + for (i = 0; i < PH_TILE_DEF_COUNT; ++i) { + const PhTileDef *tile = &ph_tile_defs[i]; + + if (!ph_asset_valid(tile->asset_id) || !ph_asset_valid(tile->background_asset_id) || + tile->sprite_tile_x < 0 || tile->sprite_tile_y < 0 || + tile->background_sprite_tile_x < 0 || tile->background_sprite_tile_y < 0) + FAIL("tile '%c': invalid sprite reference", tile->symbol); + } + for (i = 0; i < PH_ITEM_DEF_COUNT; ++i) + if (!ph_asset_valid(ph_item_defs[i].asset_id)) + FAIL("item %d: invalid asset %d", ph_item_defs[i].id, ph_item_defs[i].asset_id); + for (i = 0; i < PH_ENTITY_DEF_COUNT; ++i) { + const PhEntityDef *entity = &ph_entity_defs[i]; + + if (!ph_asset_valid(entity->asset_id)) FAIL("entity %d: invalid asset %d", entity->id, entity->asset_id); + if (entity->interaction_id && !ph_game_interaction_def(entity->interaction_id)) + FAIL("entity %d: unknown interaction %d", entity->id, entity->interaction_id); + } + for (i = 0; i < PH_INTERACTION_COUNT; ++i) { + const PhInteractionDef *interaction = &ph_interaction_defs[i]; + + if (interaction->id <= 0 || interaction->kind != PH_INTERACTION_SHOP || + !ph_game_shop_def(interaction->content_id)) + FAIL("interaction %d: invalid kind or shop %d", interaction->id, interaction->content_id); + for (j = 0; j < i; ++j) + if (ph_interaction_defs[j].id == interaction->id) FAIL("interaction %d: duplicate ID", interaction->id); + } + for (i = 0; i < PH_SHOP_COUNT; ++i) { + const PhShopDef *shop = &ph_shop_defs[i]; + + if (shop->id <= 0 || !shop->name || shop->kind < PH_SHOP_ITEMS || + shop->kind > PH_SHOP_SPELLS || shop->stock_count < 0 || shop->stock_count > PH_SHOP_STOCK_MAX) + FAIL("shop %d: invalid definition", shop->id); + for (j = 0; j < i; ++j) + if (ph_shop_defs[j].id == shop->id) FAIL("shop %d: duplicate ID", shop->id); + for (j = 0; j < PH_SHOP_TALK_LINES; ++j) + if (!shop->talk[j]) FAIL("shop %d: missing talk line %d", shop->id, j); + for (j = 0; j < shop->stock_count; ++j) { + const PhItemDef *item = ph_world_item_def(&lookup, shop->stock[j]); + const PhSpellDef *spell = ph_spellbook_def(&ph_player_spellbook, shop->stock[j]); + int k; + + if (shop->kind == PH_SHOP_ITEMS ? (!item || item->buy_value <= 0) : (!spell || spell->buy_value <= 0)) + FAIL("shop %d stock entry %d: invalid %s %d", shop->id, j, + shop->kind == PH_SHOP_ITEMS ? "item" : "spell", shop->stock[j]); + for (k = 0; k < j; ++k) + if (shop->stock[k] == shop->stock[j]) FAIL("shop %d: duplicate stock ID %d", shop->id, shop->stock[j]); + } + } + return 0; +} diff --git a/src/tests/render.c b/src/tests/render.c @@ -62,6 +62,36 @@ ph_pixel_is(SDL_Surface *surface, int x, int y, PhColor color) } static int +ph_debug_smoke(PhRenderer *renderer, SDL_Surface *surface) +{ + unsigned char tiles[64] = { 0 }; + const PhTileDef defs[] = { { .symbol = '.' }, { .symbol = '#', .blocks_movement = 1 } }; + const PhEntityDef actor = { .id = 1, .kind = PH_ENTITY_MONSTER, .blocks_movement = 1 }; + PhWorld world = { + .area = { .width = 8, .height = 8, .tiles = tiles, .tile_defs = defs, .tile_def_count = 2 }, + .content = { .entities = &actor, .entity_count = 1 }, + .player_index = -1, .entity_count = 1, + }; + PhWorld before; + PhCamera camera = { .pos = { 16, 16 }, .viewport_w = PH_VIEW_W, .viewport_h = PH_VIEW_H }; + + tiles[3 * 8 + 2] = 1; + world.entities[0] = (PhEntity){ .type_id = 1, .active = 1, .pos = { 96, 96 }, + .motion = { .target = { 112, 112 }, .moving = 1 } }; + memcpy(&before, &world, sizeof(world)); + ph_render_color(renderer, (PhColor){ PH_CLEAR_COLOR }); + ph_render_clear(renderer); + ph_render_world_debug(renderer, &world, &camera); + ph_render_present(renderer); + if (memcmp(&before, &world, sizeof(world)) || + !ph_pixel_is(surface, 16, 32, (PhColor){ PH_DEBUG_BLOCK_COLOR }) || + !ph_pixel_is(surface, 80 - PH_ENTITY_BLOCK_RADIUS, 80, (PhColor){ PH_DEBUG_ACTOR_COLOR }) || + !ph_pixel_is(surface, 96 + PH_ENTITY_BLOCK_RADIUS, 96, (PhColor){ PH_DEBUG_TARGET_COLOR }) || + !ph_pixel_is(surface, 94, 94, (PhColor){ PH_DEBUG_TARGET_COLOR })) return 1; + return 0; +} + +static int ph_media_smoke(SDL_Renderer *target, SDL_Surface *surface) { char directory[] = "/tmp/ph-media-XXXXXX"; @@ -245,6 +275,10 @@ main(void) SDL_Quit(); return 1; } + if (ph_debug_smoke(renderer, surface)) { + fprintf(stderr, "debug overlay geometry test failed\n"); + result = 1; + } if (ph_start_input(&start_ui, PH_ACTION_ACCEPT) != PH_START_NEW) { fprintf(stderr, "render smoke test failed: new-game selection\n"); result = 1; @@ -256,7 +290,22 @@ main(void) result = 1; } context.menu = PH_MENU_START; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); + { + PhWorld before; + PhDebugView debug = { .enabled = 1, .frame_ms = 16, .steps = 1 }; + PhColor baseline; + + memcpy(&before, &world, sizeof(world)); + SDL_ReadSurfacePixel(surface, 0, 0, &baseline.r, &baseline.g, &baseline.b, &baseline.a); + ph_present_frame(renderer, &context, &labels, &debug); + if (!ph_pixel_is(surface, 0, 0, (PhColor){ PH_DEBUG_BG_COLOR }) || + memcmp(&before, &world, sizeof(world))) result = 1; + debug.enabled = 0; + ph_present_frame(renderer, &context, &labels, &debug); + if (!ph_pixel_is(surface, 0, 0, baseline)) result = 1; + } + if (!(audio = ph_audio_sdl_create(ph_audio_defs, PH_AUDIO_COUNT))) { fprintf(stderr, "audio smoke test failed: %s\n", SDL_GetError()); ph_render_destroy(renderer); @@ -319,7 +368,7 @@ main(void) result = 1; } context.menu = PH_MENU_MAIN; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_options_input(&options_ui, PH_ACTION_BACK); if (ph_options_input(&options_ui, PH_ACTION_BACK) != PH_OPTIONS_CLOSE) { fprintf(stderr, "render smoke test failed: options cancel\n"); @@ -362,7 +411,7 @@ main(void) i == 9 ? PH_INVENTORY_ACTIONS : PH_INVENTORY_BROWSE; context.menu = i == 7 ? PH_MENU_CHARACTER : i >= 6 ? PH_MENU_INVENTORY : PH_MENU_NONE; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); } ph_spellbook_open(&spellbook_ui, &spells); ph_spellbook_input(&spellbook_ui, &world, &spells, PH_ACTION_ACCEPT); @@ -385,34 +434,34 @@ main(void) world.entities[world.player_index].vitals.mp = ph_world_entity_stats(&world, ph_world_player(&world)).max_mp; context.menu = PH_MENU_SPELLBOOK; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); spellbook_ui.mode = PH_SPELLBOOK_ACTIONS; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); spellbook_ui.mode = PH_SPELLBOOK_EXAMINE; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_shop_open(&shop_ui, ph_shop_defs[0].id); for (i = PH_SHOP_ROOT; i <= PH_SHOP_TALK; ++i) { shop_ui.mode = (PhShopMode)i; shop_ui.selected = i == PH_SHOP_SELL ? ph_inventory_step(&world, -1, 1) : 0; context.menu = PH_MENU_SHOP; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); } ph_shop_open(&shop_ui, PH_SHOP_MAGICK); shop_ui.mode = PH_SHOP_ROOT; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_shop_input(&shop_ui, &world, &spells, PH_ACTION_ACCEPT); if (shop_ui.mode != PH_SHOP_BUY) result = 1; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_shop_input(&shop_ui, &world, &spells, PH_ACTION_ACCEPT); if (!ph_spellbook_has(&spells, PH_SPELL_EMBER) || ph_world_player(&world)->gold != 16) { fprintf(stderr, "render smoke test failed: magick purchase\n"); result = 1; } - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); shop_ui.mode = PH_SHOP_TALK; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); for (i = 0; i < world.entity_count; ++i) { const PhEntityDef *def = ph_world_entity_def(&world, world.entities[i].type_id); if (def && def->kind == PH_ENTITY_MONSTER) enemy_index = i; @@ -426,15 +475,15 @@ main(void) context.menu = PH_MENU_NONE; ph_battle_begin(&battle, &world, enemy_index, spells); game = PH_GAME_TRANSITION; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (game != PH_GAME_TRANSITION) result = 1; context.menu = PH_MENU_NONE; ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS * 0.5f, 1); - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); game = PH_GAME_BATTLE; ph_options_open(&options_ui); context.menu = PH_MENU_MAIN; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); context.menu = PH_MENU_NONE; battle.phase = PH_BATTLE_ENEMY_ACTION; battle.timer = PH_BATTLE_ACTION_SECONDS; @@ -449,7 +498,7 @@ main(void) context.menu = PH_MENU_NONE; battle_result = ph_battle_update(&battle, &world, 0.0f, 0); if (battle_result == PH_BATTLE_DEFEAT) game = PH_GAME_OVER; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (game != PH_GAME_OVER) result = 1; world.entities[world.player_index].vitals.hp = ph_world_entity_stats(&world, ph_world_player(&world)).max_hp; @@ -458,7 +507,7 @@ main(void) context.menu = PH_MENU_NONE; if (ph_battle_update(&battle, &world, PH_BATTLE_TRANSITION_SECONDS, 1) == PH_BATTLE_READY) game = PH_GAME_BATTLE; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); world.entities[enemy_index].vitals.hp = ph_world_entity_stats(&world, &world.entities[enemy_index]).max_hp; world.entities[enemy_index].vitals.hp = 1; @@ -469,7 +518,7 @@ main(void) if (battle.phase != PH_BATTLE_TARGET || battle.choice.target_index != world.player_index) result = 1; if (ph_battle_target_step(&battle, &world, 1) != enemy_index) result = 1; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_battle_confirm_target(&battle, &world); ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); if (ph_battle_take_event(&battle) != PH_BATTLE_EVENT_SPELL) { @@ -509,14 +558,14 @@ main(void) result = 1; } context.menu = PH_MENU_NONE; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_battle_cast(&battle, &world); if (battle.phase != PH_BATTLE_TARGET || battle.choice.target_index != enemy_index) { fprintf(stderr, "render smoke test failed: offensive target default\n"); result = 1; } - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); ph_battle_confirm_target(&battle, &world); context.menu = PH_MENU_NONE; ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); @@ -524,10 +573,10 @@ main(void) fprintf(stderr, "render smoke test failed: spell sound event\n"); result = 1; } - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); context.menu = PH_MENU_NONE; ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (game != PH_GAME_BATTLE || battle.phase != PH_BATTLE_COMMAND || battle.turn.current_actor != world.player_index || ph_world_player(&world)->vitals.mp != 35 || @@ -545,12 +594,12 @@ main(void) fprintf(stderr, "render smoke test failed: attack sound event\n"); result = 1; } - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); context.menu = PH_MENU_NONE; battle_result = ph_battle_update(&battle, &world, PH_BATTLE_ACTION_SECONDS * 0.5f, 0); if (battle_result == PH_BATTLE_VICTORY) game = PH_GAME_VICTORY; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (game != PH_GAME_VICTORY || ph_world_player(&world)->xp != battle.reward.xp || ph_world_item_amount(&world, battle.reward.item_id) != 2) { @@ -594,18 +643,18 @@ main(void) ph_shop_open(&shop_ui, ph_shop_defs[0].id); shop_ui.mode = PH_SHOP_TALK; context.menu = PH_MENU_SHOP; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (ph_render_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1) { fprintf(stderr, "render smoke test failed: return to town\n"); result = 1; } context.menu = PH_MENU_NONE; - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (ph_render_enter_portal(&world, PH_AREA_MEADOW) != 1) { fprintf(stderr, "render smoke test failed: return to meadow\n"); result = 1; } - ph_present_frame(renderer, &context, &labels); + ph_present_frame(renderer, &context, &labels, NULL); if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) { fprintf(stderr, "SDL_SaveBMP failed: %s\n", SDL_GetError()); diff --git a/src/tests/smoke.c b/src/tests/smoke.c @@ -7,10 +7,33 @@ #include "config.h" #include <stdio.h> +#include <limits.h> #include <string.h> #define LEN(a) (sizeof(a) / sizeof(0[a])) +static int +ph_event_overflow_test(void) +{ + PhWorld world = { 0 }; + PhWorldEvent event; + int i; + + for (i = 0; i < PH_MAX_WORLD_EVENTS; ++i) + if (ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, (PhVec2){ 0 }, i) < 0) return 1; + if (world.event_dropped || ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, + (PhVec2){ 0 }, -1) != -1 || world.event_dropped != 1) return 1; + for (i = 0; i < PH_MAX_WORLD_EVENTS; ++i) + if (!ph_world_take_event(&world, &event) || event.entity_index != i) return 1; + if (world.event_dropped != 1 || ph_world_take_event(&world, &event)) return 1; + if (ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, (PhVec2){ 0 }, 17) < 0 || + !ph_world_take_event(&world, &event) || event.entity_index != 17) return 1; + world.event_count = PH_MAX_WORLD_EVENTS; + world.event_dropped = UINT_MAX; + ph_world_emit_event(&world, PH_WORLD_EVENT_MOVE, (PhVec2){ 0 }, -1); + return world.event_dropped != UINT_MAX; +} + static unsigned int ph_world_event_mask(PhWorld *world) { @@ -639,6 +662,7 @@ ph_save_test(void) player->vitals.hp = 23; player->xp = 91; player->gold = 137; + world.event_dropped = 3; world.inventory[3] = 4; if (ph_spellbook_learn(&spells, PH_SPELL_EMBER) < 0) goto done_world; if (ph_enter_portal(&world, PH_AREA_STONEHOLLOW) != 1 || @@ -650,7 +674,7 @@ ph_save_test(void) } player = &loaded.entities[loaded.player_index]; if (loaded.area_id != PH_AREA_STONEHOLLOW || loaded.entity_count != 1 || - loaded.initialized_area_count != 2 || player->vitals.hp != 23 || + loaded.initialized_area_count != 2 || loaded.event_dropped != 0 || player->vitals.hp != 23 || player->xp != 91 || player->gold != 137 || ph_world_item_amount(&loaded, PH_ITEM_HEALING_POTION) != 4 || !ph_spellbook_has(&loaded_spells, PH_SPELL_FLASH) || @@ -713,6 +737,10 @@ main(void) fprintf(stderr, "smoke test failed: tile configuration\n"); return 1; } + if (ph_event_overflow_test()) { + fprintf(stderr, "smoke test failed: event overflow diagnostics\n"); + return 1; + } if (ph_area_catalog_test()) { fprintf(stderr, "smoke test failed: engine area catalog\n"); return 1; diff --git a/src/tests/validate.c b/src/tests/validate.c @@ -0,0 +1,191 @@ +#include "engine/validate.h" +#include "game/content.h" + +#include <stdint.h> +#include <stdio.h> +#include <string.h> + +#define CHECK(test) do { if (!(test)) { \ + fprintf(stderr, "validation test failed at line %d: %s\n", __LINE__, #test); \ + return 1; \ +} } while (0) + +static int +ph_reject(const PhWorldSetup *setup, const char *expected) +{ + char error[256]; + + if (ph_validate_world(setup, error, sizeof(error)) < 0 && strstr(error, expected)) return 1; + fprintf(stderr, "expected '%s', got '%s'\n", expected, error); + return 0; +} + +/* Write a fixture in the same format as mapc, without changing production maps. */ +static int +ph_write_map(const char *path, const PhArea *area) +{ + struct { + uint32_t magic, version, width, height, markers; + char name[PH_AREA_NAME_MAX]; + } header = { PH_MAP_MAGIC, PH_MAP_VERSION, area->width, area->height, + area->marker_count, "Validation fixture" }; + FILE *fp = fopen(path, "wb"); + int i, ok; + size_t count = (size_t)area->width * area->height; + + if (!fp) return -1; + ok = fwrite(&header, sizeof(header), 1, fp) == 1 && fwrite(area->tiles, 1, count, fp) == count; + for (i = 0; i < area->marker_count && ok; ++i) { + uint32_t marker[] = { area->markers[i].symbol, area->markers[i].tile_x, area->markers[i].tile_y }; + ok = fwrite(marker, sizeof(marker), 1, fp) == 1; + } + if (fclose(fp)) ok = 0; + return ok ? 0 : -1; +} + +static int +ph_map_tests(PhWorldSetup *setup, PhAreaDef *defs) +{ + const char *path = "obj/validation-fixture.phmap"; + PhArea area; + PhAreaMarker saved; + const char *original = defs[0].path; + unsigned char tile; + int result = 1; + int i; + + if (ph_area_load(&area, original) < 0) return 1; + defs[0].path = path; + tile = area.tiles[0]; + area.tiles[0] = 255; + if (ph_write_map(path, &area) < 0 || !ph_reject(setup, "invalid tile index 255")) goto done; + area.tiles[0] = tile; + saved = area.markers[0]; + area.markers[0].symbol = '?'; + if (ph_write_map(path, &area) < 0 || !ph_reject(setup, "undefined marker")) goto done; + area.markers[0] = saved; + area.markers[0].tile_x = area.width; + if (ph_write_map(path, &area) < 0 || !ph_reject(setup, "cannot load map")) goto done; + area.markers[0] = saved; + area.markers[1] = saved; + if (ph_write_map(path, &area) < 0 || !ph_reject(setup, "overlapping markers")) goto done; + ph_area_free(&area); + if (ph_area_load(&area, original) < 0) goto done; + /* Dropping the starting marker must be diagnosed before play. */ + for (i = 0; i < area.marker_count; ++i) { + const PhMarkerDef *marker = ph_area_catalog_marker(setup->areas, defs[0].id, area.markers[i].symbol); + if (marker->kind != PH_MARKER_PLAYER) continue; + area.markers[i] = area.markers[--area.marker_count]; + break; + } + if (ph_write_map(path, &area) < 0 || !ph_reject(setup, "invalid spawn counts")) goto done; + result = 0; +done: + defs[0].path = original; + ph_area_free(&area); + remove(path); + return result; +} + +int +main(void) +{ + PhWorldSetup setup = ph_game_world; + PhAreaCatalog catalog = *setup.areas; + PhEntityDef entities[PH_ENTITY_DEF_COUNT]; + PhItemDef items[PH_ITEM_DEF_COUNT]; + PhAreaDef areas[PH_AREA_COUNT]; + PhTileDef tiles[PH_TILE_DEF_COUNT]; + PhMarkerDef markers[PH_MARKER_DEF_COUNT + 1]; + PhSpellDef spells[PH_SPELL_DEF_COUNT]; + PhSpellBook book = ph_player_spellbook; + char error[256]; + int equipment = 9999; + int i; + + if (ph_game_validate_content(error, sizeof(error)) < 0) { + fprintf(stderr, "content: %s\n", error); + return 1; + } + memcpy(entities, ph_entity_defs, sizeof(entities)); + memcpy(items, ph_item_defs, sizeof(items)); + memcpy(areas, ph_area_defs, sizeof(areas)); + memcpy(tiles, ph_tile_defs, sizeof(tiles)); + memcpy(markers, ph_marker_defs, sizeof(ph_marker_defs)); + memcpy(spells, ph_spell_defs, sizeof(spells)); + setup.areas = &catalog; + setup.content.entities = entities; + setup.content.items = items; + catalog.areas = areas; + catalog.tiles = tiles; + catalog.markers = markers; + book.defs = spells; + CHECK(ph_validate_world(&setup, error, sizeof(error)) == 0); + CHECK(ph_reject(NULL, "missing setup")); + catalog.tiles = NULL; + CHECK(ph_reject(&setup, "missing arrays")); + catalog.tiles = tiles; + catalog.marker_count = -1; + CHECK(ph_reject(&setup, "invalid area catalog counts")); + catalog.marker_count = PH_MARKER_DEF_COUNT; + markers[1] = markers[0]; + CHECK(ph_reject(&setup, "duplicate definition")); + markers[1] = ph_marker_defs[1]; + entities[1].animation_frames = PH_ANIMATION_FRAMES + 1; + CHECK(ph_reject(&setup, "invalid definition")); + entities[1] = ph_entity_defs[1]; + entities[1].id = entities[0].id; + CHECK(ph_reject(&setup, "duplicate ID")); + entities[1] = ph_entity_defs[1]; + entities[1].reward.item_id = 9999; + CHECK(ph_reject(&setup, "invalid reward item 9999")); + entities[1] = ph_entity_defs[1]; + items[1].id = items[0].id; + CHECK(ph_reject(&setup, "duplicate ID")); + items[1] = ph_item_defs[1]; + tiles[1].symbol = tiles[0].symbol; + CHECK(ph_reject(&setup, "duplicate symbol")); + tiles[1] = ph_tile_defs[1]; + areas[1].id = areas[0].id; + CHECK(ph_reject(&setup, "duplicate ID")); + areas[1] = ph_area_defs[1]; + areas[1].path = "obj/no-such-validation-map.phmap"; + CHECK(ph_reject(&setup, "cannot load map")); + areas[1] = ph_area_defs[1]; + setup.start_area_id = 9999; + CHECK(ph_reject(&setup, "unknown start area 9999")); + setup.start_area_id = ph_game_world.start_area_id; + setup.equipment = &equipment; + setup.equipment_count = 1; + CHECK(ph_reject(&setup, "invalid item 9999")); + setup.equipment = ph_game_world.equipment; + setup.equipment_count = ph_game_world.equipment_count; + markers[0].content_id = 9999; + CHECK(ph_reject(&setup, "invalid entity 9999")); + markers[0] = ph_marker_defs[0]; + for (i = 0; markers[i].kind != PH_MARKER_PORTAL; ++i) {} + markers[i].destination_area_id = 9999; + CHECK(ph_reject(&setup, "invalid destination 9999")); + markers[i] = ph_marker_defs[i]; + markers[PH_MARKER_DEF_COUNT] = (PhMarkerDef){ + .area_id = markers[i].destination_area_id, .symbol = '?', .tile_symbol = '.', + .kind = PH_MARKER_ARRIVAL }; + ++catalog.marker_count; + markers[i].destination_symbol = '?'; + CHECK(ph_reject(&setup, "needs one arrival '?' (found 0)")); + --catalog.marker_count; + markers[i] = ph_marker_defs[i]; + CHECK(ph_map_tests(&setup, areas) == 0); + spells[1].id = spells[0].id; + CHECK(ph_validate_spells(&book, error, sizeof(error)) < 0 && strstr(error, "duplicate ID")); + spells[1] = ph_spell_defs[1]; + book.learned[0] = 9999; + CHECK(ph_validate_spells(&book, error, sizeof(error)) < 0 && strstr(error, "unknown learned spell 9999")); + book = ph_player_spellbook; + book.learned_count = PH_SPELL_MAX + 1; + CHECK(ph_validate_spells(&book, error, sizeof(error)) < 0); + CHECK(ph_validate_world(&setup, NULL, 0) == 0); + CHECK(ph_validate_world(NULL, error, 1) < 0 && error[0] == '\0'); + puts("content check: catalogs, all maps, and invalid-data regression tests passed"); + return 0; +}