File size: 12,200 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
220
221
// Automated playthrough of the demo game on the headless backend. Walks the UNIFY
// definition of done stage by stage and verifies each one with evidence from the running
// engine (not from the script's own claims). Writes screenshots to <out-dir>.
//   unify_playtest <assets-dir> <out-dir>
#include "runtime/engine.h"
#include <lua.h>
#include <cstdio>
#include <cstring>
#include <set>

using namespace unify;

static int failures = 0;
static void check(bool ok, const char* stage, const std::string& evidence) {
    std::printf("  [%s] %-22s %s\n", ok ? "PASS" : "FAIL", stage, evidence.c_str());
    if (!ok) failures++;
}

struct Game {
    HeadlessBackend backend;
    std::unique_ptr<Engine> engine;
    std::string out;
    uint64_t frames = 0;

    void frame() {
        backend.advance_clock(16666667);  // one 60 Hz tick per frame (rounded up so frames never fall a tick short)
        engine->frame();
        frames++;
    }
    void run(int n) { for (int i = 0; i < n; i++) frame(); }
    void press(uint16_t k) { engine->inject(k, true); }
    void release(uint16_t k) { engine->inject(k, false); }
    void tap(uint16_t k) { press(k); frame(); release(k); frame(); }
    double num(const char* expr) {
        lua_State* L = engine->lua().L();
        if (!engine->lua().run(std::string("return ") + expr, "probe", 1)) return -1e9;
        double v = lua_tonumber(L, -1);
        lua_pop(L, 1);
        return v;
    }
    void shot(const char* name) { write_png(out + "/" + name, engine->target().color); }
};

static size_t distinct_colors(const Image& img) {
    std::set<uint32_t> colors;
    for (size_t i = 0; i < img.pixels.size() && colors.size() < 1000; i += 4 * 7)
        colors.insert(uint32_t(img.pixels[i]) | uint32_t(img.pixels[i + 1]) << 8 | uint32_t(img.pixels[i + 2]) << 16);
    return colors.size();
}

