From 15c3a3f3143a4a80828d6895e503f71fb6b5ea08 Mon Sep 17 00:00:00 2001 From: Rix Date: Wed, 7 Oct 2026 02:00:23 -0700 Subject: [PATCH] Play: send edits to the running game While the game runs, an edit that has settled for 0.3 s is exported into the play folder's next/, moved over the files the game loads, and reload.flag is bumped. psxsplash reloads the scene in place when the flag changes, keeping the player where they stand. A failed export leaves the running scene's files alone; edits stay in the document after Stop. --- src/editor/play.cpp | 31 ++++++++++++++++++++++++++ src/editor/play.hh | 9 ++++++++ src/editor/tests/editor_tests.cpp | 19 ++++++++++++++++ src/mockup/main_screen.cpp | 36 ++++++++++++++++++++++++++++--- src/mockup/main_screen.h | 6 ++++++ 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/editor/play.cpp b/src/editor/play.cpp index 49da957..89bbcb6 100644 --- a/src/editor/play.cpp +++ b/src/editor/play.cpp @@ -146,6 +146,7 @@ splash::ExportResult exportForPlay(const splash::Scene& scene, const fs::path& p } // Stale files from an earlier play must not be booted if this export fails. for (const char* ext : {".splashpack", ".vram", ".spu"}) fs::remove(dir / (std::string("scene_0") + ext), ec); + fs::remove(dir / "reload.flag", ec); splash::ExportOptions opt; opt.luaBytecode = true; try { @@ -168,6 +169,36 @@ bool parseRenderPeak(const std::string& line, RenderPeak* out) { return true; } +bool writeReloadFlag(const fs::path& dir, unsigned generation) { + const fs::path tmp = dir / "reload.flag.tmp"; + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + out << generation; + if (!out) return false; + } + std::error_code ec; + fs::rename(tmp, dir / "reload.flag", ec); + return !ec; +} + +splash::ExportResult reloadForPlay(const splash::Scene& scene, const fs::path& projectRoot, const fs::path& dir, + unsigned generation) { + const fs::path next = dir / "next"; + std::error_code ec; + fs::remove_all(next, ec); + splash::ExportResult r = exportForPlay(scene, projectRoot, next); + if (!r.ok()) return r; + for (const fs::directory_entry& e : fs::directory_iterator(next, ec)) { + fs::rename(e.path(), dir / e.path().filename(), ec); + if (ec) { + r.errors.push_back("Cannot replace " + utf8(dir / e.path().filename()) + ": " + ec.message()); + return r; + } + } + if (!writeReloadFlag(dir, generation)) r.errors.push_back("Cannot write " + utf8(dir / "reload.flag")); + return r; +} + std::vector reduxCommand(const PlayTools& t, const fs::path& dir) { std::vector a = {utf8(t.redux), "-run", "-fastboot", "-no-ui", "-shmdisplay"}; if (!t.bios.empty()) { diff --git a/src/editor/play.hh b/src/editor/play.hh index bfd6d8b..73bcf0f 100644 --- a/src/editor/play.hh +++ b/src/editor/play.hh @@ -43,6 +43,15 @@ std::vector missingHardwareTools(const PlayTools& tools); splash::ExportResult exportForPlay(const splash::Scene& scene, const std::filesystem::path& projectRoot, const std::filesystem::path& dir); +// Live edits while the game runs. psxsplash re-reads dir/reload.flag every +// few frames when it existed at boot, and reloads the scene in place when its +// contents change. writeReloadFlag stores `generation` there; reloadForPlay +// exports into dir/next, moves the files over the ones the game loads, then +// bumps the flag. A failed export leaves the running scene's files alone. +bool writeReloadFlag(const std::filesystem::path& dir, unsigned generation); +splash::ExportResult reloadForPlay(const splash::Scene& scene, const std::filesystem::path& projectRoot, + const std::filesystem::path& dir, unsigned generation); + // What a development (PCdrv) psxsplash reports using, from its line // "psxsplash: render peak depth D of N, bump B of M". struct RenderPeak { diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index 1c95a3d..fcbe212 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -684,6 +684,25 @@ void testPlay() { CHECK(!b.ok()); CHECK(!fs::exists(dir / "scene_0.splashpack") && !fs::exists(dir / "scene_0.vram") && !fs::exists(dir / "scene_0.spu")); + // Live edits: a good export replaces the files and bumps the flag the game + // polls; a bad one leaves both as they were. Play writes the flag at 0. + auto flag = [&] { + std::ifstream in(dir / "reload.flag"); + std::string v; + std::getline(in, v); + return v; + }; + CHECK(editor::exportForPlay(scene, project, dir).ok() && !fs::exists(dir / "reload.flag")); + CHECK(editor::writeReloadFlag(dir, 0) && flag() == "0"); + splash::Scene moved = scene; + moved.objects.push_back(named("added during play")); + splash::ExportResult lr = editor::reloadForPlay(moved, project, dir, 3); + CHECK(lr.ok() && flag() == "3"); + CHECK(fs::file_size(dir / "scene_0.splashpack", ec) == lr.stats.splashpackBytes); + const auto packTime = fs::last_write_time(dir / "scene_0.splashpack", ec); + CHECK(!editor::reloadForPlay(bad, project, dir, 4).ok()); + CHECK(flag() == "3" && fs::exists(dir / "scene_0.vram") && fs::last_write_time(dir / "scene_0.splashpack", ec) == packTime); + // Settings: round trip (non-ASCII path), missing file, unknown keys. const fs::path cfg = g_outDir / "play" / "play.cfg"; editor::PlayTools t; diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index 9b29583..3ad97fe 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -110,6 +110,7 @@ static void togglePlay(State& st, const editor::Document& doc) { return; } if (p.build.valid()) return; + if (p.reload.valid()) p.reload.wait(); // a live edit still writing the folder p.message.clear(); p.peak.reset(); p.peakScene = doc.loadId(); @@ -118,8 +119,13 @@ static void togglePlay(State& st, const editor::Document& doc) { p.setupFor = 0; return; } + p.sentRev = p.seenRev = doc.revision(); + p.reloadGeneration = 0; p.build = std::async(std::launch::async, [scene = doc.scene(), root = doc.projectRoot()] { - return editor::exportForPlay(scene, root, playDir()); + splash::ExportResult r = editor::exportForPlay(scene, root, playDir()); + // Its presence at boot is what makes the game watch for live edits. + if (r.ok() && !editor::writeReloadFlag(playDir(), 0)) r.errors.push_back("Cannot write reload.flag"); + return r; }); } @@ -153,8 +159,32 @@ static void toggleRun(State& st, const editor::Document& doc) { // Ctrl+B and the Export button: ask where the disc image goes, then build it. static void startExport(State& st, const editor::Document& doc); -static void updatePlay(State& st) { +// Edits made while the game runs: once the document has stayed unchanged for +// a moment, export it again and tell the running game to reload the scene. +static void sendEdits(State& st, const editor::Document& doc) { State::Play& p = st.play; + const double now = ImGui::GetTime(); + if (p.reload.valid()) { + if (p.reload.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; + splash::ExportResult r = p.reload.get(); + p.message = r.ok() ? std::string() : "Live edit not sent: " + r.errors.front(); + } + if (!p.emu.running() || p.build.valid() || doc.loadId() != p.peakScene) return; + if (doc.revision() != p.seenRev) { + p.seenRev = doc.revision(); + p.seenAt = now; + } + if (doc.revision() == p.sentRev || now - p.seenAt < 0.3) return; + p.sentRev = doc.revision(); + const unsigned gen = ++p.reloadGeneration; + p.reload = std::async(std::launch::async, [scene = doc.scene(), root = doc.projectRoot(), gen] { + return editor::reloadForPlay(scene, root, playDir(), gen); + }); +} + +static void updatePlay(State& st, const editor::Document& doc) { + State::Play& p = st.play; + sendEdits(st, doc); if (p.hwBuild.valid() && p.hwBuild.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { splash::ExportResult r = p.hwBuild.get(); const editor::PlayTools eff = editor::withDefaults(p.tools, p.bundleDir); @@ -3597,7 +3627,7 @@ ImRect drawMainScreen(State& st, editor::Document& doc, viewport::Ps1View& view, filesPending(st, doc); shortcuts(st, doc); st.live.update(doc, ImGui::GetTime()); - updatePlay(st); + updatePlay(st, doc); updateExport(st); ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(size); diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index 5b93e11..e312cd8 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -87,6 +87,12 @@ struct State { std::optional peak; unsigned peakScene = 0; uint64_t linesSeen = 0; + // Live edits: the document revision the running game was last sent, + // the revision seen last frame and since when, the export in flight, + // and the count written to reload.flag. + std::future reload; + unsigned sentRev = 0, seenRev = 0, reloadGeneration = 0; + double seenAt = 0; } play; // F2 rename in the tree: the object's path, and whether the editor still has to open.