From 58f2679deffb2cf001fcb88f030e004092305f2a Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 22 Apr 2026 01:41:12 -0400 Subject: [PATCH 1/9] Rollgoal: Gyro & Mirror Mode Fixes --- src/d/actor/d_a_mg_fshop.cpp | 20 ++++++++++---------- src/dusk/gyro.cpp | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/d/actor/d_a_mg_fshop.cpp b/src/d/actor/d_a_mg_fshop.cpp index 1c93648eac..99f39e410e 100644 --- a/src/d/actor/d_a_mg_fshop.cpp +++ b/src/d/actor/d_a_mg_fshop.cpp @@ -761,6 +761,11 @@ static void koro2_game(fshop_class* i_this) { sp5C.x = mDoCPd_c::getStickX3D(PAD_1); sp5C.y = 0.0f; sp5C.z = mDoCPd_c::getStickY(PAD_1); +#if TARGET_PC + if (dusk::getSettings().game.enableMirrorMode) { + sp5C.x = -sp5C.x; + } +#endif MtxPosition(&sp5C, &sp68); f32 reg_f31 = sp68.x; @@ -782,20 +787,15 @@ static void koro2_game(fshop_class* i_this) { reg_f30 = 0.0f; } + s16 gyro_ax = 0; + s16 gyro_az = 0; #if TARGET_PC if (dusk::getSettings().game.enableGyroRollgoal) { - s16 rg_add_x; - s16 rg_add_z; - dusk::gyro::rollgoalTableOffset(rg_add_x, rg_add_z); - s16 tgt_x = static_cast(reg_f30 * (-6000.0f + JREG_F(7))) + rg_add_x; - s16 tgt_z = static_cast(reg_f31 * (-6000.0f + JREG_F(8))) + rg_add_z; - cLib_addCalcAngleS2(&i_this->field_0x4020.x, tgt_x, 4, 0x200); - cLib_addCalcAngleS2(&i_this->field_0x4020.z, tgt_z, 4, 0x200); + dusk::gyro::rollgoalTableOffset(gyro_ax, gyro_az); } -#else - cLib_addCalcAngleS2(&i_this->field_0x4020.x, reg_f30 * (-6000.0f + JREG_F(7)), 4, 0x200); - cLib_addCalcAngleS2(&i_this->field_0x4020.z, reg_f31 * (-6000.0f + JREG_F(8)), 4, 0x200); #endif + cLib_addCalcAngleS2(&i_this->field_0x4020.x, reg_f30 * (-6000.0f + JREG_F(7)) + gyro_ax, 4, 0x200); + cLib_addCalcAngleS2(&i_this->field_0x4020.z, reg_f31 * (-6000.0f + JREG_F(8)) + gyro_az, 4, 0x200); } #if TARGET_PC if (i_this->field_0x4010 != 2) { diff --git a/src/dusk/gyro.cpp b/src/dusk/gyro.cpp index d390c9c8b4..5b6e880a1f 100644 --- a/src/dusk/gyro.cpp +++ b/src/dusk/gyro.cpp @@ -3,7 +3,7 @@ namespace dusk::gyro { namespace { -constexpr s32 kRollgoalTableMaxOffset = 12000; +constexpr s32 kRollgoalTableMaxOffset = 6500; constexpr float kGyroEmaAlphaMin = 0.05f; constexpr float kGyroEmaAlphaMax = 1.0f; From a2a56122e27062cd0c37f17b8ad20be44f33f8e2 Mon Sep 17 00:00:00 2001 From: Irastris Date: Wed, 22 Apr 2026 01:46:10 -0400 Subject: [PATCH 2/9] Gyro: Hawk Aiming --- src/d/actor/d_a_alink_dusk.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/d/actor/d_a_alink_dusk.cpp b/src/d/actor/d_a_alink_dusk.cpp index 045a9655bd..0f442aff94 100644 --- a/src/d/actor/d_a_alink_dusk.cpp +++ b/src/d/actor/d_a_alink_dusk.cpp @@ -154,6 +154,7 @@ bool daAlink_c::checkGyroAimContext() { case PROC_BOW_SUBJECT: case PROC_BOOMERANG_SUBJECT: case PROC_COPY_ROD_SUBJECT: + case PROC_HAWK_SUBJECT: case PROC_HOOKSHOT_SUBJECT: case PROC_SWIM_HOOKSHOT_SUBJECT: case PROC_HORSE_BOW_SUBJECT: From 6f34bb050ab1d636177ef08df487dcd5fcfc5a17 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 22 Apr 2026 00:14:41 -0600 Subject: [PATCH 3/9] Call J3DModel::diff in mDoExt_modelEntryDL when interpolating Fixes #355 --- src/m_Do/m_Do_ext.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/m_Do/m_Do_ext.cpp b/src/m_Do/m_Do_ext.cpp index 84bf51ecf5..fc8952e91d 100644 --- a/src/m_Do/m_Do_ext.cpp +++ b/src/m_Do/m_Do_ext.cpp @@ -351,8 +351,13 @@ void mDoExt_modelUpdateDL(J3DModel* i_model) { void mDoExt_modelEntryDL(J3DModel* i_model) { #if TARGET_PC - if (!dusk::frame_interp::is_sim_frame()) + if (!dusk::frame_interp::is_sim_frame()) { + // FRAME INTERP NOTE: This fixes issue #355 where some lights would flicker. + // This is likely better solved by updating J3DMaterial::needsInterpCallBack, + // but it's unclear what exactly needs to be added. + i_model->diff(); return; + } #endif modelMtxErrorCheck(i_model); From 319efbe66296a697cb2ef9959a708f5a64af8d39 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 22 Apr 2026 00:30:11 -0600 Subject: [PATCH 4/9] Reset game clock with over 250ms frame gap --- src/dusk/game_clock.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/dusk/game_clock.cpp b/src/dusk/game_clock.cpp index 29bd699c7d..8b887f610c 100644 --- a/src/dusk/game_clock.cpp +++ b/src/dusk/game_clock.cpp @@ -17,6 +17,7 @@ std::unordered_map s_interval_last_sample; constexpr clock::duration kSimPeriodDuration = std::chrono::duration_cast(std::chrono::duration(sim_pace())); +constexpr clock::duration kAbnormalGapResetThreshold = std::chrono::milliseconds(250); constexpr int kMaxSimTicksPerFrame = 2; void ensure_initialized() { @@ -30,14 +31,15 @@ void ensure_initialized() { void reset_frame_timer() { s_previous_sample = clock::now(); - s_current_snapshot_time = s_previous_sample; + s_current_snapshot_time = s_previous_sample - kSimPeriodDuration; } MainLoopPacer advance_main_loop() { ensure_initialized(); const clock::time_point now = clock::now(); - const float presentation_dt = std::chrono::duration(now - s_previous_sample).count(); + const clock::duration frame_gap = now - s_previous_sample; + const float presentation_dt = std::chrono::duration(frame_gap).count(); s_previous_sample = now; MainLoopPacer out{}; @@ -54,6 +56,12 @@ MainLoopPacer advance_main_loop() { return out; } + if (frame_gap > kAbnormalGapResetThreshold) { + s_current_snapshot_time = now - kSimPeriodDuration; + out.sim_ticks_to_run = 0; + return out; + } + int sim_ticks_to_run = 0; clock::time_point projected_snapshot_time = s_current_snapshot_time; const clock::time_point render_time = now - kSimPeriodDuration; From 832e567620aa6a2ab7cb5a181b9347b5ffbe1fc7 Mon Sep 17 00:00:00 2001 From: madeline Date: Wed, 22 Apr 2026 01:50:17 -0700 Subject: [PATCH 5/9] better share states --- src/dusk/imgui/ImGuiStateShare.cpp | 224 ++++++++++++++++++++++++++--- src/dusk/imgui/ImGuiStateShare.hpp | 35 +++-- 2 files changed, 226 insertions(+), 33 deletions(-) diff --git a/src/dusk/imgui/ImGuiStateShare.cpp b/src/dusk/imgui/ImGuiStateShare.cpp index 172aad17a5..8d3af0bedb 100644 --- a/src/dusk/imgui/ImGuiStateShare.cpp +++ b/src/dusk/imgui/ImGuiStateShare.cpp @@ -5,14 +5,18 @@ #include "imgui.h" #include "fmt/format.h" #include "absl/strings/escaping.h" +#include "nlohmann/json.hpp" #include "d/d_com_inf_game.h" #include "dusk/main.h" +#include "dusk/io.hpp" #include namespace dusk { +using json = nlohmann::json; + #pragma pack(push, 1) struct StateSharePacket { char stageName[8]; @@ -24,8 +28,52 @@ struct StateSharePacket { #pragma pack(pop) static constexpr size_t PACKET_TOTAL = sizeof(StateSharePacket) + sizeof(dSv_info_c); +static constexpr auto STATES_FILENAME = "states.json"; -void ImGuiStateShare::copyState() { +static std::string GetStatesFilePath() { + return (dusk::ConfigPath / STATES_FILENAME).string(); +} + +void ImGuiStateShare::loadStatesFile() { + m_loaded = true; + const std::filesystem::path filePath = dusk::ConfigPath / STATES_FILENAME; + if (!std::filesystem::exists(filePath)) { + return; + } + try { + const std::string pathStr = filePath.string(); + auto data = io::FileStream::ReadAllBytes(pathStr.c_str()); + auto j = json::parse(data); + if (!j.is_array()) { + return; + } + for (const auto& entry : j) { + if (!entry.contains("name") || !entry.contains("data")) { + continue; + } + SavedStateEntry s; + s.name = entry["name"].get(); + s.encoded = entry["data"].get(); + m_states.push_back(std::move(s)); + } + } catch (const std::exception& e) { + m_statusMsg = fmt::format("Failed to load states: {}", e.what()); + } +} + +void ImGuiStateShare::saveStatesFile() { + json j = json::array(); + for (const auto& s : m_states) { + j.push_back({{"name", s.name}, {"data", s.encoded}}); + } + try { + io::FileStream::WriteAllText(GetStatesFilePath().c_str(), j.dump(2)); + } catch (const std::exception& e) { + m_statusMsg = fmt::format("Failed to save states: {}", e.what()); + } +} + +std::string ImGuiStateShare::encodeCurrentState() { StateSharePacket pkt = {}; strncpy(pkt.stageName, dComIfGp_getStartStageName(), 7); pkt.roomNo = dComIfGp_getStartStageRoomNo(); @@ -40,20 +88,12 @@ void ImGuiStateShare::copyState() { std::string compressed(bound, '\0'); compressed.resize(ZSTD_compress(compressed.data(), bound, raw.data(), raw.size(), 1)); - std::string encoded = absl::Base64Escape(compressed); - ImGui::SetClipboardText(encoded.c_str()); - m_statusMsg = "Copied to clipboard."; + return absl::Base64Escape(compressed); } -bool ImGuiStateShare::pasteState() { - const char* clip = ImGui::GetClipboardText(); - if (!clip || clip[0] == '\0') { - m_statusMsg = "Clipboard is empty."; - return false; - } - +bool ImGuiStateShare::applyEncodedState(const std::string& encoded, const std::string& name) { std::string decoded; - if (!absl::Base64Unescape(clip, &decoded)) { + if (!absl::Base64Unescape(encoded, &decoded)) { m_statusMsg = "Invalid base64."; return false; } @@ -78,7 +118,6 @@ bool ImGuiStateShare::pasteState() { memcpy(&g_dComIfG_gameInfo.info, raw.data() + sizeof(pkt), sizeof(dSv_info_c)); s16 spawnPoint = pkt.startPoint == -4 ? -1 : pkt.startPoint; - if (spawnPoint == -1) { dComIfGs_setRestartRoomParam(pkt.roomNo & 0x3F); } @@ -86,34 +125,174 @@ bool ImGuiStateShare::pasteState() { dComIfGp_setNextStage(pkt.stageName, spawnPoint, pkt.roomNo, pkt.layer); m_pendingInfo = g_dComIfG_gameInfo.info; - m_statusMsg = fmt::format("Warping to {} room {} layer {}.", pkt.stageName, (int)pkt.roomNo, (int)pkt.layer); + if (name.empty()) { + m_statusMsg = fmt::format("{} room {} layer {}.", pkt.stageName, (int)pkt.roomNo, (int)pkt.layer); + } else { + m_statusMsg = fmt::format("{}: {} room {} layer {}.", name, pkt.stageName, (int)pkt.roomNo, (int)pkt.layer); + } return true; } void ImGuiStateShare::tickPendingApply() { - if (!m_pendingInfo.has_value() || dComIfGp_isEnableNextStage()) + if (!m_pendingInfo.has_value() || dComIfGp_isEnableNextStage()) { return; + } g_dComIfG_gameInfo.info = *m_pendingInfo; m_pendingInfo.reset(); } +static bool ValidateEncodedState(const std::string& encoded) { + std::string decoded; + if (!absl::Base64Unescape(encoded, &decoded)) { + return false; + } + unsigned long long dSize = ZSTD_getFrameContentSize(decoded.data(), decoded.size()); + return dSize != ZSTD_CONTENTSIZE_ERROR && dSize != ZSTD_CONTENTSIZE_UNKNOWN && dSize >= PACKET_TOTAL; +} + void ImGuiStateShare::draw(bool& open) { - if (dusk::IsGameLaunched) + if (dusk::IsGameLaunched) { tickPendingApply(); + } - if (!open) + if (!m_loaded) { + loadStatesFile(); + } + + if (!open) { return; + } + ImGui::SetNextWindowSizeConstraints(ImVec2(400, 0), ImVec2(FLT_MAX, FLT_MAX)); if (!ImGui::Begin("State Share", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav)) { ImGui::End(); return; } - if (!dusk::IsGameLaunched) ImGui::BeginDisabled(); - if (ImGui::Button("Copy State")) copyState(); + const bool gameRunning = dusk::IsGameLaunched; + + const float rowH = ImGui::GetTextLineHeightWithSpacing(); + const float listH = rowH * 8 + ImGui::GetStyle().FramePadding.y * 2; + ImGui::BeginChild("##states", ImVec2(0, listH), true); + + if (m_states.empty()) { + ImGui::TextDisabled("No saved states. Save or import one below."); + } + + int toDelete = -1; + for (int i = 0; i < (int)m_states.size(); ++i) { + ImGui::PushID(i); + + if (m_renamingIndex == i) { + ImGui::SetNextItemWidth(150); + bool done = ImGui::InputText("##rename", m_renameBuffer, sizeof(m_renameBuffer), + ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll); + if (done) { + if (m_renameBuffer[0] != '\0') { + m_states[i].name = m_renameBuffer; + } + m_renamingIndex = -1; + saveStatesFile(); + } else if (ImGui::IsItemDeactivated()) { + m_renamingIndex = -1; + } + } else { + ImGui::Selectable(m_states[i].name.c_str(), false, ImGuiSelectableFlags_None, ImVec2(150, 0)); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Double-click to rename"); + if (ImGui::IsMouseDoubleClicked(0)) { + m_renamingIndex = i; + strncpy(m_renameBuffer, m_states[i].name.c_str(), sizeof(m_renameBuffer) - 1); + m_renameBuffer[sizeof(m_renameBuffer) - 1] = '\0'; + ImGui::SetKeyboardFocusHere(-1); + } + } + } + + ImGui::SameLine(); + if (!gameRunning) { ImGui::BeginDisabled(); } + if (ImGui::Button("Load")) { + applyEncodedState(m_states[i].encoded, m_states[i].name); + } + if (!gameRunning) { ImGui::EndDisabled(); } + + ImGui::SameLine(); + if (ImGui::Button("Copy")) { + ImGui::SetClipboardText(m_states[i].encoded.c_str()); + m_statusMsg = fmt::format("'{}' copied to clipboard.", m_states[i].name); + } + + ImGui::SameLine(); + if (ImGui::Button("Del")) { + toDelete = i; + } + + ImGui::PopID(); + } + + if (toDelete >= 0) { + if (m_renamingIndex == toDelete) { m_renamingIndex = -1; } + m_states.erase(m_states.begin() + toDelete); + saveStatesFile(); + } + + ImGui::EndChild(); + + // Toolbar + if (!gameRunning) { ImGui::BeginDisabled(); } + if (ImGui::Button("Save Current")) { + SavedStateEntry entry; + entry.name = fmt::format("State {}", m_states.size() + 1); + entry.encoded = encodeCurrentState(); + m_states.push_back(std::move(entry)); + saveStatesFile(); + m_statusMsg = fmt::format("Saved as '{}'.", m_states.back().name); + } + if (!gameRunning) { ImGui::EndDisabled(); } + ImGui::SameLine(); - if (ImGui::Button("Import State")) pasteState(); - if (!dusk::IsGameLaunched) ImGui::EndDisabled(); + if (ImGui::Button("Import Clipboard")) { + const char* clip = ImGui::GetClipboardText(); + if (!clip || clip[0] == '\0') { + m_statusMsg = "Clipboard is empty."; + } else { + std::string clipStr = clip; + if (!ValidateEncodedState(clipStr)) { + m_statusMsg = "Clipboard does not contain a valid state."; + } else { + SavedStateEntry entry; + entry.name = fmt::format("Imported {}", m_states.size() + 1); + entry.encoded = std::move(clipStr); + m_states.push_back(std::move(entry)); + saveStatesFile(); + m_statusMsg = fmt::format("Imported as '{}'.", m_states.back().name); + } + } + } + + if (!m_states.empty()) { + ImGui::SameLine(); + if (ImGui::Button("Clear All")) { + ImGui::OpenPopup("##clearall"); + } + + if (ImGui::BeginPopup("##clearall")) { + ImGui::Text("Delete all saved states?"); + ImGui::Spacing(); + if (ImGui::Button("Yes, clear all")) { + m_states.clear(); + m_renamingIndex = -1; + saveStatesFile(); + m_statusMsg = "All states cleared."; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } if (!m_statusMsg.empty()) { ImGui::Spacing(); @@ -125,8 +304,9 @@ void ImGuiStateShare::draw(bool& open) { } void ImGuiMenuTools::ShowStateShare() { - if (!ImGuiConsole::CheckMenuViewToggle(ImGuiKey_F8, m_showStateShare)) + if (!ImGuiConsole::CheckMenuViewToggle(ImGuiKey_F8, m_showStateShare)) { return; + } m_stateShare.draw(m_showStateShare); } diff --git a/src/dusk/imgui/ImGuiStateShare.hpp b/src/dusk/imgui/ImGuiStateShare.hpp index a09cfd5963..6d319889b8 100644 --- a/src/dusk/imgui/ImGuiStateShare.hpp +++ b/src/dusk/imgui/ImGuiStateShare.hpp @@ -4,21 +4,34 @@ #include "d/d_save.h" #include #include +#include namespace dusk { - class ImGuiStateShare { - public: - void draw(bool& open); - private: - void copyState(); - bool pasteState(); - void tickPendingApply(); +struct SavedStateEntry { + std::string name; + std::string encoded; +}; + +class ImGuiStateShare { +public: + void draw(bool& open); + +private: + std::string encodeCurrentState(); + bool applyEncodedState(const std::string& encoded, const std::string& name = {}); + void tickPendingApply(); + void loadStatesFile(); + void saveStatesFile(); + + std::vector m_states; + std::string m_statusMsg; + std::optional m_pendingInfo; + int m_renamingIndex = -1; + char m_renameBuffer[128] = {}; + bool m_loaded = false; +}; - std::string m_statusMsg; - std::optional m_pendingInfo; - }; } #endif - \ No newline at end of file From 1787de517c8397c2c14312f5cd98c3aedb78cef9 Mon Sep 17 00:00:00 2001 From: madeline Date: Wed, 22 Apr 2026 01:55:23 -0700 Subject: [PATCH 6/9] properly set oxygen in share states --- src/dusk/imgui/ImGuiStateShare.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dusk/imgui/ImGuiStateShare.cpp b/src/dusk/imgui/ImGuiStateShare.cpp index 8d3af0bedb..f24b209b1d 100644 --- a/src/dusk/imgui/ImGuiStateShare.cpp +++ b/src/dusk/imgui/ImGuiStateShare.cpp @@ -139,6 +139,9 @@ void ImGuiStateShare::tickPendingApply() { } g_dComIfG_gameInfo.info = *m_pendingInfo; m_pendingInfo.reset(); + dComIfGp_offOxygenShowFlag(); + dComIfGp_setMaxOxygen(600); + dComIfGp_setOxygen(600); } static bool ValidateEncodedState(const std::string& encoded) { From 9c562ff740cd33fb246228caa27e820313cf124d Mon Sep 17 00:00:00 2001 From: madeline Date: Wed, 22 Apr 2026 02:44:58 -0700 Subject: [PATCH 7/9] state packs and partial states --- src/dusk/imgui/ImGuiStateShare.cpp | 124 ++++++++++++++++++++++++++--- src/dusk/imgui/ImGuiStateShare.hpp | 6 +- tools/saves_to_states_json.py | 58 ++++++++++++++ 3 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 tools/saves_to_states_json.py diff --git a/src/dusk/imgui/ImGuiStateShare.cpp b/src/dusk/imgui/ImGuiStateShare.cpp index f24b209b1d..cb3d3a561c 100644 --- a/src/dusk/imgui/ImGuiStateShare.cpp +++ b/src/dusk/imgui/ImGuiStateShare.cpp @@ -10,7 +10,11 @@ #include "d/d_com_inf_game.h" #include "dusk/main.h" #include "dusk/io.hpp" +#include "dusk/logging.h" +#include "../file_select.hpp" +#include "aurora/lib/window.hpp" +#include #include namespace dusk { @@ -27,9 +31,21 @@ struct StateSharePacket { }; #pragma pack(pop) -static constexpr size_t PACKET_TOTAL = sizeof(StateSharePacket) + sizeof(dSv_info_c); +static constexpr size_t PACKET_TOTAL = sizeof(StateSharePacket) + sizeof(dSv_info_c); +static constexpr size_t PACKET_SAVE_ONLY = sizeof(StateSharePacket) + sizeof(dSv_save_c); static constexpr auto STATES_FILENAME = "states.json"; +static bool ValidateEncodedState(const std::string&); + +void ImGuiStateShare::onMergeFileSelected(void* userdata, const char* path, const char* /*error*/) { + auto* self = static_cast(userdata); + if (path != nullptr) { + self->m_pendingMergePath = path; + } +} + + + static std::string GetStatesFilePath() { return (dusk::ConfigPath / STATES_FILENAME).string(); } @@ -64,7 +80,7 @@ void ImGuiStateShare::loadStatesFile() { void ImGuiStateShare::saveStatesFile() { json j = json::array(); for (const auto& s : m_states) { - j.push_back({{"name", s.name}, {"data", s.encoded}}); + j.push_back(json{{"name", s.name}, {"data", s.encoded}}); } try { io::FileStream::WriteAllText(GetStatesFilePath().c_str(), j.dump(2)); @@ -99,7 +115,14 @@ bool ImGuiStateShare::applyEncodedState(const std::string& encoded, const std::s } unsigned long long dSize = ZSTD_getFrameContentSize(decoded.data(), decoded.size()); - if (dSize == ZSTD_CONTENTSIZE_ERROR || dSize == ZSTD_CONTENTSIZE_UNKNOWN || dSize < PACKET_TOTAL) { + if (dSize == ZSTD_CONTENTSIZE_ERROR || dSize == ZSTD_CONTENTSIZE_UNKNOWN) { + m_statusMsg = "Not a valid state string."; + return false; + } + + const bool isFull = (dSize == PACKET_TOTAL); + const bool isPartial = (dSize == PACKET_SAVE_ONLY); + if (!isFull && !isPartial) { m_statusMsg = "Not a valid state string."; return false; } @@ -115,15 +138,27 @@ bool ImGuiStateShare::applyEncodedState(const std::string& encoded, const std::s memcpy(&pkt, raw.data(), sizeof(pkt)); pkt.stageName[7] = '\0'; - memcpy(&g_dComIfG_gameInfo.info, raw.data() + sizeof(pkt), sizeof(dSv_info_c)); + if (isFull) { + memcpy(&g_dComIfG_gameInfo.info, raw.data() + sizeof(pkt), sizeof(dSv_info_c)); + m_pendingInfo = g_dComIfG_gameInfo.info; + m_pendingSavedata.reset(); + } else { + memcpy(&g_dComIfG_gameInfo.info.mSavedata, raw.data() + sizeof(pkt), sizeof(dSv_save_c)); + m_pendingSavedata = g_dComIfG_gameInfo.info.mSavedata; + m_pendingInfo.reset(); + } s16 spawnPoint = pkt.startPoint == -4 ? -1 : pkt.startPoint; if (spawnPoint == -1) { dComIfGs_setRestartRoomParam(pkt.roomNo & 0x3F); } + DuskLog.info("StateShare: applying {} state - stage={} room={} layer={} point={} lastSceneMode={}", + isFull ? "full" : "partial", + pkt.stageName, (int)pkt.roomNo, (int)pkt.layer, (int)spawnPoint, + dComIfGs_getLastSceneMode()); + dComIfGp_setNextStage(pkt.stageName, spawnPoint, pkt.roomNo, pkt.layer); - m_pendingInfo = g_dComIfG_gameInfo.info; if (name.empty()) { m_statusMsg = fmt::format("{} room {} layer {}.", pkt.stageName, (int)pkt.roomNo, (int)pkt.layer); @@ -134,11 +169,21 @@ bool ImGuiStateShare::applyEncodedState(const std::string& encoded, const std::s } void ImGuiStateShare::tickPendingApply() { - if (!m_pendingInfo.has_value() || dComIfGp_isEnableNextStage()) { + if (!m_pendingInfo.has_value() && !m_pendingSavedata.has_value()) { return; } - g_dComIfG_gameInfo.info = *m_pendingInfo; - m_pendingInfo.reset(); + if (dComIfGp_isEnableNextStage()) { + return; + } + if (m_pendingInfo.has_value()) { + DuskLog.info("StateShare: tickPendingApply full - lastSceneMode={}", dComIfGs_getLastSceneMode()); + g_dComIfG_gameInfo.info = *m_pendingInfo; + m_pendingInfo.reset(); + } else { + DuskLog.info("StateShare: tickPendingApply partial - lastSceneMode={}", dComIfGs_getLastSceneMode()); + g_dComIfG_gameInfo.info.mSavedata = *m_pendingSavedata; + m_pendingSavedata.reset(); + } dComIfGp_offOxygenShowFlag(); dComIfGp_setMaxOxygen(600); dComIfGp_setOxygen(600); @@ -150,7 +195,55 @@ static bool ValidateEncodedState(const std::string& encoded) { return false; } unsigned long long dSize = ZSTD_getFrameContentSize(decoded.data(), decoded.size()); - return dSize != ZSTD_CONTENTSIZE_ERROR && dSize != ZSTD_CONTENTSIZE_UNKNOWN && dSize >= PACKET_TOTAL; + return dSize == PACKET_TOTAL || dSize == PACKET_SAVE_ONLY; +} + +void ImGuiStateShare::mergeFromFile(const std::string& path) { + try { + auto data = io::FileStream::ReadAllBytes(path.c_str()); + auto j = json::parse(data); + if (!j.is_array()) { + m_statusMsg = "File does not contain a JSON array."; + return; + } + + std::unordered_set existingNames; + for (const auto& s : m_states) { + existingNames.insert(s.name); + } + + int added = 0; + int skipped = 0; + for (const auto& entry : j) { + if (!entry.contains("name") || !entry.contains("data")) { + ++skipped; + continue; + } + const std::string name = entry["name"].get(); + const std::string encoded = entry["data"].get(); + if (!ValidateEncodedState(encoded)) { + ++skipped; + continue; + } + if (existingNames.count(name)) { + ++skipped; + continue; + } + SavedStateEntry s; + s.name = name; + s.encoded = encoded; + existingNames.insert(s.name); + m_states.push_back(std::move(s)); + ++added; + } + + if (added > 0) { + saveStatesFile(); + } + m_statusMsg = fmt::format("Merged: {} added, {} skipped.", added, skipped); + } catch (const std::exception& e) { + m_statusMsg = fmt::format("Failed to load file: {}", e.what()); + } } void ImGuiStateShare::draw(bool& open) { @@ -162,6 +255,11 @@ void ImGuiStateShare::draw(bool& open) { loadStatesFile(); } + if (!m_pendingMergePath.empty()) { + mergeFromFile(m_pendingMergePath); + m_pendingMergePath.clear(); + } + if (!open) { return; } @@ -243,7 +341,7 @@ void ImGuiStateShare::draw(bool& open) { // Toolbar if (!gameRunning) { ImGui::BeginDisabled(); } - if (ImGui::Button("Save Current")) { + if (ImGui::Button("Current")) { SavedStateEntry entry; entry.name = fmt::format("State {}", m_states.size() + 1); entry.encoded = encodeCurrentState(); @@ -273,6 +371,12 @@ void ImGuiStateShare::draw(bool& open) { } } + ImGui::SameLine(); + if (ImGui::Button("Load Pack")) { + static constexpr SDL_DialogFileFilter filter = {"State pack", "json"}; + ShowFileSelect(&onMergeFileSelected, this, aurora::window::get_sdl_window(), &filter, 1, nullptr, false); + } + if (!m_states.empty()) { ImGui::SameLine(); if (ImGui::Button("Clear All")) { diff --git a/src/dusk/imgui/ImGuiStateShare.hpp b/src/dusk/imgui/ImGuiStateShare.hpp index 6d319889b8..7739e3db3b 100644 --- a/src/dusk/imgui/ImGuiStateShare.hpp +++ b/src/dusk/imgui/ImGuiStateShare.hpp @@ -23,13 +23,17 @@ private: void tickPendingApply(); void loadStatesFile(); void saveStatesFile(); + void mergeFromFile(const std::string& path); + static void onMergeFileSelected(void* userdata, const char* path, const char* error); std::vector m_states; std::string m_statusMsg; - std::optional m_pendingInfo; + std::optional m_pendingInfo; + std::optional m_pendingSavedata; int m_renamingIndex = -1; char m_renameBuffer[128] = {}; bool m_loaded = false; + std::string m_pendingMergePath; }; } diff --git a/tools/saves_to_states_json.py b/tools/saves_to_states_json.py new file mode 100644 index 0000000000..90032cf9d6 --- /dev/null +++ b/tools/saves_to_states_json.py @@ -0,0 +1,58 @@ +""" +Convert a folder of TPGZ saves to a states.json + +Usage: + python saves_to_states_json.py path/to/saves [prefix] + +Requirements: + pip install zstandard +""" + +import base64 +import json +import struct +import sys +import zstandard +from pathlib import Path + +SAVE_C_SIZE = 0x958 + +PACKET_FORMAT = "<8sbbh" + +RETURN_PLACE_OFF = 0x058 +NAME_OFF = RETURN_PLACE_OFF + 0x00 +ROOM_OFF = RETURN_PLACE_OFF + 0x09 +SPAWN_POINT_OFF = RETURN_PLACE_OFF + 0x08 + +folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent +out_path = folder / "states.json" + +if len(sys.argv) > 2: + prefix = sys.argv[2] +else: + prefix = None + +cctx = zstandard.ZstdCompressor(level=1) +states = [] + +for bin_path in sorted(folder.glob("*.bin")): + raw = bin_path.read_bytes() + save_c = raw[:SAVE_C_SIZE] + if len(save_c) < SAVE_C_SIZE: + print(f" skip {bin_path.name}: too small ({len(save_c)} bytes)") + continue + + stage_name = save_c[NAME_OFF:NAME_OFF + 8] + room_no = struct.unpack_from("b", save_c, ROOM_OFF)[0] + spawn_point = struct.unpack_from("B", save_c, SPAWN_POINT_OFF)[0] + + pkt = struct.pack(PACKET_FORMAT, stage_name, room_no, -1, spawn_point) + payload = pkt + save_c + encoded = base64.b64encode(cctx.compress(payload)).decode("ascii") + + stage_str = stage_name.rstrip(b"\x00").decode("ascii", errors="replace") + print(f" {bin_path.stem:30s} stage={stage_str!r} room={room_no} point={spawn_point}") + states.append({"name": f"({prefix}) {bin_path.stem}" if prefix else bin_path.stem, "data": encoded}) + +out_path.write_text(json.dumps(states, indent=2)) +print(f"\nWrote {len(states)} states to {out_path}") From 42e8d9ab9d2d721a374f01134202f154d1396838 Mon Sep 17 00:00:00 2001 From: madeline Date: Wed, 22 Apr 2026 02:50:49 -0700 Subject: [PATCH 8/9] name fix --- src/dusk/imgui/ImGuiStateShare.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dusk/imgui/ImGuiStateShare.cpp b/src/dusk/imgui/ImGuiStateShare.cpp index cb3d3a561c..d2cb2deaaa 100644 --- a/src/dusk/imgui/ImGuiStateShare.cpp +++ b/src/dusk/imgui/ImGuiStateShare.cpp @@ -341,7 +341,7 @@ void ImGuiStateShare::draw(bool& open) { // Toolbar if (!gameRunning) { ImGui::BeginDisabled(); } - if (ImGui::Button("Current")) { + if (ImGui::Button("Save")) { SavedStateEntry entry; entry.name = fmt::format("State {}", m_states.size() + 1); entry.encoded = encodeCurrentState(); From c4d01b82a6d77be93e830a75909e5150971a73f9 Mon Sep 17 00:00:00 2001 From: madeline Date: Wed, 22 Apr 2026 03:16:13 -0700 Subject: [PATCH 9/9] get rid of old logs --- src/dusk/imgui/ImGuiStateShare.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/dusk/imgui/ImGuiStateShare.cpp b/src/dusk/imgui/ImGuiStateShare.cpp index d2cb2deaaa..9d88181b10 100644 --- a/src/dusk/imgui/ImGuiStateShare.cpp +++ b/src/dusk/imgui/ImGuiStateShare.cpp @@ -153,11 +153,6 @@ bool ImGuiStateShare::applyEncodedState(const std::string& encoded, const std::s dComIfGs_setRestartRoomParam(pkt.roomNo & 0x3F); } - DuskLog.info("StateShare: applying {} state - stage={} room={} layer={} point={} lastSceneMode={}", - isFull ? "full" : "partial", - pkt.stageName, (int)pkt.roomNo, (int)pkt.layer, (int)spawnPoint, - dComIfGs_getLastSceneMode()); - dComIfGp_setNextStage(pkt.stageName, spawnPoint, pkt.roomNo, pkt.layer); if (name.empty()) { @@ -176,11 +171,9 @@ void ImGuiStateShare::tickPendingApply() { return; } if (m_pendingInfo.has_value()) { - DuskLog.info("StateShare: tickPendingApply full - lastSceneMode={}", dComIfGs_getLastSceneMode()); g_dComIfG_gameInfo.info = *m_pendingInfo; m_pendingInfo.reset(); } else { - DuskLog.info("StateShare: tickPendingApply partial - lastSceneMode={}", dComIfGs_getLastSceneMode()); g_dComIfG_gameInfo.info.mSavedata = *m_pendingSavedata; m_pendingSavedata.reset(); }