File size: 9,111 Bytes
3ce1a40 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | // Engine-level tests against the real demo game content.
#include "test_main.h"
#include "../engine/runtime/engine.h"
#include <lua.h>
#include <memory>
using namespace unify;
namespace {
struct Harness {
HeadlessBackend backend;
std::unique_ptr<Engine> engine;
explicit Harness(uint64_t seed = 7) {
set_log_level(LogLevel::Error);
EngineConfig c;
c.asset_root = UNIFY_GAME_ASSETS;
c.seed = seed;
engine = std::make_unique<Engine>(backend, c);
std::string err;
booted = engine->boot(err);
if (!booted) std::printf(" boot error: %s\n", err.c_str());
}
bool booted = false;
void frames(int n) { for (int i = 0; i < n; i++) { backend.advance_clock(16666667); engine->frame(); } }
void tap(uint16_t k) { engine->inject(k, true); frames(1); engine->inject(k, false); frames(1); }
double num(const std::string& expr) {
lua_State* L = engine->lua().L();
if (!engine->lua().run("return " + expr, "probe", 1)) return -1e9;
double v = lua_tonumber(L, -1);
lua_pop(L, 1);
return v;
}
bool lua_ok(const std::string& code) { return engine->lua().run(code, "probe"); }
// Plays a fixed scripted sequence (same for every harness).
void script(int ticks) {
for (int t = 0; t < ticks; t++) {
if (t % 50 == 0) engine->inject(key::Right, true);
if (t % 50 == 30) engine->inject(key::Right, false);
if (t % 37 == 5) engine->inject(key::Space, true);
if (t % 37 == 15) engine->inject(key::Space, false);
frames(1);
}
}
};
} // namespace
TEST(engine_scene_transitions_do_not_leak) {
Harness h;
CHECK(h.booted);
h.frames(3);
ResourceCounts title = h.engine->resources();
size_t asset_bytes = h.engine->assets().allocator().used();
for (int round = 0; round < 3; round++) {
h.tap(key::Enter); // title -> level: ~360 entities, bodies, buffers, kernel, voices
h.frames(30);
CHECK(h.engine->active_scene()->name == "level");
CHECK(h.engine->world().alive() > 300);
CHECK(h.engine->compute().live_buffers() == 5 + 3 + 0 || h.engine->compute().live_buffers() >= 5);
h.tap(key::Escape); // level -> title
h.frames(3);
ResourceCounts back = h.engine->resources();
CHECK(back == title); // entities, bodies, anim players, voices, buffers, kernels, assets, textures, Lua refs
CHECK(h.engine->assets().allocator().used() == asset_bytes);
}
}
TEST(engine_same_seed_and_input_gives_same_simulation) {
Harness a(99), b(99);
a.tap(key::Enter); b.tap(key::Enter);
a.script(300); b.script(300);
CHECK(a.engine->state_hash() == b.engine->state_hash());
CHECK(a.num("scene.state().jumps") == b.num("scene.state().jumps"));
CHECK(a.num("scene.state().jumps") > 2);
}
TEST(engine_save_file_restores_in_a_fresh_engine) {
std::vector<uint8_t> saved;
uint64_t hash_at_save, hash_later;
{
Harness a;
a.tap(key::Enter);
a.script(120);
saved = a.engine->save_state();
hash_at_save = a.engine->state_hash();
a.script(90);
hash_later = a.engine->state_hash();
}
Harness b; // a new engine, as after a process restart: boots into the title scene
std::string err;
CHECK(b.engine->load_state(saved, err));
if (!err.empty()) std::printf(" load error: %s\n", err.c_str());
CHECK(b.engine->active_scene()->name == "level");
CHECK(b.engine->state_hash() == hash_at_save);
b.script(90); // the same scripted input from the same state...
// ...reaches the same state, except for script() tick alignment which depends only on its own counter.
CHECK(b.engine->state_hash() == hash_later);
}
TEST(engine_rejects_corrupt_or_foreign_save_states) {
Harness h;
std::string err;
CHECK(!h.engine->load_state({1, 2, 3}, err));
std::vector<uint8_t> s = h.engine->save_state();
s.resize(s.size() / 2);
CHECK(!h.engine->load_state(s, err) && !err.empty());
}
TEST(engine_lua_handles_are_validated) {
Harness h;
h.tap(key::Enter);
h.frames(2);
CHECK(h.lua_ok("local c = world:find('coin'); c:destroy(); _G.stale = c"));
CHECK(!h.lua_ok("stale:position()"));
CHECK(h.engine->lua().last_error().find("stale entity handle") != std::string::npos);
CHECK(h.num("stale:valid() and 1 or 0") == 0);
CHECK(!h.lua_ok("ui:text('x', 0, 0)")); // only valid during render()
CHECK(h.num("(os == nil and io == nil) and 1 or 0") == 1); // sandboxed: no wall clock, no file access
}
TEST(engine_lua_gc_is_engine_scheduled_and_budgeted) {
Harness h;
CHECK(h.lua_ok("gc_pause(); junk = {}; for i = 1, 20000 do junk[i] = {i} end; junk = nil"));
size_t before = h.engine->lua().bytes();
h.frames(5); // paused: ticks do not collect
CHECK(h.engine->lua().bytes() >= before);
CHECK(h.lua_ok("gc_resume(); gc_budget(16)"));
h.frames(200); // bounded incremental steps each tick reclaim it
CHECK(h.engine->lua().bytes() < before);
CHECK(h.lua_ok("gc_collect()"));
CHECK(h.engine->lua().bytes() < before / 2);
}
TEST(engine_lua_memory_is_capped_separately) {
HeadlessBackend backend;
EngineConfig c;
c.asset_root = UNIFY_GAME_ASSETS;
c.lua_memory_limit = 4u << 20;
Engine e(backend, c);
std::string err;
CHECK(e.boot(err));
CHECK(!e.lua().run("local t = {} for i = 1, 1e7 do t[i] = i end", "hog"));
CHECK(e.lua().last_error().find("not enough memory") != std::string::npos);
CHECK(e.lua().bytes() <= e.lua().limit());
CHECK(e.lua().run("x = 1 + 1", "after")); // the VM survives the failed allocation
}
TEST(engine_steady_state_tick_does_not_heap_allocate) {
Harness h;
h.tap(key::Enter);
h.frames(120); // warm up: contacts, pools and scratch vectors reach steady size
uint64_t worst = 0;
for (int i = 0; i < 60; i++) { h.frames(1); worst = std::max<uint64_t>(worst, h.engine->stats().heap_allocs_last_tick); }
CHECK(worst == 0);
if (worst) std::printf(" worst tick: %llu allocations\n", static_cast<unsigned long long>(worst));
}
TEST(engine_scheduler_runs_stages_in_order) {
Harness h;
h.frames(2);
const auto& t = h.engine->trace();
std::vector<Stage> expect = {Stage::Input, Stage::InputBuffer, Stage::FixedSimulation, Stage::Physics, Stage::Gameplay,
Stage::Animation, Stage::Audio, Stage::RenderPrep, Stage::Render, Stage::Present};
CHECK(t == expect);
}
TEST(engine_fixed_timestep_is_independent_of_frame_rate) {
Harness h;
uint64_t t0 = h.engine->sim_clock().tick;
for (int i = 0; i < 30; i++) { h.backend.advance_clock(1000000000ull / 30); h.engine->frame(); } // 30 FPS for 1 s
uint64_t at30 = h.engine->sim_clock().tick - t0;
uint64_t t1 = h.engine->sim_clock().tick;
for (int i = 0; i < 144; i++) { h.backend.advance_clock(1000000000ull / 144); h.engine->frame(); } // 144 FPS for 1 s
uint64_t at144 = h.engine->sim_clock().tick - t1;
CHECK(at30 >= 59 && at30 <= 61);
CHECK(at144 >= 59 && at144 <= 61);
// A 5-second stall drops time instead of running 300 catch-up ticks in one frame.
uint64_t t2 = h.engine->sim_clock().tick;
h.backend.advance_clock(5'000'000'000ull);
h.engine->frame();
CHECK(h.engine->sim_clock().tick - t2 == uint64_t(h.engine->config().max_ticks_per_frame));
}
#include "../engine/net/rollback.h"
TEST(net_rollback_corrects_a_late_input) {
// Timeline A: the player jumps at tick J. Timeline B: B first simulates with the jump
// missing (a late network packet), then learns the truth and rolls back.
auto make_jump = [](uint64_t tick, const InputFrame& base) {
InputFrame f = base;
f.tick = tick;
f.pressed = {key::Space}; // a tap: pressed and released within the tick,
f.released = {key::Space}; // so the key is not left held on either timeline
return f;
};
Harness a, b;
a.tap(key::Enter); b.tap(key::Enter);
a.frames(60); b.frames(60);
CHECK(a.engine->state_hash() == b.engine->state_hash());
const uint64_t J = a.engine->sim_clock().tick + 25;
RollbackBuffer ra, rb(10, 600);
for (int i = 0; i < 80; i++) {
uint64_t t = a.engine->sim_clock().tick;
if (t == J) { a.engine->replay_tick(make_jump(t, a.engine->input().frame())); continue; }
ra.advance(*a.engine);
}
for (int i = 0; i < 80; i++) rb.advance(*b.engine); // no jump at all
CHECK(a.engine->state_hash() != b.engine->state_hash());
std::string err;
InputFrame late;
for (const InputFrame& f : rb.log()) if (f.tick == J) late = f;
CHECK(rb.correct(*b.engine, J, make_jump(J, late), err));
if (!err.empty()) std::printf(" rollback error: %s\n", err.c_str());
CHECK(rb.resimulated_ticks() > 50);
CHECK(a.engine->sim_clock().tick == b.engine->sim_clock().tick);
CHECK(a.engine->state_hash() == b.engine->state_hash()); // corrected timeline == true timeline
}
|