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:
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;
+}