int main(int argc, char** argv) {
    if (argc < 3) { std::fprintf(stderr, "usage: unify_playtest <assets-dir> <out-dir>\n"); return 2; }
    set_log_level(LogLevel::Warn);
    Game g;
    g.out = argv[2];
    EngineConfig cfg;
    cfg.asset_root = argv[1];
    const bool snapkitty = argc > 3 && std::string(argv[3]) == "--snapkitty";
    if (snapkitty) cfg.boot_scene = "snapkitty";
    cfg.seed = 2026;
    g.engine = std::make_unique<Engine>(g.backend, cfg);
    Engine& e = *g.engine;
    std::printf("UNIFY definition-of-done playtest (headless backend)\n");

    // BOOT
    std::string err;
    bool booted = e.boot(err);
    check(booted, "BOOT", booted ? std::string(Engine::kEngineVersion) + " on " + e.backend().name() : err);
    if (!booted) return 1;

    // LOAD ASSETS (title scene: script, vadd kernel source, streamed music header)
    g.run(30);
    check(e.assets().live() >= 3, "LOAD ASSETS", std::to_string(e.assets().live()) + " assets resident, " + std::to_string(e.assets().resident_bytes()) + " bytes");
    if (!snapkitty) {
    bool vadd_ok = false;
    for (auto& l : e.log_lines()) if (l.find("vadd kernel: OK") != std::string::npos) vadd_ok = true;
    check(snapkitty || (vadd_ok && g.num("scene.state().kernel_ok and 1 or 0") == 1), "EXECUTE WASM KERNEL",
          "title: C[i]=A[i]+B[i] verified in Lua after " + std::to_string(int(g.num("scene.state().kernel_instructions"))) + " WASM instructions");
    } else {
        check(e.assets().find("textures/snapkitty/kitten").bits != 0, "SNAPKITTY FRONTEND", "original kitten texture loaded");
    }
    g.shot("01_title.png");

    // CREATE WORLD
    g.tap(key::Enter);
    g.run(2);
    bool in_level = e.active_scene() && e.active_scene()->name == (snapkitty ? "snapkitty_level" : "level");
    check(in_level && e.world().alive() > 150 && e.physics().body_count() > 20, "CREATE WORLD",
          "scene '" + std::string(e.active_scene() ? e.active_scene()->name : "?") + "': " + std::to_string(e.world().alive()) + " entities, " +
          std::to_string(e.physics().body_count()) + " bodies");

    // RUN PHYSICS: the player drops onto the ground and comes to rest.
    g.run(60);
    double py0 = g.num("select(2, scene.state().player:position())");
    check(g.num("scene.state().grounded and 1 or 0") == 1 && e.physics().stats.touching > 0, "RUN PHYSICS",
          "player resting at y=" + std::to_string(py0) + ", " + std::to_string(e.physics().stats.touching) + " touching contacts");
    g.shot("02_level_start.png");

    // ACCEPT INPUT + RUN GAMEPLAY: hold right.
    double x0 = g.num("scene.state().player:position()");
    g.press(key::Right);
    g.run(20);
    double x1 = g.num("scene.state().player:position()");
    std::string anim_run = [&] { lua_State* L = e.lua().L(); e.lua().run("return (scene.state().player:animation())", "probe", 1); std::string s = lua_tostring(L, -1) ? lua_tostring(L, -1) : ""; lua_pop(L, 1); return s; }();
    check(x1 > x0 + 1.0, "ACCEPT INPUT", "held RIGHT for 20 ticks: x " + std::to_string(x0) + " -> " + std::to_string(x1));

    // RUN LUA: gameplay counters live in Lua state and are driven by the controller script.
    check(g.num("scene.state().time") > 1.0 && e.lua().bytes() > 0, "RUN LUA",
          "scene time " + std::to_string(g.num("scene.state().time")) + "s, Lua heap " + std::to_string(e.lua().bytes() / 1024) + " KB (separate budget)");

    // ANIMATE: running clip selected from simulation state.
    check(anim_run == "player_run", "ANIMATE", "player clip while moving: " + anim_run);

    // Tap JUMP between two ticks: the press and release both land inside one tick's window
    // and must still produce a jump (input buffering).
    uint64_t jump_ns = e.next_tick_input_ns() + 2'000'000;
    e.input().push({jump_ns, InputDevice::Keyboard, InputEventType::ButtonDown, key::Space, 0, 0});
    e.input().push({jump_ns + 3'000'000, InputDevice::Keyboard, InputEventType::ButtonUp, key::Space, 0, 0});
    uint32_t voices_before = e.mixer().active_voices();
    g.run(2);
    double vy = g.num("select(2, scene.state().player:velocity())");
    check(g.num("scene.state().jumps") == 1 && vy > 0, "INPUT BUFFER", "3 ms tap between ticks -> jump, vy=" + std::to_string(vy));
    std::string anim_jump = [&] { lua_State* L = e.lua().L(); e.lua().run("return (scene.state().player:animation())", "probe", 1); std::string s = lua_tostring(L, -1) ? lua_tostring(L, -1) : ""; lua_pop(L, 1); return s; }();
    check(anim_jump == "player_jump", "ANIMATE", "jump clip with squash/stretch track: " + anim_jump);

    // PLAY AUDIO: jump SFX voice + streamed music on the audio device thread.
    uint64_t audio_frames = e.mixer().frames_rendered();
    check(e.mixer().active_voices() >= voices_before && e.mixer().active_voices() >= 2 && audio_frames > 0, "PLAY AUDIO",
          std::to_string(e.mixer().active_voices()) + " voices (music stream + SFX), audio clock " + std::to_string(e.mixer().audio_time()) + "s");
    g.shot("03_jump.png");

    // COLLIDE: keep running right, hopping every 40 ticks, until a coin is collected.
    int ticks = 0;
    while (g.num("scene.state().coins") < 1 && ticks < 900) {
        if (ticks % 40 == 0) g.press(key::Space);
        if (ticks % 40 == 12) g.release(key::Space);
        g.frame();
        ticks++;
    }
    check(g.num("scene.state().coins") >= 1 && g.num("scene.state().collisions") > 0, "COLLIDE",
          "coin trigger after " + std::to_string(ticks) + " ticks; " + std::to_string(int(g.num("scene.state().collisions"))) + " contact-begin events");
    g.run(3);
    check(g.num("scene.state().kernel_runs") > 0 && g.num("scene.state().particles_alive") > 0, "EXECUTE WASM KERNEL",
          "particles kernel dispatched " + std::to_string(int(g.num("scene.state().kernel_runs"))) + "x, " +
          std::to_string(int(g.num("scene.state().particles_alive"))) + " live particles");
    g.shot("04_coin_particles.png");

    // RENDER
    const Image& frame = g.backend.last_frame();
    check(g.backend.presented() == g.frames && distinct_colors(frame) > 20 && e.renderer().stats().batches > 0, "RENDER",
          std::to_string(e.renderer().stats().quads) + " sprites in " + std::to_string(e.renderer().stats().batches) + " batches, " +
          std::to_string(distinct_colors(frame)) + "+ colours, " + std::to_string(g.backend.presented()) + " frames presented");

    // SAVE (through the gameplay path: F5 in Lua -> save_state() at tick end)
    g.tap(key::F5);
    std::vector<uint8_t> quick = e.quick_slot();
    std::string saved_msg = [&] { lua_State* L = e.lua().L(); e.lua().run("return scene.state().msg", "probe", 1); std::string s = lua_tostring(L, -1) ? lua_tostring(L, -1) : ""; lua_pop(L, 1); return s; }();
    // Reference hash: a snapshot taken at the same tick as the quick save.
    std::vector<uint8_t> snap = e.snapshot();
    uint64_t h_save = e.state_hash();
    double score_saved = g.num("scene.state().score"), x_saved = g.num("scene.state().player:position()");
    check(!quick.empty() && saved_msg == "STATE SAVED", "SAVE", std::to_string(snap.size()) + "-byte save state at tick " + std::to_string(e.sim_clock().tick) +
          ", hash " + std::to_string(h_save));

    // CONTINUE: play on, recording the exact per-tick input the simulation consumed.
    e.input().set_recording(true);
    g.press(key::Left);
    for (int i = 0; i < 90; i++) {
        if (i == 20) g.press(key::Space);
        if (i == 30) g.release(key::Space);
        g.frame();
    }
    g.release(key::Left);
    g.frame();
    std::vector<InputFrame> recorded = e.input().recorded();
    e.input().set_recording(false);
    uint64_t h_continue = e.state_hash();
    double x_continue = g.num("scene.state().player:position()");
    check(h_continue != h_save && x_continue != x_saved, "CONTINUE", std::to_string(recorded.size()) + " more ticks; player moved to x=" + std::to_string(x_continue));

    // RESTORE
    bool restored = e.restore(snap, err);
    uint64_t h_restored = e.state_hash();
    check(restored && h_restored == h_save && g.num("scene.state().score") == score_saved && g.num("scene.state().player:position()") == x_saved,
          "RESTORE", restored ? "hash after restore " + std::to_string(h_restored) + (h_restored == h_save ? " == saved" : " != saved") : err);

    // VERIFY STATE: replaying the recorded input from the restored state reproduces the
    // continued run bit-for-bit (deterministic simulation; the rollback/lockstep property).
    for (const InputFrame& f : recorded) e.replay_tick(f);
    uint64_t h_replayed = e.state_hash();
    check(h_replayed == h_continue, "VERIFY STATE", "replayed " + std::to_string(recorded.size()) + " recorded input frames: hash " + std::to_string(h_replayed) +
          (h_replayed == h_continue ? " == continued run" : " != continued run " + std::to_string(h_continue)));

    // Save file round trip through disk (F5 also wrote slot1).
    std::vector<uint8_t> file;
    bool have_file = read_file(e.assets().path_for(AssetType::SaveData, "saves/slot1"), file);
    check(have_file && file.size() > 1000, "SAVE (file)", "saves/slot1.usav " + std::to_string(file.size()) + " bytes, written atomically");

    // CONTINUE PLAYING after the restore, through the normal real-time path.
    g.press(key::Right);
    g.run(120);
    g.release(key::Right);
    g.run(30);
    int errors = 0;
    for (auto& l : e.log_lines()) if (l.rfind("ERROR", 0) == 0) errors++;
    check(errors == 0 && e.physics().stats.failures == 0, "CONTINUE PLAYING",
          "tick " + std::to_string(e.sim_clock().tick) + ", " + std::to_string(errors) + " script errors, " + std::to_string(e.physics().stats.failures) + " physics failures");
    g.shot("05_after_restore.png");

    // Scheduler order for one real-time frame.
    std::string order;
    for (Stage s : e.trace()) { if (!order.empty()) order += " > "; order += stage_name(s); }
    std::printf("\n  scheduler: %s\n", order.c_str());
    std::printf("  heap allocations in last tick: %llu, Lua heap %zu KB (peak %zu KB)\n",
                static_cast<unsigned long long>(e.stats().heap_allocs_last_tick), e.lua().bytes() / 1024, e.lua().peak_bytes() / 1024);

    std::printf("\n%s: %d failure(s)\n", failures ? "FAILED" : "ALL STAGES PASSED", failures);
    g.backend.shutdown();
    return failures ? 1 : 0;
}