diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67050af..44b121a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,28 +69,28 @@ jobs: if-no-files-found: error windows: - name: Windows MSVC + name: Windows MinGW (MSVC also tested by native package job) runs-on: windows-latest steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Configure and build run: | - cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ cmake --build build --parallel 2 - name: Run tests and benchmark run: | ctest --test-dir build --output-on-failure New-Item -ItemType Directory -Force docs/reports/generated | Out-Null - ./build/forge2d_benchmark.exe | Tee-Object docs/reports/generated/benchmark-msvc.json + ./build/forge2d_benchmark.exe | Tee-Object docs/reports/generated/benchmark-mingw.json ./build/forge2d_arena.exe assets/arena.scene out/ci - name: Upload Windows evidence if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: windows-msvc-evidence + name: windows-mingw-evidence path: | - docs/reports/generated/benchmark-msvc.json + docs/reports/generated/benchmark-mingw.json out/ci if-no-files-found: error @@ -111,9 +111,9 @@ jobs: -DCMAKE_EXPORT_COMPILE_COMMANDS=ON - name: Analyze authored targets run: >- - clang-tidy -p build src/collision.cpp src/pathfinding.cpp src/replay.cpp src/scene.cpp + clang-tidy -p build src/editor.cpp src/collision.cpp src/pathfinding.cpp src/replay.cpp src/scene.cpp src/software_renderer.cpp src/world.cpp apps/forge2d_arena.cpp tests/test_main.cpp - benchmarks/benchmark.cpp + tests/test_editor.cpp benchmarks/benchmark.cpp coverage: name: Coverage gate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2ef3bcd..888fae4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,3 +33,24 @@ jobs: cmake --build build --parallel 2 - name: Analyze uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3; coordinated version + + analyze-windows: + name: Analyze native Windows C and C++ + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Initialize CodeQL + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3; coordinated version + with: + languages: c-cpp + build-mode: manual + - name: Build actual native and core targets + run: | + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug + cmake --build build --parallel 2 + - name: Analyze native Windows source + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3; coordinated version diff --git a/.github/workflows/native-app.yml b/.github/workflows/native-app.yml new file mode 100644 index 0000000..b5dda06 --- /dev/null +++ b/.github/workflows/native-app.yml @@ -0,0 +1,63 @@ +# File: native-app.yml +# Purpose: Build and extract-test the actual Windows editor; retain bounded proof, not routine full packages. +name: Native application +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + retain_package: + description: Retain the unsigned portable candidate for reviewed main-source distribution + type: boolean + required: false + default: false +permissions: + contents: read +concurrency: + group: native-${{ github.ref }} + cancel-in-progress: true +jobs: + windows: + name: Actual Windows editor and extracted portable package + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14.10" + check-latest: true + - name: Build static-runtime executable and actual core/file tests + shell: pwsh + run: | + $vs = & "${env:ProgramFiles(x86)}/Microsoft Visual Studio/Installer/vswhere.exe" -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs -or -not (Test-Path -LiteralPath "$vs/Common7/Tools/VsDevCmd.bat")) { throw 'Reviewed MSVC toolchain unavailable.' } + $command = '"' + $vs + '/Common7/Tools/VsDevCmd.bat" -arch=x64 -host_arch=x64 && pwsh -NoProfile -File scripts/build-native.ps1 -Output release/windows' + & cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0) { throw 'Native build/tests failed.' } + - name: Extract package and exercise native play, restart and corrupt-profile recovery + run: python scripts/smoke-native.py release/windows/Forge2DEditor-1.1.0-windows-x64.zip artifacts/native/hosted + - name: Retain bounded synthetic receipts and own-window screenshots + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: forge2d-native-proof + path: | + artifacts/native/hosted/*.json + artifacts/native/hosted/*.png + artifacts/native/hosted/*failure.txt + if-no-files-found: warn + retention-days: 1 + - name: Retain full unsigned package only on explicit trusted-main request + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.retain_package + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: forge2d-reviewed-portable-candidate + path: | + release/windows/*.zip + release/windows/*.sha256 + if-no-files-found: error + retention-days: 1 diff --git a/.gitignore b/.gitignore index 1fb9db1..c7c05b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # File: .gitignore # Purpose: Exclude build products, editor state, generated reports, and local runtime output. .cache/ +.runtime/ +artifacts/ +release/ .idea/ .vs/ .vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt index c416fe6..27e3b63 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ # Purpose: Configure the portable engine core, tests, benchmark, sample, and optional SDL3 frontend. cmake_minimum_required(VERSION 3.25) -project(forge2d_engine VERSION 1.0.0 LANGUAGES C CXX) +project(forge2d_engine VERSION 1.1.0 LANGUAGES C CXX) include(CTest) include(GNUInstallDirs) @@ -64,6 +64,7 @@ forge2d_enable_sanitizers(forge2d_platform_c) forge2d_enable_coverage(forge2d_platform_c) add_library(forge2d_core STATIC + src/editor.cpp src/collision.cpp src/pathfinding.cpp src/replay.cpp @@ -87,6 +88,38 @@ forge2d_set_warnings(forge2d_arena) forge2d_enable_sanitizers(forge2d_arena) forge2d_enable_coverage(forge2d_arena) +if(WIN32) + enable_language(RC) + set(FORGE2D_NATIVE_SOURCE "unavailable") + set(FORGE2D_NATIVE_DIRTY "true") + find_package(Git QUIET) + if(GIT_FOUND) + execute_process(COMMAND ${GIT_EXECUTABLE} -c safe.directory=${CMAKE_CURRENT_SOURCE_DIR} + rev-parse HEAD WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE native_source OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE native_git_result) + if(native_git_result EQUAL 0 AND native_source MATCHES "^[0-9a-f]+$") + set(FORGE2D_NATIVE_SOURCE "${native_source}") + execute_process(COMMAND ${GIT_EXECUTABLE} -c safe.directory=${CMAKE_CURRENT_SOURCE_DIR} + status --porcelain WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE native_status OUTPUT_STRIP_TRAILING_WHITESPACE) + if(native_status STREQUAL "") + set(FORGE2D_NATIVE_DIRTY "false") + endif() + endif() + endif() + add_executable(Forge2DEditor WIN32 apps/forge2d_editor.cpp apps/editor_files.cpp apps/editor.rc) + target_link_libraries(Forge2DEditor PRIVATE Forge2D::Core comdlg32 gdiplus bcrypt shell32 ole32) + target_compile_definitions(Forge2DEditor PRIVATE UNICODE _UNICODE + FORGE2D_NATIVE_SOURCE="${FORGE2D_NATIVE_SOURCE}" FORGE2D_NATIVE_DIRTY=${FORGE2D_NATIVE_DIRTY}) + if(MINGW) + target_link_options(Forge2DEditor PRIVATE -municode) + endif() + forge2d_set_warnings(Forge2DEditor) + add_custom_command(TARGET Forge2DEditor POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/assets $/assets) +endif() + if(FORGE2D_BUILD_SDL) include(FetchContent) set(SDL_SHARED OFF CACHE BOOL "" FORCE) @@ -113,6 +146,19 @@ if(FORGE2D_BUILD_TESTS) forge2d_enable_sanitizers(forge2d_tests) forge2d_enable_coverage(forge2d_tests) add_test(NAME forge2d_tests COMMAND forge2d_tests) + add_executable(forge2d_editor_tests tests/test_editor.cpp) + target_link_libraries(forge2d_editor_tests PRIVATE Forge2D::Core) + forge2d_set_warnings(forge2d_editor_tests) + forge2d_enable_sanitizers(forge2d_editor_tests) + forge2d_enable_coverage(forge2d_editor_tests) + add_test(NAME forge2d_editor_tests COMMAND forge2d_editor_tests) + if(WIN32) + add_executable(forge2d_editor_files_tests tests/test_editor_files.cpp apps/editor_files.cpp) + target_link_libraries(forge2d_editor_files_tests PRIVATE Forge2D::Core bcrypt shell32 ole32) + target_compile_definitions(forge2d_editor_files_tests PRIVATE UNICODE _UNICODE) + forge2d_set_warnings(forge2d_editor_files_tests) + add_test(NAME forge2d_editor_files_tests COMMAND forge2d_editor_files_tests) + endif() endif() if(FORGE2D_BUILD_BENCHMARKS) diff --git a/Dockerfile b/Dockerfile index 578ead4..790952b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update \ WORKDIR /source COPY . . RUN cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ - && cmake --build build --target forge2d_arena forge2d_tests forge2d_benchmark --parallel \ + && cmake --build build --target forge2d_arena forge2d_tests forge2d_editor_tests forge2d_benchmark --parallel \ && ctest --test-dir build --output-on-failure \ && ./build/forge2d_benchmark diff --git a/README.md b/README.md index 542bf2b..8cdaa63 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,23 @@ generation-safe entities, sparse-set components, fixed-timestep simulation, spat A* pathfinding, versioned scenes and replays, bounded allocation, software screenshot evidence, an optional SDL3 frontend, and regression budgets. +The **Windows native Forge2D Editor** adds an actual scene-edit/play workflow over that same C++ +engine. Open `Forge2DEditor.exe` from an extracted portable candidate: edit all strict scene fields, +Apply with validation, move the player with focused-canvas keys, Play/Pause/Step, inspect overlap +counts, save an authored project, import recorded inputs and export matching scene/replay/image/ +snapshot evidence. Prepared arena and collision-layer files are normal inputs inside the full app. +The application needs no IDE, compiler, Python, Node or network at runtime. + +Project saves live under the current OS account's local `Forge2DEditor` profile, outside the program +folder. Restart restores the authored scene **paused at tick zero**. Invalid Apply preserves the +previous world; closing warns about unapplied/unsaved edits; a second profile owner is refused. +This is a small, purpose-built editor, not a complete commercial game engine: masks detect AABB +overlaps without physical response, and imported replay files need their matching scene. +See [native usage, fields, limits and build](docs/native-editor.md), +[observed validation](docs/reports/native-editor-2026-11-10.md) and the +[native workflow](.github/workflows/native-app.yml). Created by **Jason Stys**; +[source/contact](https://github.com/JasonStys). + The project is intentionally smaller than a commercial engine. Its goal is to demonstrate memory ownership, data layout, deterministic behavior, parser safety, algorithmic trade-offs, portable build engineering, and honest performance evidence in a codebase a reviewer can understand. @@ -37,6 +54,7 @@ application responsibilities. - Four-neighbor A* with stable tie-breaking and explicit O((V+E) log V) analysis. - Versioned, bounded, strict scene/replay parsers plus a libFuzzer target. - Headless software rendering to portable PPM evidence and an optional SDL3 accelerated frontend. +- C++/Win32 native source editor, fixed-tick play/replay, durable local project and evidence exports. - MSVC, GCC, and Clang builds; warnings-as-errors; ASan/UBSan; fuzz smoke; CodeQL; coverage; packaging; and a non-root headless container. diff --git a/apps/editor.rc b/apps/editor.rc new file mode 100644 index 0000000..912368d --- /dev/null +++ b/apps/editor.rc @@ -0,0 +1,31 @@ +// File: editor.rc +// Purpose: Embed truthful Windows product/author metadata for the portable native editor. +#include +VS_VERSION_INFO VERSIONINFO + FILEVERSION 1,1,0,0 + PRODUCTVERSION 1,1,0,0 + FILEFLAGSMASK 0x3fL + FILEFLAGS 0x0L + FILEOS 0x40004L + FILETYPE 0x1L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Jason Stys\0" + VALUE "FileDescription", "Forge2D Editor\0" + VALUE "FileVersion", "1.1.0\0" + VALUE "InternalName", "Forge2DEditor\0" + VALUE "LegalCopyright", "Copyright Jason Stys. MIT license.\0" + VALUE "OriginalFilename", "Forge2DEditor.exe\0" + VALUE "ProductName", "Forge2D Editor\0" + VALUE "ProductVersion", "1.1.0\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/apps/editor_files.cpp b/apps/editor_files.cpp new file mode 100644 index 0000000..f49427d --- /dev/null +++ b/apps/editor_files.cpp @@ -0,0 +1,181 @@ +/** + * File: editor_files.cpp + * Purpose: Keep native project files outside the executable, bound actual reads and atomically + * save. Functions/variables: see docs/code-index.md. Same-OS-user access is trusted; no encryption + * is claimed. + */ +#include "editor_files.hpp" + +#include "forge2d/editor.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace forge2d::desktop { +namespace { +[[noreturn]] void file_error() { + throw std::runtime_error("File unavailable, unsafe, locked, too large, or not writable."); +} + +void regular(HANDLE handle) { + BY_HANDLE_FILE_INFORMATION info{}; + if (handle == INVALID_HANDLE_VALUE || GetFileType(handle) != FILE_TYPE_DISK || + !GetFileInformationByHandle(handle, &info) || info.nNumberOfLinks != 1U || + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0U) { + file_error(); + } +} + +[[nodiscard]] std::wstring random_suffix() { + std::array bytes{}; + if (BCryptGenRandom(nullptr, bytes.data(), static_cast(bytes.size()), + BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + file_error(); + } + constexpr wchar_t digits[] = L"0123456789abcdef"; + std::wstring result; + for (const auto byte : bytes) { + result += digits[byte >> 4U]; + result += digits[byte & 15U]; + } + return result; +} +} // namespace + +Handle::~Handle() { + if (value_ != INVALID_HANDLE_VALUE && value_ != nullptr) { + CloseHandle(value_); + } +} + +std::string read_text(const std::filesystem::path& path) { + const Handle file{CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr)}; + regular(file.get()); + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file.get(), &size) || size.QuadPart < 0 || + size.QuadPart > static_cast(EditorSession::maximum_input_bytes)) { + file_error(); + } + std::string text(static_cast(size.QuadPart), '\0'); + DWORD received{}; + if (!ReadFile(file.get(), text.data(), static_cast(text.size()), &received, nullptr) || + received != text.size()) { + file_error(); + } + char extra{}; + if (!ReadFile(file.get(), &extra, 1, &received, nullptr) || received != 0U) { + file_error(); + } + (void)wide(text); // Reject invalid UTF-8 before it reaches the native text control. + return text; +} + +void atomic_text(const std::filesystem::path& path, std::string_view text) { + if (text.size() > EditorSession::maximum_input_bytes) { + file_error(); + } + const auto attributes = GetFileAttributesW(path.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES) { + const Handle existing{CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr)}; + regular(existing.get()); + } + const auto staging = std::filesystem::path{path.wstring() + L".tmp-" + random_suffix()}; + try { + { + const Handle file{CreateFileW(staging.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, nullptr)}; + regular(file.get()); + DWORD written{}; + if (!WriteFile(file.get(), text.data(), static_cast(text.size()), &written, + nullptr) || + written != text.size() || !FlushFileBuffers(file.get())) { + file_error(); + } + } + if (!MoveFileExW(staging.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + file_error(); + } + } catch (...) { + DeleteFileW(staging.c_str()); + throw; + } +} + +std::filesystem::path executable_directory() { + std::vector path(32'768U); + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0U || length >= path.size()) { + file_error(); + } + return std::filesystem::path{std::wstring{path.data(), length}}.parent_path(); +} + +std::filesystem::path default_profile() { + PWSTR result{}; + if (SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, nullptr, &result) != S_OK) { + file_error(); + } + const std::filesystem::path path{result}; + CoTaskMemFree(result); + return path / L"Forge2DEditor"; +} + +void ensure_profile(const std::filesystem::path& path) { + std::filesystem::create_directories(path); + const auto attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0U) { + file_error(); + } +} + +std::wstring wide(std::string_view text) { + if (text.empty()) { + return {}; + } + if (text.size() > EditorSession::maximum_input_bytes) { + file_error(); + } + const auto length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), nullptr, 0); + if (length == 0) { + file_error(); + } + std::wstring result(static_cast(length), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), result.data(), length) != length) { + file_error(); + } + return result; +} + +std::string utf8(std::wstring_view text) { + if (text.empty()) { + return {}; + } + if (text.size() > EditorSession::maximum_input_bytes) { + file_error(); + } + const auto length = + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), nullptr, 0, nullptr, nullptr); + if (length == 0 || length > static_cast(EditorSession::maximum_input_bytes)) { + file_error(); + } + std::string result(static_cast(length), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), result.data(), length, nullptr, + nullptr) != length) { + file_error(); + } + return result; +} +} // namespace forge2d::desktop diff --git a/apps/editor_files.hpp b/apps/editor_files.hpp new file mode 100644 index 0000000..fdf4f94 --- /dev/null +++ b/apps/editor_files.hpp @@ -0,0 +1,41 @@ +/** + * File: editor_files.hpp + * Purpose: Declare Windows descriptor-bound scene reads, atomic saves and exclusive profile + * ownership. Symbols, functions and variable locations are listed in docs/code-index.md. + */ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include + +namespace forge2d::desktop { + +/** An owned OS handle is never copied or leaked to a child process. */ +class Handle { + public: + explicit Handle(HANDLE value = INVALID_HANDLE_VALUE) noexcept : value_(value) {} + ~Handle(); + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + [[nodiscard]] HANDLE get() const noexcept { + return value_; + } + + private: + HANDLE value_; +}; + +[[nodiscard]] std::string read_text(const std::filesystem::path& path); +void atomic_text(const std::filesystem::path& path, std::string_view text); +[[nodiscard]] std::filesystem::path executable_directory(); +[[nodiscard]] std::filesystem::path default_profile(); +void ensure_profile(const std::filesystem::path& path); +[[nodiscard]] std::wstring wide(std::string_view text); +[[nodiscard]] std::string utf8(std::wstring_view text); + +} // namespace forge2d::desktop diff --git a/apps/forge2d_editor.cpp b/apps/forge2d_editor.cpp new file mode 100644 index 0000000..2714034 --- /dev/null +++ b/apps/forge2d_editor.cpp @@ -0,0 +1,806 @@ +/** + * File: forge2d_editor.cpp + * Purpose: Provide the actual native scene editor, fixed-tick play/replay, durable saves and + * evidence. Functions/classes/important variables: docs/code-index.md. Keyboard input belongs only + * to this window's focused canvas; no global input hooks, network, accounts, plugins or external + * processes. + */ +#include "editor_files.hpp" + +#include "forge2d/editor.hpp" +#include "forge2d/fixed_step.hpp" + +// GDI+ requires the COM IStream/PROPID declarations before its own SDK headers. +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace forge2d::desktop { +namespace { +constexpr int apply_id = 101; +constexpr int play_id = 102; +constexpr int step_id = 103; +constexpr int reset_id = 104; +constexpr int save_id = 105; +constexpr int open_id = 106; +constexpr int replay_id = 107; +constexpr int export_id = 108; +constexpr int arena_id = 109; +constexpr int layers_id = 110; +constexpr int help_id = 111; +constexpr int quit_id = 112; +constexpr int text_id = 120; +constexpr UINT_PTR frame_timer = 1U; +constexpr UINT_PTR acceptance_timer = 2U; +constexpr wchar_t help_text[] = + L"Forge2D Editor 1.1.0 | Created by Jason Stys\n\n" + L"1. Open a scene or choose a prepared example. Edit the strict scene text; Apply validates " + L"all " + L"fields before replacing the running world. Invalid edits preserve the applied scene.\n" + L"2. Click the canvas. WASD/arrow keys control the player, Space records action. Play advances " + L"60 fixed ticks/second; Step advances once. Reset returns to the authored scene.\n" + L"3. Save project stores the applied scene in your local profile. Unapplied edits are not " + L"saved. " + L"Import replay plays recorded inputs against the currently applied scene. Export evidence " + L"writes that scene, its replay, a PPM image and numeric snapshot together.\n\n" + L"Scene row v2: entity NAME X Y VX VY HALF_W HALF_H RGBA LAYER PLAYER CATEGORY MASK. " + L"Integers only; category is one positive bit. Mask zero disables matching. Bounds must " + L"contain " + L"every body. Native limits: 1 MiB input, 1,000 entities, 36,000 frames at 60 Hz.\n\n" + L"AABB masks report candidate overlaps; they do not provide physical collision response. " + L"Unrecorded real-time scheduling drops excess ticks instead of accumulating lag. Exported " + L"replays require the matching scene. Local files are plaintext under your OS account; do not " + L"put secrets in scene names. Closing releases only this application's profile lock.\n\n" + L"Contact: github.com/JasonStys | Windows portable app; unsigned, no installer required."; + +/** Retrieve only this editor's text; size validation precedes allocation and UTF-8 conversion. */ +[[nodiscard]] std::string editor_text(HWND control) { + const auto count = GetWindowTextLengthW(control); + if (count < 0 || count > static_cast(EditorSession::maximum_input_bytes)) { + throw std::invalid_argument("Scene text exceeds the 1 MiB native editor limit."); + } + std::wstring text(static_cast(count) + 1U, L'\0'); + const auto received = GetWindowTextW(control, text.data(), count + 1); + text.resize(static_cast(received)); + auto encoded = utf8(text); + std::string portable; + portable.reserve(encoded.size()); + for (std::size_t index = 0; index < encoded.size(); ++index) { + if (encoded[index] == '\r' && index + 1U < encoded.size() && encoded[index + 1U] == '\n') { + continue; + } + portable += encoded[index]; + } + return portable; +} + +[[nodiscard]] std::filesystem::path choose_file(HWND owner, bool save, const wchar_t* filter) { + std::array path{}; + OPENFILENAMEW dialog{}; + dialog.lStructSize = sizeof(dialog); + dialog.hwndOwner = owner; + dialog.lpstrFilter = filter; + dialog.lpstrFile = path.data(); + dialog.nMaxFile = static_cast(path.size()); + dialog.Flags = + OFN_NOCHANGEDIR | OFN_PATHMUSTEXIST | (save ? OFN_OVERWRITEPROMPT : OFN_FILEMUSTEXIST); + dialog.lpstrDefExt = L"scene"; + if ((save ? GetSaveFileNameW(&dialog) : GetOpenFileNameW(&dialog)) == FALSE) { + return {}; + } + return std::filesystem::path{path.data()}; +} + +void require(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +/** Capture this application's owned visible window only, never the desktop or another process. */ +void screenshot(HWND window, const std::filesystem::path& destination) { + RECT bounds{}; + GetWindowRect(window, &bounds); + const auto width = bounds.right - bounds.left; + const auto height = bounds.bottom - bounds.top; + const auto screen = GetDC(window); + const auto memory = CreateCompatibleDC(screen); + const auto bitmap = CreateCompatibleBitmap(screen, width, height); + const auto previous = SelectObject(memory, bitmap); + const auto painted = PrintWindow(window, memory, 2U); + SelectObject(memory, previous); + DeleteDC(memory); + ReleaseDC(window, screen); + if (painted == FALSE) { + DeleteObject(bitmap); + throw std::runtime_error("Owned window screenshot was unavailable."); + } + Gdiplus::GdiplusStartupInput startup; + ULONG_PTR token{}; + require(Gdiplus::GdiplusStartup(&token, &startup, nullptr) == Gdiplus::Ok, + "Screenshot encoder unavailable."); + bool saved{}; + { + // The PNG encoder has a stable system CLSID; it does not load an application plugin. + const CLSID encoder{0x557cf406, 0x1a04, 0x11d3, {0x9a, 0x73, 0, 0, 0xf8, 0x1e, 0xf3, 0x2e}}; + Gdiplus::Bitmap image{bitmap, nullptr}; + saved = image.Save(destination.c_str(), &encoder, nullptr) == Gdiplus::Ok; + } + Gdiplus::GdiplusShutdown(token); + DeleteObject(bitmap); + require(saved, "Screenshot could not be saved."); +} + +/** Main-window state. Session state, disk profile and visible source buffer remain separate. */ +class Application { + public: + Application(std::filesystem::path profile, std::filesystem::path acceptance, int phase) + : profile_(std::move(profile)), acceptance_(std::move(acceptance)), phase_(phase), + owner_(CreateFileW((profile_ / L"owner.lock").c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)) { + require(owner_.get() != INVALID_HANDLE_VALUE, + "This profile is already open or unavailable."); + BY_HANDLE_FILE_INFORMATION info{}; + require(GetFileInformationByHandle(owner_.get(), &info) != FALSE && + info.nNumberOfLinks == 1U && + (info.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0U, + "Unsafe profile lock."); + const auto defaults = read_text(executable_directory() / L"assets" / L"arena.scene"); + session_ = std::make_unique(defaults); + const auto saved = profile_ / L"project.scene"; + if (std::filesystem::exists(saved)) { + try { + session_->apply_scene(read_text(saved)); + } catch (const std::exception&) { + load_error_ = + true; // Preserve corrupt input; save requires an explicit new decision. + } + } + } + + int run(HINSTANCE instance, int show) { + WNDCLASSW main_class{}; + main_class.hInstance = instance; + main_class.lpfnWndProc = procedure; + main_class.lpszClassName = L"Forge2DEditorWindow"; + main_class.hCursor = LoadCursorW(nullptr, IDC_ARROW); + main_class.hbrBackground = reinterpret_cast(COLOR_BTNFACE + 1); + require(RegisterClassW(&main_class) != 0U, "Cannot register editor window."); + WNDCLASSW canvas_class = main_class; + canvas_class.lpfnWndProc = canvas_procedure; + canvas_class.lpszClassName = L"Forge2DEditorCanvas"; + canvas_class.hbrBackground = nullptr; + require(RegisterClassW(&canvas_class) != 0U, "Cannot register canvas."); + window_ = CreateWindowExW(0, main_class.lpszClassName, L"Forge2D Editor 1.1.0 - Jason Stys", + WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, CW_USEDEFAULT, + CW_USEDEFAULT, 1240, 850, nullptr, nullptr, instance, this); + require(window_ != nullptr, "Cannot create editor window."); + ShowWindow(window_, show); + UpdateWindow(window_); + if (!acceptance_.empty()) { + SetTimer(window_, acceptance_timer, 200U, nullptr); + } + MSG message{}; + while (GetMessageW(&message, nullptr, 0, 0) > 0) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + return exit_code_; + } + + private: + static LRESULT CALLBACK procedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + auto* app = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + const auto* create = reinterpret_cast(lparam); + app = static_cast(create->lpCreateParams); + app->window_ = window; + SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(app)); + } + if (app != nullptr) { + try { + return app->message(message, wparam, lparam); + } catch (const std::exception& error) { + app->session_->pause(); + app->notice_ = wide(error.what()); + if (!app->acceptance_.empty()) { + app->exit_code_ = 2; + atomic_text(app->acceptance_ / L"failure.txt", error.what()); + DestroyWindow(window); + } else { + MessageBoxW(window, app->notice_.c_str(), L"Forge2D - input not applied", + MB_OK | MB_ICONWARNING); + app->update_status(); + } + return 0; + } + } + return DefWindowProcW(window, message, wparam, lparam); + } + + static LRESULT CALLBACK canvas_procedure(HWND window, UINT message, WPARAM wparam, + LPARAM lparam) { + auto* app = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + const auto* create = reinterpret_cast(lparam); + app = static_cast(create->lpCreateParams); + SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(app)); + } + if (app == nullptr) { + return DefWindowProcW(window, message, wparam, lparam); + } + if (message == WM_LBUTTONDOWN) { + SetFocus(window); + return 0; + } + if (message == WM_KILLFOCUS) { + app->keys_.fill(false); + } + if ((message == WM_KEYDOWN || message == WM_KEYUP) && wparam < app->keys_.size()) { + app->keys_[wparam] = message == WM_KEYDOWN; + return 0; + } + if (message == WM_PAINT) { + try { + app->paint_canvas(window); + } catch (...) { + app->session_->pause(); + } + return 0; + } + return DefWindowProcW(window, message, wparam, lparam); + } + + LRESULT message(UINT kind, WPARAM wparam, LPARAM lparam) { + switch (kind) { + case WM_CREATE: + create_controls(); + return 0; + case WM_SIZE: + layout(); + return 0; + case WM_GETMINMAXINFO: { + auto* size = reinterpret_cast(lparam); + size->ptMinTrackSize = POINT{900, 650}; + return 0; + } + case WM_COMMAND: + if (LOWORD(wparam) == text_id && HIWORD(wparam) == EN_CHANGE && !setting_text_) { + unapplied_ = true; + update_status(); + } else if (HIWORD(wparam) == 0U) { + command(LOWORD(wparam)); + } + return 0; + case WM_TIMER: + if (wparam == acceptance_timer) { + KillTimer(window_, acceptance_timer); + acceptance(); + } else if (wparam == frame_timer) { + const auto now = std::chrono::steady_clock::now(); + const auto ticks = clock_.consume(now - last_frame_); + last_frame_ = now; + const auto previous_tick = session_->world().tick(); + for (std::size_t tick = 0; tick < ticks; ++tick) { + session_->advance(input()); + } + if (session_->world().tick() != previous_tick) { + update_status(); + } + } + return 0; + case WM_CLOSE: + if (acceptance_.empty() && (unapplied_ || unsaved_) && + MessageBoxW(window_, L"Unapplied or unsaved edits will be discarded. Close?", + L"Forge2D - preserve your work", + MB_YESNO | MB_DEFBUTTON2 | MB_ICONWARNING) != IDYES) { + return 0; + } + DestroyWindow(window_); + return 0; + case WM_DESTROY: + KillTimer(window_, frame_timer); + PostQuitMessage(0); + return 0; + default: + return DefWindowProcW(window_, kind, wparam, lparam); + } + } + + HWND child(const wchar_t* type, const wchar_t* text, DWORD style, int id) { + const auto control = CreateWindowExW( + type == std::wstring_view{L"EDIT"} ? WS_EX_CLIENTEDGE : 0, type, text, + WS_CHILD | WS_VISIBLE | style, 0, 0, 1, 1, window_, + reinterpret_cast(static_cast(id)), GetModuleHandleW(nullptr), nullptr); + require(control != nullptr, "Cannot create editor control."); + SendMessageW(control, WM_SETFONT, + reinterpret_cast(GetStockObject(DEFAULT_GUI_FONT)), TRUE); + return control; + } + + void create_controls() { + const auto menu = CreateMenu(); + const auto file = CreatePopupMenu(); + AppendMenuW(file, MF_STRING, open_id, L"Open scene..."); + AppendMenuW(file, MF_STRING, save_id, L"Save applied project to profile"); + AppendMenuW(file, MF_STRING, replay_id, L"Import replay..."); + AppendMenuW(file, MF_STRING, export_id, L"Export evidence bundle..."); + AppendMenuW(file, MF_STRING, quit_id, L"Exit"); + AppendMenuW(menu, MF_POPUP, reinterpret_cast(file), L"File"); + const auto examples = CreatePopupMenu(); + AppendMenuW(examples, MF_STRING, arena_id, L"Prepared arena"); + AppendMenuW(examples, MF_STRING, layers_id, L"Collision layers"); + AppendMenuW(menu, MF_POPUP, reinterpret_cast(examples), L"Prepared data"); + AppendMenuW(menu, MF_STRING, help_id, L"Help / About"); + SetMenu(window_, menu); + const std::array, 6> buttons{ + {{apply_id, L"Apply scene"}, + {play_id, L"Play / Pause"}, + {step_id, L"Step"}, + {reset_id, L"Reset"}, + {save_id, L"Save project"}, + {export_id, L"Export evidence"}}}; + for (const auto& [id, label] : buttons) { + buttons_.push_back(child(L"BUTTON", label, WS_TABSTOP | BS_PUSHBUTTON, id)); + } + heading_ = + child(L"STATIC", L"Applied world | click here, then WASD / arrows / Space", 0, 0); + source_label_ = + child(L"STATIC", L"Scene source | edit, then Apply (all fields are editable)", 0, 0); + source_ = child(L"EDIT", L"", + ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | ES_AUTOHSCROLL | + WS_VSCROLL | WS_HSCROLL | WS_TABSTOP, + text_id); + SendMessageW(source_, EM_SETLIMITTEXT, EditorSession::maximum_input_bytes, 0); + SendMessageW(source_, WM_SETFONT, reinterpret_cast(GetStockObject(ANSI_FIXED_FONT)), + TRUE); + canvas_ = CreateWindowExW(WS_EX_CLIENTEDGE, L"Forge2DEditorCanvas", L"Actual engine canvas", + WS_CHILD | WS_VISIBLE | WS_TABSTOP, 0, 0, 1, 1, window_, nullptr, + GetModuleHandleW(nullptr), this); + require(canvas_ != nullptr, "Cannot create engine canvas."); + instructions_ = + child(L"STATIC", + L"Save keeps the applied authored scene; replay records actual tick inputs.\r\n" + L"Collision masks detect overlaps, not physical response. File > Export evidence " + L"includes the matching scene.\r\n" + L"Local OS profile, no network. Created by Jason Stys | github.com/JasonStys | " + L"Help / About explains fields.", + 0, 0); + status_ = child(L"STATIC", L"", SS_LEFT, 0); + set_source(session_->scene_text()); + if (load_error_) { + notice_ = L"Saved project invalid: preserved on disk; working from prepared scene. " + L"Export recovery before overwriting."; + } else if (!std::filesystem::exists(profile_ / L"project.scene")) { + notice_ = L"First use: choose prepared data, edit and Apply, click canvas, Play. Save " + L"project persists it."; + } + last_frame_ = std::chrono::steady_clock::now(); + SetTimer(window_, frame_timer, 16U, nullptr); + layout(); + update_status(); + } + + void layout() { + RECT bounds{}; + GetClientRect(window_, &bounds); + const auto width = std::max(880L, bounds.right); + const auto height = std::max(580L, bounds.bottom); + const auto split = width * 54 / 100; + for (std::size_t index = 0; index < buttons_.size(); ++index) { + MoveWindow(buttons_[index], 12 + static_cast(index) * 137, 10, 129, 32, TRUE); + } + MoveWindow(heading_, 12, 53, split - 24, 24, TRUE); + MoveWindow(source_label_, split + 8, 53, width - split - 20, 24, TRUE); + MoveWindow(canvas_, 12, 81, split - 24, height - 216, TRUE); + MoveWindow(source_, split + 8, 81, width - split - 20, height - 216, TRUE); + MoveWindow(status_, 12, height - 125, width - 24, 53, TRUE); + MoveWindow(instructions_, 12, height - 66, width - 24, 62, TRUE); + } + + void set_source(std::string_view text) { + // A native multiline EDIT requires CRLF; scene serialization deliberately remains portable + // LF. + std::string displayed; + displayed.reserve(text.size() + + static_cast(std::count(text.begin(), text.end(), '\n'))); + for (const auto character : text) { + if (character == '\r') { + continue; + } + if (character == '\n') { + displayed += '\r'; + } + displayed += character; + } + setting_text_ = true; + require(SetWindowTextW(source_, wide(displayed).c_str()) != FALSE, + "Cannot display scene source."); + setting_text_ = false; + unapplied_ = false; + } + + [[nodiscard]] InputFrame input() const { + const auto horizontal = static_cast(keys_[VK_RIGHT] || keys_['D']) - + static_cast(keys_[VK_LEFT] || keys_['A']); + const auto vertical = static_cast(keys_[VK_DOWN] || keys_['S']) - + static_cast(keys_[VK_UP] || keys_['W']); + return {static_cast(horizontal), static_cast(vertical), + keys_[VK_SPACE]}; + } + + void update_status() { + const auto& world = session_->world(); + std::wostringstream text; + text << (session_->playing() ? L"PLAYING" : L"PAUSED") << L" | tick " << world.tick() + << L" | entities " << world.entity_count() << L" | overlap pairs " + << world.collision_pairs().size() << L" | recorded " + << session_->recording().frames.size() << L"/36000" + << (session_->reviewing_replay() ? L" | imported replay" : L" | live input") + << (unapplied_ ? L" | UNAPPLIED EDITS" : L"") << (unsaved_ ? L" | UNSAVED" : L"") + << L" | dropped wall ticks " << clock_.dropped_ticks() << L"\r\n" + << notice_; + SetWindowTextW(status_, text.str().c_str()); + InvalidateRect(canvas_, nullptr, FALSE); + } + + void command(int id) { + notice_.clear(); + if (id == apply_id) { + session_->apply_scene(editor_text(source_)); + unapplied_ = false; + unsaved_ = true; + } else if (id == play_id) { + if (session_->playing()) { + session_->pause(); + } else { + session_->play(); + SetFocus(canvas_); + } + } else if (id == step_id) { + session_->pause(); + session_->step(input()); + } else if (id == reset_id) { + session_->reset(); + } else if (id == save_id) { + require(!unapplied_, "Apply or discard the pending source edits before saving."); + if (load_error_ && acceptance_.empty() && + MessageBoxW(window_, + L"The saved project was invalid and has been preserved. Replace it " + L"with the applied scene?", + L"Confirm project replacement", + MB_YESNO | MB_DEFBUTTON2 | MB_ICONWARNING) != IDYES) { + return; + } + atomic_text(profile_ / L"project.scene", session_->scene_text()); + unsaved_ = false; + load_error_ = false; + notice_ = L"Applied project saved in your OS-local profile. Running state is not " + L"auto-resumed."; + } else if (id == open_id || id == arena_id || id == layers_id) { + if ((unapplied_ || unsaved_) && acceptance_.empty() && + MessageBoxW( + window_, + L"Replace the editor buffer and applied scene, discarding unsaved edits?", + L"Open scene", MB_YESNO | MB_DEFBUTTON2 | MB_ICONWARNING) != IDYES) { + return; + } + auto path = + id == open_id + ? choose_file(window_, false, L"Forge2D scene\0*.scene\0All files\0*.*\0") + : executable_directory() / L"assets" / + (id == arena_id ? L"arena.scene" : L"collision-layers.scene"); + if (!path.empty()) { + session_->apply_scene(read_text(path)); + set_source(session_->scene_text()); + unsaved_ = true; + } + } else if (id == replay_id) { + const auto path = + choose_file(window_, false, L"Forge2D replay\0*.replay\0All files\0*.*\0"); + if (!path.empty()) { + session_->load_replay(read_text(path)); + notice_ = L"Replay loaded against the applied scene. Play or Step consumes its " + L"recorded inputs."; + } + } else if (id == export_id) { + const auto path = choose_file(window_, true, L"Evidence bundle base\0*.scene\0"); + if (!path.empty()) { + const auto directory = path.parent_path() / (path.stem().wstring() + L".evidence"); + export_bundle(directory); + notice_ = L"Evidence folder created; existing folders are never overwritten."; + } + } else if (id == help_id) { + MessageBoxW(window_, help_text, L"Forge2D - how this editor works", + MB_OK | MB_ICONINFORMATION); + } else if (id == quit_id) { + SendMessageW(window_, WM_CLOSE, 0, 0); + return; + } + update_status(); + } + + void export_bundle(const std::filesystem::path& path) const { + require(std::filesystem::create_directory(path), + "Choose a new evidence folder; existing exports are preserved."); + atomic_text(path / L"scene.scene", session_->scene_text()); + atomic_text(path / L"inputs.replay", session_->replay_text()); + const auto image = session_->image(640U, 640U); + image.write_ppm(path / L"frame.ppm"); + std::ostringstream csv; + csv << "entity_id,player,x,y,vx,vy,category,mask\n"; + for (const auto& entity : session_->world().snapshot()) { + csv << entity.entity.packed() << ',' << static_cast(entity.player) << ',' + << entity.transform.position.x << ',' << entity.transform.position.y << ',' + << entity.body.velocity.x << ',' << entity.body.velocity.y << ',' + << entity.body.filter.category << ',' << entity.body.filter.mask << '\n'; + } + atomic_text(path / L"snapshot.csv", csv.str()); + std::ostringstream report; + report << "{\"schema\":1,\"version\":\"" << FORGE2D_VERSION + << "\",\"tick\":" << session_->world().tick() << ",\"stateHash\":\"" + << session_->world().state_hash() << "\",\"pixelHash\":\"" << image.pixel_hash() + << "\",\"overlapPairs\":" << session_->world().collision_pairs().size() + << ",\"recordedFrames\":" << session_->recording().frames.size() + << ",\"author\":\"Jason Stys\"}\n"; + atomic_text(path / L"summary.json", report.str()); + } + + void paint_canvas(HWND canvas) { + PAINTSTRUCT paint{}; + const auto dc = BeginPaint(canvas, &paint); + RECT bounds{}; + GetClientRect(canvas, &bounds); + if (bounds.right > 0 && bounds.bottom > 0) { + const auto image = session_->image(640U, 640U); + std::vector pixels; + pixels.reserve(image.pixels().size()); + for (const auto rgba : image.pixels()) { + pixels.push_back(rgba >> 8U); + } + BITMAPINFO info{}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = 640; + info.bmiHeader.biHeight = -640; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + StretchDIBits(dc, 0, 0, bounds.right, bounds.bottom, 0, 0, 640, 640, pixels.data(), + &info, DIB_RGB_COLORS, SRCCOPY); + } + EndPaint(canvas, &paint); + } + + /** Event-dispatched synthetic acceptance exercises this same visible executable and handlers. + */ + void acceptance() { + require(IsWindowVisible(window_) != FALSE && IsWindowVisible(canvas_) != FALSE, + "Native editor window is not visible."); + if (phase_ == 1) { + if (acceptance_play_pending_) { + if (session_->playing() && GetTickCount64() - acceptance_play_started_ < 2'000U) { + SetTimer(window_, acceptance_timer, 16U, nullptr); + return; + } + require(!session_->playing() && session_->world().tick() == 3U && + session_->world().state_hash() == acceptance_replay_hash_, + "Native Play timer did not complete the three-frame imported replay."); + finish_acceptance(); + return; + } + command(arena_id); + require(SendMessageW(source_, EM_GETLINECOUNT, 0, 0) >= 10, + "Native source control did not display the actual multiline scene."); + const Handle duplicate{CreateFileW((profile_ / L"owner.lock").c_str(), GENERIC_READ, 0, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr)}; + require(duplicate.get() == INVALID_HANDLE_VALUE, + "Profile owner lock was not exclusive."); + auto authored = editor_text(source_); + const auto location = authored.find("750 750"); + require(location != std::string::npos, "Prepared scene coordinate missing."); + authored.replace(location, 7, "1100 900"); + SetWindowTextW(source_, wide(authored).c_str()); + SendMessageW(window_, WM_COMMAND, apply_id, 0); + SendMessageW(window_, WM_COMMAND, save_id, 0); + require(!unapplied_ && !unsaved_, + "Applied scene not saved through the actual handler."); + const auto saved = read_text(profile_ / L"project.scene"); + const auto before = session_->world().state_hash(); + SetWindowTextW(source_, L"FORGE2D_SCENE invalid"); + bool refused{}; + try { + command(apply_id); + } catch (const std::exception&) { + refused = true; + } + require(refused && session_->world().state_hash() == before && + read_text(profile_ / L"project.scene") == saved, + "Invalid scene changed applied world or saved file."); + set_source(saved); + SetFocus(canvas_); + SendMessageW(canvas_, WM_KEYDOWN, VK_RIGHT, 0); + for (int tick = 0; tick < 3; ++tick) { + SendMessageW(window_, WM_COMMAND, step_id, 0); + } + SendMessageW(canvas_, WM_KEYUP, VK_RIGHT, 0); + const auto snapshot = session_->world().snapshot(); + const auto player = std::find_if(snapshot.begin(), snapshot.end(), + [](const auto& entity) { return entity.player; }); + require(player != snapshot.end() && player->transform.position == Vec2i{1520, 900}, + "Actual engine did not execute the three captured right inputs."); + const auto hash = session_->world().state_hash(); + const auto replay = session_->replay_text(); + export_bundle(acceptance_ / L"evidence"); + const auto initial = World::from_scene( + parse_scene(read_text(acceptance_ / L"evidence" / L"scene.scene"))); + require(run_replay(initial, parse_replay(read_text(acceptance_ / L"evidence" / + L"inputs.replay"))) == hash, + "Exported scene/replay does not independently reproduce the state."); + session_->load_replay(replay); + acceptance_replay_hash_ = hash; + acceptance_play_pending_ = true; + acceptance_play_started_ = GetTickCount64(); + SendMessageW(window_, WM_COMMAND, play_id, 0); + SetTimer(window_, acceptance_timer, 16U, nullptr); + return; + } else if (phase_ == 2) { + require(!load_error_, "Saved project did not restore."); + const auto snapshot = session_->world().snapshot(); + const auto player = std::find_if(snapshot.begin(), snapshot.end(), + [](const auto& entity) { return entity.player; }); + require(player != snapshot.end() && player->transform.position == Vec2i{1100, 900} && + session_->world().tick() == 0U && !session_->playing(), + "Restart did not restore authored coordinates paused at tick zero."); + atomic_text(acceptance_ / L"phase-2.json", + "{\"schema\":1,\"version\":\"1.1.0\",\"restoredAuthoredScene\":true," + "\"tick\":0,\"playing\":false}\n"); + } else { + require(load_error_ && !session_->playing() && + read_text(profile_ / L"project.scene") == "invalid saved scene\n" && + notice_.find(L"preserved on disk") != std::wstring::npos, + "Corrupt saved project was reset or not visibly identified."); + atomic_text(acceptance_ / L"phase-3.json", + "{\"schema\":1,\"version\":\"1.1.0\",\"corruptProjectPreserved\":true," + "\"visibleRecoveryNotice\":true,\"preparedWorkingCopy\":true}\n"); + } + unapplied_ = false; + unsaved_ = false; + SendMessageW(window_, WM_COMMAND, quit_id, 0); + } + + void finish_acceptance() { + notice_ = L"Acceptance: applied custom coordinates, refused invalid input, saved, played " + L"three inputs and verified exported replay."; + update_status(); + UpdateWindow(canvas_); + UpdateWindow(window_); + screenshot(window_, acceptance_ / L"editor-play.png"); + command(layers_id); + notice_ = L"Prepared collision-layer scene: category/mask fields are editable in the same " + L"application."; + update_status(); + UpdateWindow(canvas_); + screenshot(window_, acceptance_ / L"editor-layers.png"); + bool overwrite_refused{}; + try { + export_bundle(acceptance_ / L"evidence"); + } catch (const std::exception&) { + overwrite_refused = true; + } + require(overwrite_refused && read_text(acceptance_ / L"evidence" / L"scene.scene") == + read_text(profile_ / L"project.scene"), + "Existing export was overwritten."); + set_source("FORGE2D_SCENE 2\nbounds 0 0 1000 1000\nentities 0\n"); + command(apply_id); + command(step_id); + require(session_->world().entity_count() == 0U && session_->world().tick() == 1U, + "Empty scene could not be applied and stepped through the native controls."); + std::ostringstream receipt; + receipt + << "{\"schema\":1,\"version\":\"1.1.0\",\"visibleWindow\":true,\"customScene\":true," + "\"invalidPreserved\":true,\"realInputs\":3,\"player\":[1520,900]," + "\"exportReplayVerified\":true,\"nativeHandlers\":true,\"nativePlayTimer\":true," + "\"profileLockExclusive\":true,\"multilineSource\":true,\"exportOverwriteRefused\":" + "true,\"emptyScene\":true,\"source\":\"" + << FORGE2D_NATIVE_SOURCE + << "\",\"sourceDirty\":" << (FORGE2D_NATIVE_DIRTY ? "true" : "false") << "}\n"; + atomic_text(acceptance_ / L"phase-1.json", receipt.str()); + unapplied_ = false; + unsaved_ = false; + SendMessageW(window_, WM_COMMAND, quit_id, 0); + } + + std::filesystem::path profile_; + std::filesystem::path acceptance_; + int phase_{}; + Handle owner_; + std::unique_ptr session_; + HWND window_{}; + HWND canvas_{}; + HWND source_{}; + HWND heading_{}; + HWND source_label_{}; + HWND instructions_{}; + HWND status_{}; + std::vector buttons_; + std::array keys_{}; + bool setting_text_{}; + bool unapplied_{}; + bool unsaved_{}; + bool load_error_{}; + int exit_code_{}; + bool acceptance_play_pending_{}; + ULONGLONG acceptance_play_started_{}; + std::uint64_t acceptance_replay_hash_{}; + std::wstring notice_; + FixedStepClock clock_{std::chrono::nanoseconds{1'000'000'000 / 60}}; + std::chrono::steady_clock::time_point last_frame_{}; +}; +} // namespace +} // namespace forge2d::desktop + +/** No user-selected arguments are executed. Acceptance roots require an explicit private marker. */ +int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show) { + using namespace forge2d::desktop; + std::filesystem::path acceptance; + try { + int count{}; + auto** arguments = CommandLineToArgvW(GetCommandLineW(), &count); + require(arguments != nullptr, "Cannot read application arguments."); + std::filesystem::path profile; + int phase{}; + if (count == 1) { + profile = default_profile(); + } else if (count == 5 && std::wstring_view{arguments[1]} == L"--acceptance-root" && + std::wstring_view{arguments[3]} == L"--acceptance-phase") { + std::array enabled{}; + require(GetEnvironmentVariableW(L"FORGE2D_ACCEPTANCE", enabled.data(), + static_cast(enabled.size())) == 1U && + enabled[0] == L'1', + "Acceptance mode is not enabled."); + acceptance = std::filesystem::absolute(arguments[2]); + require(read_text(acceptance / L"acceptance.marker") == "forge2d-owned-native-test\n", + "Acceptance root marker is missing."); + const std::wstring_view choice{arguments[4]}; + require(choice == L"1" || choice == L"2" || choice == L"3", + "Invalid acceptance phase."); + phase = choice == L"1" ? 1 : (choice == L"2" ? 2 : 3); + profile = acceptance / L"profile"; + } else { + LocalFree(arguments); + throw std::invalid_argument( + "This application does not accept arbitrary command-line arguments."); + } + LocalFree(arguments); + ensure_profile(profile); + Application app{profile, acceptance, phase}; + return app.run(instance, show); + } catch (const std::exception& error) { + if (acceptance.empty()) { + MessageBoxW(nullptr, wide(error.what()).c_str(), L"Forge2D Editor could not start", + MB_OK | MB_ICONERROR); + } else { + try { + atomic_text(acceptance / L"startup-failure.txt", error.what()); + } catch (...) { + // A failure cannot justify writing elsewhere or displaying a modal test dialog. + } + } + return 2; + } +} diff --git a/docs/code-index.md b/docs/code-index.md index 276dfec..f75c932 100644 --- a/docs/code-index.md +++ b/docs/code-index.md @@ -3,6 +3,53 @@ Generated by `scripts/generate-code-index.mjs`. Source headers point here for exact declaration locations. Regenerate after authored source changes. +## `apps/editor_files.cpp` + +- Line 21: `file_error` +- Line 25: `regular` +- Line 26: `info` +- Line 34: `random_suffix` +- Line 35: `bytes` +- Line 40: `digits` +- Line 50: `Handle::~Handle` +- Line 56: `read_text` +- Line 60: `size` +- Line 65: `text` +- Line 66: `received` +- Line 71: `extra` +- Line 79: `atomic_text` +- Line 83: `attributes` +- Line 89: `staging` +- Line 95: `written` +- Line 112: `executable_directory` +- Line 113: `path` +- Line 114: `length` +- Line 121: `default_profile` +- Line 122: `result` +- Line 131: `ensure_profile` +- Line 133: `attributes` +- Line 140: `wide` +- Line 147: `length` +- Line 152: `result` +- Line 160: `utf8` +- Line 167: `length` +- Line 173: `result` + +## `apps/editor_files.hpp` + +- Line 19: `Handle` +- Line 21: `Handle` +- Line 23: `Handle` +- Line 24: `operator=` +- Line 25: `get` +- Line 33: `read_text` +- Line 34: `atomic_text` +- Line 35: `executable_directory` +- Line 36: `default_profile` +- Line 37: `ensure_profile` +- Line 38: `wide` +- Line 39: `utf8` + ## `apps/forge2d_arena.cpp` - Line 25: `read_text` @@ -39,6 +86,134 @@ declaration locations. Regenerate after authored source changes. - Line 31: `input_frame` - Line 33: `now` +## `apps/forge2d_editor.cpp` + +- Line 32: `apply_id` +- Line 33: `play_id` +- Line 34: `step_id` +- Line 35: `reset_id` +- Line 36: `save_id` +- Line 37: `open_id` +- Line 38: `replay_id` +- Line 39: `export_id` +- Line 40: `arena_id` +- Line 41: `layers_id` +- Line 42: `help_id` +- Line 43: `quit_id` +- Line 44: `text_id` +- Line 45: `frame_timer` +- Line 46: `acceptance_timer` +- Line 47: `help_text` +- Line 69: `editor_text` +- Line 70: `count` +- Line 74: `text` +- Line 75: `received` +- Line 77: `encoded` +- Line 89: `choose_file` +- Line 91: `dialog` +- Line 106: `require` +- Line 113: `screenshot` +- Line 114: `bounds` +- Line 116: `width` +- Line 117: `height` +- Line 118: `screen` +- Line 119: `memory` +- Line 120: `bitmap` +- Line 121: `previous` +- Line 122: `painted` +- Line 131: `token` +- Line 134: `saved` +- Line 138: `image` +- Line 147: `Application` +- Line 149: `Application` +- Line 150: `profile_` +- Line 156: `info` +- Line 162: `defaults` +- Line 164: `saved` +- Line 175: `run` +- Line 176: `main_class` +- Line 197: `message` +- Line 206: `procedure` +- Line 207: `app` +- Line 209: `create` +- Line 235: `canvas_procedure` +- Line 237: `app` +- Line 239: `create` +- Line 268: `message` +- Line 277: `size` +- Line 294: `now` +- Line 295: `ticks` +- Line 297: `previous_tick` +- Line 324: `child` +- Line 325: `control` +- Line 335: `create_controls` +- Line 336: `menu` +- Line 337: `file` +- Line 344: `examples` +- Line 398: `layout` +- Line 399: `bounds` +- Line 401: `width` +- Line 402: `height` +- Line 403: `split` +- Line 415: `set_source` +- Line 437: `input` +- Line 438: `horizontal` +- Line 440: `vertical` +- Line 446: `update_status` +- Line 447: `world` +- Line 461: `command` +- Line 502: `path` +- Line 505: `executable_directory` +- Line 513: `path` +- Line 521: `path` +- Line 523: `directory` +- Line 537: `export_bundle` +- Line 542: `image` +- Line 563: `paint_canvas` +- Line 564: `paint` +- Line 565: `dc` +- Line 566: `bounds` +- Line 569: `image` +- Line 575: `info` +- Line 590: `acceptance` +- Line 613: `authored` +- Line 614: `location` +- Line 622: `saved` +- Line 623: `before` +- Line 625: `refused` +- Line 641: `snapshot` +- Line 642: `player` +- Line 646: `hash` +- Line 647: `replay` +- Line 649: `initial` +- Line 663: `snapshot` +- Line 664: `player` +- Line 686: `finish_acceptance` +- Line 699: `overwrite_refused` +- Line 730: `phase_` +- Line 733: `window_` +- Line 734: `canvas_` +- Line 735: `source_` +- Line 736: `heading_` +- Line 737: `source_label_` +- Line 738: `instructions_` +- Line 739: `status_` +- Line 741: `keys_` +- Line 742: `setting_text_` +- Line 743: `unapplied_` +- Line 744: `unsaved_` +- Line 745: `load_error_` +- Line 746: `exit_code_` +- Line 747: `acceptance_play_pending_` +- Line 748: `acceptance_play_started_` +- Line 749: `acceptance_replay_hash_` +- Line 752: `last_frame_` +- Line 758: `wWinMain` +- Line 762: `count` +- Line 766: `phase` +- Line 771: `enabled` +- Line 791: `app` + ## `benchmarks/benchmark.cpp` - Line 20: `started` @@ -105,6 +280,33 @@ declaration locations. Regenerate after authored source changes. - Line 47: `action` - Line 49: `operator<=>` +## `include/forge2d/editor.hpp` + +- Line 19: `EditorSession` +- Line 25: `EditorSession` +- Line 26: `apply_scene` +- Line 27: `load_replay` +- Line 28: `reset` +- Line 29: `play` +- Line 30: `pause` +- Line 31: `step` +- Line 32: `advance` +- Line 34: `playing` +- Line 37: `reviewing_replay` +- Line 40: `replay_remaining` +- Line 41: `scene` +- Line 44: `world` +- Line 47: `recording` +- Line 50: `scene_text` +- Line 51: `replay_text` +- Line 52: `image` +- Line 55: `scene_` +- Line 56: `world_` +- Line 57: `recording_` +- Line 58: `replay_` +- Line 59: `replay_index_` +- Line 60: `playing_` + ## `include/forge2d/entity.hpp` - Line 20: `Entity` @@ -324,6 +526,19 @@ declaration locations. Regenerate after authored source changes. - Line 28: `forge2d_arena_allocate` - Line 31: `forge2d_arena_reset` +## `scripts/build-native.ps1` + +- Line 4: `ErrorActionPreference` +- Line 6: `Repo` +- Line 7: `Destination` +- Line 18: `Package` +- Line 25: `Metadata` +- Line 27: `Source` +- Line 29: `Dirty` +- Line 40: `Archive` +- Line 42: `Stream` +- Line 43: `Hasher` + ## `scripts/generate-code-index.mjs` - Line 11: `root` @@ -343,22 +558,29 @@ declaration locations. Regenerate after authored source changes. - Line 35: `lines` - Line 36: `knownTypes` - Line 39: `match` -- Line 44: `match` -- Line 48: `type` -- Line 51: `functionMatch` -- Line 53: `qualifiedConstructor` -- Line 55: `parts` -- Line 60: `simpleConstructor` -- Line 64: `splitDeclaration` -- Line 66: `variable` -- Line 73: `render` -- Line 74: `lines` -- Line 81: `path` -- Line 82: `items` -- Line 84: `repositoryPath` -- Line 86: `item` -- Line 93: `main` -- Line 94: `content` +- Line 43: `match` +- Line 47: `match` +- Line 52: `match` +- Line 56: `type` +- Line 59: `functionMatch` +- Line 61: `qualifiedConstructor` +- Line 63: `parts` +- Line 68: `simpleConstructor` +- Line 72: `splitDeclaration` +- Line 74: `variable` +- Line 81: `render` +- Line 82: `lines` +- Line 89: `path` +- Line 90: `items` +- Line 92: `repositoryPath` +- Line 94: `item` +- Line 101: `main` +- Line 102: `content` + +## `scripts/smoke-native.py` + +- Line 20: `run` +- Line 71: `main` ## `scripts/validate-repository.mjs` @@ -408,6 +630,27 @@ declaration locations. Regenerate after authored source changes. - Line 99: `left` - Line 100: `right` +## `src/editor.cpp` + +- Line 14: `EditorSession::EditorSession` +- Line 18: `EditorSession::apply_scene` +- Line 22: `next_scene` +- Line 26: `next_world` +- Line 37: `EditorSession::reset` +- Line 38: `next_world` +- Line 46: `EditorSession::load_replay` +- Line 50: `replay` +- Line 54: `next_world` +- Line 64: `EditorSession::play` +- Line 71: `EditorSession::pause` +- Line 75: `EditorSession::replay_remaining` +- Line 79: `EditorSession::step` +- Line 103: `EditorSession::advance` +- Line 108: `EditorSession::scene_text` +- Line 111: `EditorSession::replay_text` +- Line 115: `EditorSession::image` +- Line 116: `image` + ## `src/pathfinding.cpp` - Line 18: `max_dimension` @@ -514,9 +757,9 @@ declaration locations. Regenerate after authored source changes. - Line 85: `hash` - Line 95: `render_world` - Line 101: `left` -- Line 104: `right` -- Line 107: `top` -- Line 110: `bottom` +- Line 105: `right` +- Line 109: `top` +- Line 112: `bottom` ## `src/world.cpp` @@ -563,6 +806,46 @@ declaration locations. Regenerate after authored source changes. - Line 235: `world` - Line 242: `run_replay` +## `tests/test_editor.cpp` + +- Line 18: `scene_text` +- Line 22: `require` +- Line 26: `rejects` +- Line 35: `test_authored_input_and_replay` +- Line 36: `session` +- Line 47: `snapshot` +- Line 48: `player` +- Line 55: `expected` +- Line 56: `replay` +- Line 74: `test_apply_is_transactional` +- Line 75: `session` +- Line 77: `hash` +- Line 78: `canonical` +- Line 85: `invalid` +- Line 94: `updated` +- Line 105: `test_replay_limits` +- Line 106: `session` +- Line 107: `old_hash` +- Line 134: `test_entities_and_offset_rendering` +- Line 135: `session` +- Line 136: `scene` +- Line 137: `prototype` +- Line 140: `entity` +- Line 151: `origin_image` +- Line 167: `main` + +## `tests/test_editor_files.cpp` + +- Line 14: `require` +- Line 20: `refused` +- Line 21: `rejected` +- Line 31: `main` +- Line 43: `file` +- Line 48: `linked` +- Line 57: `oversized` +- Line 58: `std::string` +- Line 65: `unicode` + ## `tests/test_main.cpp` - Line 39: `require` diff --git a/docs/file-catalog.md b/docs/file-catalog.md index c369a08..8243a35 100644 --- a/docs/file-catalog.md +++ b/docs/file-catalog.md @@ -96,6 +96,26 @@ For declaration-level locations, see the generated [code index](code-index.md). | `docs/reports/validation.md` | Records compiler, SDL, repository, artifact, and pending hosted-CI validation. | | `docs/reports/generated/.gitkeep` | Preserves the otherwise-empty directory used for generated CI reports. | +## Native editor additions + +| File | Responsibility | +| --- | --- | +| `include/forge2d/editor.hpp` | Public bounded scene/edit/play/replay model contract. | +| `src/editor.cpp` | Uses actual engine parsers, world and renderer while preserving authored/runtime state separation. | +| `apps/forge2d_editor.cpp` | Real Win32 scene editor, focused-canvas input, fixed-tick play, help, recovery and exports. | +| `apps/editor_files.hpp` | OS-handle ownership and bounded/atomic local-file interfaces. | +| `apps/editor_files.cpp` | Descriptor-bound UTF-8 reads, flushed atomic saves and OS-local profile selection. | +| `apps/editor.rc` | Embedded Windows author and product version metadata. | +| `tests/test_editor.cpp` | Independent scene/edit/replay/boundary/filter/translation model oracles. | +| `tests/test_editor_files.cpp` | Actual Windows file, hardlink, UTF-8 and byte-budget cases. | +| `scripts/build-native.ps1` | Static-runtime build, real tests and fresh portable ZIP/source/hash metadata. | +| `scripts/smoke-native.py` | Extracted actual EXE, own-window/timer, export and two-process persistence acceptance. | +| `.github/workflows/native-app.yml` | Actual package CI with bounded proof artifacts and manual-only full-package retention. | +| `docs/native-editor.md` | Usage, fields, profile/recovery, build, boundaries and actual captures. | +| `docs/reports/native-editor-2026-11-10.md` | Actual observed validation and pending hosted/installer distinctions. | +| `docs/images/native-editor-play.png` | Actual custom-scene/replay native window. | +| `docs/images/native-editor-layers.png` | Actual collision-layer scene in the same editor. | + ## Reviewed documentation editions | File | Responsibility | diff --git a/docs/handbook.md b/docs/handbook.md index 46a63de..214be3c 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -2,6 +2,11 @@ By Jason Stys. +Current native work: [Forge2D Editor usage](native-editor.md) and +[actual native validation](reports/native-editor-2026-11-10.md) describe the new 1.1.0 Windows +scene/edit/play/replay workflow. The Word edition below retains its explicitly cited historical +baseline until the separately reviewed updated guide is produced. + The implementation adds bidirectional collision category and mask filters, versioned scene migration, deterministic state hashes and initial/final overlap CSV inspection. Supports practical game-world collision layers and reproducible debugging against independent geometry oracles. diff --git a/docs/images/native-editor-layers.png b/docs/images/native-editor-layers.png new file mode 100644 index 0000000..dd6c62d Binary files /dev/null and b/docs/images/native-editor-layers.png differ diff --git a/docs/images/native-editor-play.png b/docs/images/native-editor-play.png new file mode 100644 index 0000000..d6e864f Binary files /dev/null and b/docs/images/native-editor-play.png differ diff --git a/docs/native-editor.md b/docs/native-editor.md new file mode 100644 index 0000000..2e09a8e --- /dev/null +++ b/docs/native-editor.md @@ -0,0 +1,78 @@ +# Native Forge2D Editor + +The Windows portable app contains the actual statically linked C++ engine, software renderer and +strict scene/replay parsers. Extract the entire package, then launch `Forge2DEditor.exe`. Keep its +`assets` directory beside it. There is no Python/Node/JDK installation or network dependency. +The first-use status and Help / About explain the controls. The title, help and footer credit Jason +Stys and link his source/contact profile. This unsigned portable candidate is not an OS installer; +system trust prompts, shortcut installation and manual accessibility acceptance are separate work. + +## Complete workflow + +1. Choose **Prepared data > Prepared arena** or **Collision layers**, or File > Open scene. +2. Edit the right-hand scene text, then **Apply scene**. Every existing field is editable, including + bounds, positions, velocity, extents, color, rendering layer, player and category/mask. Invalid + input leaves the previous applied world and saved project intact. The buffer remains editable. +3. Click the canvas. WASD/arrows control the player; Space records the action bit. **Step** performs + one actual engine tick; **Play / Pause** advances fixed 60 Hz ticks with bounded catch-up. The + native clock drops excess wall ticks instead of accumulating an unlimited backlog. **Reset** + returns to the applied authored scene and clears the new recording. +4. **Save project** stores the applied authored scene in `%LOCALAPPDATA%/Forge2DEditor/project.scene`. + Apply pending edits before saving. Running positions/faults are not silently baked into that file. + Restart restores the authored scene paused at tick zero, not a running simulation. +5. File > Import replay loads recorded inputs against the currently applied scene. Play/Step then + consumes those inputs and stops at the final frame. The native editor requires 60 Hz and at most + 36,000 frames. A replay is not an authenticated scene reference: use the corresponding scene. +6. **Export evidence** chooses a new `.evidence` directory beside your selected base name. It writes + `scene.scene`, `inputs.replay`, `frame.ppm`, `snapshot.csv` and `summary.json`. CSV contains only + numeric entity IDs/fields, avoiding scene-name spreadsheet formula interpretation. The summary + records actual tick, state/pixel hash and overlap counts; the full scene accompanies the replay. + +![Actual native custom-scene replay](images/native-editor-play.png) + +![Actual native collision-layer prepared input](images/native-editor-layers.png) + +## Limits and recovery + +The native input budget is 1 MiB, 1,000 entities, and 36,000 input frames (ten minutes at 60 Hz). +The existing library/CLI parser limits remain independent. Empty scenes are supported. Scene v1 +imports use the existing explicit v2 filter migration. World bounds must fully contain every body. +Player inputs override that body's authored velocity; other bodies follow the existing boundary +clamp/bounce model. Rendering layers do not alter collision categories. This editor does not add +physical collision response, triggers, sprite assets, a scripting/plugin system or a commercial +engine asset pipeline. Pathfinding remains the separate tested library API, not an editor navmesh. + +Only one process owns a profile at a time. Project reads use actual bounded regular-file handles; +hardlinks/reparse-point inputs and invalid UTF-8 are refused. Saves use a fresh random exclusive +staging file, flush, then atomic replacement. Data is plaintext under the OS account; another process +with the same user's permissions or an administrator is trusted. No multiuser login is claimed. + +A malformed saved project is preserved. The editor starts a prepared working copy, shows a recovery +notice and requires explicit confirmation before replacing that saved file. Export recovery before +choosing replacement. Closing warns about unapplied/unsaved work. Existing export folders are never +overwritten; a failed export can leave a partial **new** folder, which must be inspected manually. +Back up the profile after clean exit; do not delete the owner file to bypass a running process. + +## Build and actual package acceptance + +In a scoped Visual Studio x64 developer shell with CMake/Ninja: + +```powershell +powershell -NoProfile -File scripts/build-native.ps1 -Output release/my-candidate +python scripts/smoke-native.py release/my-candidate/Forge2DEditor-1.1.0-windows-x64.zip artifacts/native/my-run +``` + +The first command builds the static-runtime native app and all real CTests, verifies embedded +Windows author/version metadata, records Git source/dirty state, and writes a fresh ZIP/sidecar. +The acceptance helper extracts that actual archive into a fresh temporary directory, strips +developer runtimes from its child's PATH, runs three real visible-window launches with only marked +synthetic data, and verifies the executable's compiled source/version against package metadata. +The first exercises editing/play/export, the second checks durable restart, and the third preserves +an intentionally corrupt saved scene while visibly offering a prepared recovery working copy. +Actual native controls and timer events are exercised without external mouse/keyboard automation. + +The read-only native CI job performs the same workflow. Routine PR/push runs retain only bounded +synthetic receipts and screenshots for one day. A full unsigned package is retained only through an +explicit trusted-main manual opt-in. The Windows CodeQL build extracts the Win32 host as well as the +portable core; Linux compilation/analysis, SDL, sanitizer/fuzz, benchmark, coverage and container +gates remain. No successful hosted result or public release is implied by a local candidate. diff --git a/docs/reports/native-editor-2026-11-10.md b/docs/reports/native-editor-2026-11-10.md new file mode 100644 index 0000000..4346f44 --- /dev/null +++ b/docs/reports/native-editor-2026-11-10.md @@ -0,0 +1,64 @@ +# Native editor validation — 2026-11-10 + +The local 1.1.0 candidate uses the original C++20/C17 engine, scene/replay parsers, collision filters +and software renderer. New `EditorSession` logic keeps the authored scene separate from the running +world and recorded inputs. Default renderer coordinates are unchanged; translated scene origins are +now supported explicitly and independently checked against the same relative pixel image. + +Observed local MSVC Release validation: all **three CTest executables passed**. These comprise the +original ten core groups, four editor-model groups, and real Windows file-boundary cases. Tests +cover invalid/oversized input preserving prior world state; exact independently authored player +coordinates; zero-mask filtering; empty scenes; maximum entity/frame counts; imported replay rate, +completion and live-input override; translated rendering; actual atomic replacement; hardlink, +directory, invalid UTF-8 and oversized file refusal. Coverage percentages from an older report are +not reused for this new source; Linux coverage and full hosted gates remain pending. + +The actual visible native EXE ran with a new marked synthetic profile. Acceptance edited +player coordinates through the real source control, Applied and Saved through the native handlers, +refused invalid input without changing disk/world, captured three right-input steps to **(1520,900)**, +and independently replayed the exported scene/input pair to the actual state hash. Native Play timer +events consumed that three-frame replay and stopped it. The source control's actual multiline row +count and exclusive profile lock were checked. Restart restored authored **(1100,900)** paused at +tick zero. A third launch preserved an intentionally malformed saved scene and displayed the real +recovery notice while offering a prepared working copy. Empty-scene Apply/Step and existing-export +refusal also passed through the real native workflow. All three processes exited normally. The +actual ZIP was separately extracted and passed the same three-launch journey with only Windows +system executables on PATH; its metadata matched the actual EXE's compiled source/dirty/version. +Only this application's owned window was captured, never the desktop. + +The original 20,000-entity benchmark passed unchanged budgets: storage 1.4605 ms, collision 16.7956 ms +and simulation 11.4666 ms in this local run. These headless kernel measurements exclude native GUI, +file/export work and installation. The 360-tick original arena demo passed with eight entities; +state hash `7682307639504695130` and pixel hash `14365382764149795246` describe that specific input. + +Screenshot inspection caught a genuine native EDIT presentation issue: portable LF text did not +display as separate Windows lines. Source display now converts to CRLF; retrieval converts back +before the strict parser. A subsequent actual multiline-control assertion caught the missing reverse +conversion; that was fixed before acceptance passed. The failed evidence remains private; no test +deadline, assertion or core oracle was relaxed. A packaging-shell hash cmdlet availability issue was +fixed with the standard .NET streaming SHA256 API, leaving package hashes independently verifiable. + +The first PR integration at `03c0175` passed actual Windows extracted-package acceptance and the +Linux GCC/Clang, coverage, sanitizers/fuzz, clang-tidy, SDL and repository lanes. Two other Windows +jobs selected MinGW despite an old MSVC label; their Unicode `wWinMain` link required `-municode`. +The original Windows compiler lane is now explicitly GCC/MinGW and labelled accordingly; actual +MSVC remains exercised by native packaging. The container's explicit target list also omitted the +new editor-model test before running all CTests; that target is now built. These were real build/ +workflow integration faults, corrected without disabling tests. Corrective hosted results remain +pending; Docker on the PC was not changed or used. + +The corrective PR then passed all workflow jobs, but the aggregate code-scanning check was neutral: +converting the original Ubuntu analysis to explicitly categorized matrix jobs changed its required +configuration identity. Actual API records showed 58 C/C++ rules completed on both platforms, while +the original main analysis used `.github/workflows/codeql.yml:analyze` as both key/category. The +original workflow never declared Actions analysis. Its genuine Ubuntu `analyze` job is now retained +unchanged, with an independent Windows job adding real Win32 extraction. No query, supported source +scope or successful-result requirement was removed or substituted with a nominal placeholder. + +Boundaries: this is an unsigned Windows portable app, not an installer, signing proof, populated-user +migration or clean-machine certification. Actual own-window controls/timers were used; a human's +desktop-icon click, file-dialog selection, keyboard accessibility and every OS failure condition were +not automated. Only synthetic profiles were accessed. The app adds no network, hardware, scripting +or physical collision response. Recorded inputs require their matching authored scene. Local files +remain visible to the same OS user/administrator. Historical Word editions retain their original +source baselines until the separately reviewed updated guide is produced. diff --git a/include/forge2d/editor.hpp b/include/forge2d/editor.hpp new file mode 100644 index 0000000..c875392 --- /dev/null +++ b/include/forge2d/editor.hpp @@ -0,0 +1,63 @@ +/** + * File: editor.hpp + * Purpose: Define a bounded editor/play model over the actual scene, world, renderer and replay + * APIs. Symbols and important variable lines are indexed in docs/code-index.md; UI and disk IO stay + * outside. + */ +#pragma once + +#include "forge2d/renderer.hpp" + +#include +#include +#include +#include + +namespace forge2d { + +/** Keep authored scene distinct from the running world and recorded/replayed input history. */ +class EditorSession { + public: + static constexpr std::size_t maximum_input_bytes = 1'048'576U; + static constexpr std::size_t maximum_entities = 1'000U; + static constexpr std::size_t maximum_frames = 36'000U; + + explicit EditorSession(std::string_view scene_text); + void apply_scene(std::string_view text); + void load_replay(std::string_view text); + void reset(); + void play(); + void pause() noexcept; + void step(InputFrame input = {}); + void advance(InputFrame input = {}); + + [[nodiscard]] bool playing() const noexcept { + return playing_; + } + [[nodiscard]] bool reviewing_replay() const noexcept { + return replay_.has_value(); + } + [[nodiscard]] std::size_t replay_remaining() const noexcept; + [[nodiscard]] const Scene& scene() const noexcept { + return scene_; + } + [[nodiscard]] const World& world() const noexcept { + return world_; + } + [[nodiscard]] const Replay& recording() const noexcept { + return recording_; + } + [[nodiscard]] std::string scene_text() const; + [[nodiscard]] std::string replay_text() const; + [[nodiscard]] Image image(std::uint32_t width, std::uint32_t height) const; + + private: + Scene scene_{}; + World world_{}; + Replay recording_{}; + std::optional replay_{}; + std::size_t replay_index_{}; + bool playing_{}; +}; + +} // namespace forge2d diff --git a/include/forge2d/renderer.hpp b/include/forge2d/renderer.hpp index 23a14f2..86a2172 100644 --- a/include/forge2d/renderer.hpp +++ b/include/forge2d/renderer.hpp @@ -38,6 +38,7 @@ class Image { std::vector pixels_{}; }; -void render_world(Image& image, const World& world, Vec2i world_size = {10'000, 10'000}); +void render_world(Image& image, const World& world, Vec2i world_size = {10'000, 10'000}, + Vec2i world_origin = {}); } // namespace forge2d diff --git a/scripts/build-native.ps1 b/scripts/build-native.ps1 new file mode 100644 index 0000000..20c1c11 --- /dev/null +++ b/scripts/build-native.ps1 @@ -0,0 +1,47 @@ +# File: build-native.ps1 +# Purpose: Build/package a static-runtime native Windows editor; never install or touch user profiles. +param([string]$Output = 'release/native-candidate') +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$Repo = Split-Path -Parent $PSScriptRoot +$Destination = [System.IO.Path]::GetFullPath((Join-Path $Repo $Output)) +if (-not $Destination.StartsWith(($Repo + [System.IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) { + throw 'Package output must remain inside this repository.' +} +if (Test-Path -LiteralPath $Destination) { throw 'Use a fresh package output; existing candidates are preserved.' } +cmake -S $Repo -B (Join-Path $Repo '.runtime/native-build') -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded +if ($LASTEXITCODE -ne 0) { throw 'Native configuration failed.' } +cmake --build (Join-Path $Repo '.runtime/native-build') --parallel 2 +if ($LASTEXITCODE -ne 0) { throw 'Native build failed.' } +ctest --test-dir (Join-Path $Repo '.runtime/native-build') --output-on-failure +if ($LASTEXITCODE -ne 0) { throw 'Native/core tests failed.' } +$Package = Join-Path $Destination 'Forge2DEditor' +New-Item -ItemType Directory -Path (Join-Path $Package 'assets') -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $Repo '.runtime/native-build/Forge2DEditor.exe') -Destination $Package +Copy-Item -LiteralPath (Join-Path $Repo 'LICENSE') -Destination $Package +foreach ($Name in @('arena.scene', 'collision-layers.scene')) { + Copy-Item -LiteralPath (Join-Path $Repo "assets/$Name") -Destination (Join-Path $Package 'assets') +} +$Metadata = (Get-Item -LiteralPath (Join-Path $Package 'Forge2DEditor.exe')).VersionInfo +if ($Metadata.CompanyName -ne 'Jason Stys' -or $Metadata.ProductVersion -ne '1.1.0') { throw 'Native author/version mismatch.' } +$Source = (git -C $Repo -c "safe.directory=$Repo" rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or $Source -notmatch '^[0-9a-f]{40}$') { throw 'Source identity unavailable.' } +$Dirty = @(git -C $Repo -c "safe.directory=$Repo" status --porcelain).Count -ne 0 +@{schema=1; version='1.1.0'; source=$Source; dirty=$Dirty; format='Windows portable'; author='Jason Stys'} | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Package 'RELEASE.json') -Encoding utf8 +@' +Forge2D Editor 1.1.0 - Jason Stys +Extract this entire folder, then launch Forge2DEditor.exe. No IDE, compiler, Python or Node required. +Help / About describes all controls and scene fields. Prepared data is inside this actual editor. +Your project is saved under your OS-local Forge2DEditor profile, outside this program folder. +No hardware, network, authentication or physical collision-response guarantee. Unsigned portable app. +Source and contact: https://github.com/JasonStys/forge2d-engine +'@ | Set-Content -LiteralPath (Join-Path $Package 'README.txt') -Encoding utf8 +$Archive = Join-Path $Destination 'Forge2DEditor-1.1.0-windows-x64.zip' +Compress-Archive -Path (Join-Path $Package '*') -DestinationPath $Archive -CompressionLevel Optimal +$Stream = [System.IO.File]::OpenRead($Archive) +$Hasher = [System.Security.Cryptography.SHA256]::Create() +try { $Hash = [BitConverter]::ToString($Hasher.ComputeHash($Stream)).Replace('-', '').ToLowerInvariant() } +finally { $Stream.Dispose(); $Hasher.Dispose() } +"$Hash $([System.IO.Path]::GetFileName($Archive))" | Set-Content -LiteralPath ($Archive + '.sha256') -Encoding ascii +Write-Output $Archive diff --git a/scripts/generate-code-index.mjs b/scripts/generate-code-index.mjs index fa4daeb..ed9a3e7 100644 --- a/scripts/generate-code-index.mjs +++ b/scripts/generate-code-index.mjs @@ -10,8 +10,8 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const outputPath = join(root, "docs", "code-index.md"); -const supportedExtensions = new Set([".c", ".cpp", ".h", ".hpp", ".mjs", ".sh"]); -const excludedDirectories = new Set([".git", ".cache", "build", "coverage", "dist", "out"]); +const supportedExtensions = new Set([".c", ".cpp", ".h", ".hpp", ".mjs", ".sh", ".ps1", ".py", ".rc"]); +const excludedDirectories = new Set([".git", ".cache", ".runtime", "artifacts", "release", "build", "coverage", "dist", "out"]); const typePattern = /^\s*(?:class|struct|enum(?:\s+class)?)\s+([A-Za-z_][\w]*)/; const functionPattern = /^\s*(?:\[\[[^\]]+\]\]\s*)?(?:(?:constexpr|explicit|inline|static|virtual)\s+)*(?:[\w:<>,*&]+\s+)+((?:[A-Za-z_][\w]*::)*(?:~?[A-Za-z_][\w]*|operator(?:<=>|==|\[\]|\(\)|[-+*/=<>]+)))\s*\(/; const qualifiedConstructorPattern = /^\s*((?:[A-Za-z_][\w]*::)+~?[A-Za-z_][\w]*)\s*\(/; @@ -35,6 +35,14 @@ function declarations(path) { const lines = readFileSync(path, "utf8").split(/\r?\n/); const knownTypes = new Set(lines.flatMap((line) => line.match(typePattern)?.[1] ?? [])); return lines.flatMap((line, index) => { + if (extension === ".py") { + const match = line.match(/^\s*(?:def|class)\s+([A-Za-z_][\w]*)/) ?? line.match(/^([A-Z_][\w]*)\s*=/); + return match ? [{ line: index + 1, name: match[1] }] : []; + } + if (extension === ".ps1") { + const match = line.match(/^\s*function\s+([\w-]+)/i) ?? line.match(/^\s*\$([\w]+)\s*=/); + return match ? [{ line: index + 1, name: match[1] }] : []; + } if (extension === ".mjs") { const match = line.match(/\b(?:function|class)\s+([A-Za-z_$][\w$]*)/) ?? line.match(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\b/); diff --git a/scripts/smoke-native.py b/scripts/smoke-native.py new file mode 100644 index 0000000..1304d8e --- /dev/null +++ b/scripts/smoke-native.py @@ -0,0 +1,93 @@ +"""File: smoke-native.py. Purpose: run actual play/restart/recovery with a marked synthetic profile. + +Functions/variables and exact source lines are listed in docs/code-index.md. This harness uses +the editor's own event handlers, real engine, file IO and visible window, not external OS input. +It never reads the default user profile or installs shortcuts. Timeouts are failures, not retries. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import zipfile + + +def run(executable: Path, output: Path, metadata: dict | None = None) -> None: + """Bind acceptance to one packaged EXE and fresh root; preserve all three actual launch results.""" + executable = executable.resolve(strict=True) + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + (output / "acceptance.marker").write_text("forge2d-owned-native-test\n", encoding="utf-8", newline="\n") + environment = {key: os.environ[key] for key in ("SYSTEMROOT", "WINDIR", "TEMP", "TMP") if key in os.environ} + environment.update(PATH=str(Path(os.environ.get("SYSTEMROOT", "C:/Windows")) / "System32"), + FORGE2D_ACCEPTANCE="1") + stages = [] + with tempfile.TemporaryDirectory(prefix="forge2d-native-cwd-") as cwd: + for phase in (1, 2, 3): + if phase == 3: + (output / "profile" / "project.scene").write_text("invalid saved scene\n", encoding="utf-8", newline="\n") + result = subprocess.run( + [str(executable), "--acceptance-root", str(output), "--acceptance-phase", str(phase)], + cwd=cwd, + env=environment, + timeout=45, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"Native acceptance phase {phase} exited {result.returncode}.") + stages.append(json.loads((output / f"phase-{phase}.json").read_text(encoding="utf-8"))) + if metadata is not None: + if (metadata["source"] != stages[0]["source"] or metadata["dirty"] is not stages[0]["sourceDirty"] + or metadata["version"] != stages[0]["version"]): + raise RuntimeError("Packaged metadata does not match the actual compiled source/version.") + if os.environ.get("CI") and metadata["dirty"] is not False: + raise RuntimeError("Hosted native acceptance requires a clean source checkout.") + summary = json.loads((output / "evidence" / "summary.json").read_text(encoding="utf-8")) + if summary["recordedFrames"] != 3 or summary["tick"] != 3: + raise RuntimeError("Export did not retain the actual three-step engine execution.") + for name in ("editor-play.png", "editor-layers.png"): + image = output / name + if not image.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") or image.stat().st_size < 10_000: + raise RuntimeError("Native screenshot missing or implausibly empty.") + receipt = { + "schema": 1, + "version": "1.1.0", + "executableSha256": hashlib.sha256(executable.read_bytes()).hexdigest(), + "phases": stages, + "export": summary, + "scope": "Actual visible native windows and own-handler input; no external mouse/shortcut or clean-machine proof.", + } + (output / "native-acceptance.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + if (output / "profile" / "project.scene").read_bytes() != b"invalid saved scene\n": + raise RuntimeError("Corrupt-profile recovery overwrote the preserved synthetic input.") + print(json.dumps({"conclusion": "success", "launches": 3, "recordedInputs": 3, "output": str(output)})) + + +def main() -> None: + """Use only caller-named EXE/ZIP and a new output directory; archives are this build's outputs.""" + parser = argparse.ArgumentParser() + parser.add_argument("executable", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + if args.executable.suffix.lower() == ".zip": + with tempfile.TemporaryDirectory(prefix="forge2d-extracted-") as folder: + with zipfile.ZipFile(args.executable) as package: + names = set(package.namelist()) + allowed = {"Forge2DEditor.exe", "LICENSE", "RELEASE.json", "README.txt", + "assets/arena.scene", "assets/collision-layers.scene"} + if names - {"assets/"} != allowed or sum(row.file_size for row in package.infolist()) > 30_000_000: + raise ValueError("Unexpected portable archive members or size.") + package.extractall(folder) + metadata = json.loads((Path(folder) / "RELEASE.json").read_text(encoding="utf-8-sig")) + run(Path(folder) / "Forge2DEditor.exe", args.output, metadata) + else: + run(args.executable, args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate-repository.mjs b/scripts/validate-repository.mjs index 8f60818..3813f02 100644 --- a/scripts/validate-repository.mjs +++ b/scripts/validate-repository.mjs @@ -16,8 +16,8 @@ const requiredFiles = [ "docs/testing.md", "docs/adr/0001-headless-core-and-optional-sdl3.md", "docs/reports/benchmark.md", "docs/reports/test-summary.md", "docs/reports/validation.md", ]; -const headerExtensions = new Set([".c", ".cpp", ".h", ".hpp", ".mjs", ".sh"]); -const excludedDirectories = new Set([".git", ".cache", "build", "coverage", "dist", "out"]); +const headerExtensions = new Set([".c", ".cpp", ".h", ".hpp", ".mjs", ".sh", ".ps1", ".py", ".rc"]); +const excludedDirectories = new Set([".git", ".cache", ".runtime", "artifacts", "release", "build", "coverage", "dist", "out"]); /** Recursively list authored files while excluding generated output and dependencies. */ function walk(directory) { diff --git a/scripts/verify.sh b/scripts/verify.sh index cabe4a0..1815369 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -13,9 +13,9 @@ mapfile -t SOURCES < <(find apps benchmarks fuzz include platform src tests \ clang-format --dry-run --Werror "${SOURCES[@]}" cmake -S . -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build "${BUILD_DIR}" --parallel -clang-tidy -p "${BUILD_DIR}" src/collision.cpp src/pathfinding.cpp src/replay.cpp src/scene.cpp \ +clang-tidy -p "${BUILD_DIR}" src/editor.cpp src/collision.cpp src/pathfinding.cpp src/replay.cpp src/scene.cpp \ src/software_renderer.cpp src/world.cpp apps/forge2d_arena.cpp tests/test_main.cpp \ - benchmarks/benchmark.cpp + tests/test_editor.cpp benchmarks/benchmark.cpp ctest --test-dir "${BUILD_DIR}" --output-on-failure "${BUILD_DIR}/forge2d_benchmark" | tee docs/reports/generated/benchmark.json "${BUILD_DIR}/forge2d_arena" assets/arena.scene out/verify diff --git a/src/editor.cpp b/src/editor.cpp new file mode 100644 index 0000000..449832a --- /dev/null +++ b/src/editor.cpp @@ -0,0 +1,122 @@ +/** + * File: editor.cpp + * Purpose: Apply scene changes transactionally and run bounded real engine/replay workflows. + * Symbols and important variable lines are indexed in docs/code-index.md; no platform IO occurs + * here. + */ +#include "forge2d/editor.hpp" + +#include +#include + +namespace forge2d { + +EditorSession::EditorSession(std::string_view scene_text) { + apply_scene(scene_text); +} + +void EditorSession::apply_scene(std::string_view text) { + if (text.size() > maximum_input_bytes) { + throw std::invalid_argument("Editor scene input exceeds 1 MiB"); + } + auto next_scene = parse_scene(text); + if (next_scene.entities.size() > maximum_entities) { + throw std::invalid_argument("Editor scenes are limited to 1000 entities"); + } + auto next_world = World::from_scene(next_scene); + Replay next_recording; + next_recording.frames.reserve(maximum_frames); + scene_ = std::move(next_scene); + world_ = std::move(next_world); + recording_ = std::move(next_recording); + replay_.reset(); + replay_index_ = 0U; + playing_ = false; +} + +void EditorSession::reset() { + auto next_world = World::from_scene(scene_); + world_ = std::move(next_world); + recording_.frames.clear(); + replay_.reset(); + replay_index_ = 0U; + playing_ = false; +} + +void EditorSession::load_replay(std::string_view text) { + if (text.size() > maximum_input_bytes) { + throw std::invalid_argument("Editor replay input exceeds 1 MiB"); + } + auto replay = parse_replay(text); + if (replay.tick_rate != 60U || replay.frames.size() > maximum_frames) { + throw std::invalid_argument("Editor replay requires 60 Hz and at most 36000 frames"); + } + auto next_world = World::from_scene(scene_); + Replay next_recording{replay.seed, replay.tick_rate, {}}; + next_recording.frames.reserve(maximum_frames); + world_ = std::move(next_world); + recording_ = std::move(next_recording); + replay_ = std::move(replay); + replay_index_ = 0U; + playing_ = false; +} + +void EditorSession::play() { + if (recording_.frames.size() >= maximum_frames || (replay_ && replay_remaining() == 0U)) { + throw std::invalid_argument("Recording/replay is complete; reset or load a new input"); + } + playing_ = true; +} + +void EditorSession::pause() noexcept { + playing_ = false; +} + +std::size_t EditorSession::replay_remaining() const noexcept { + return replay_ ? replay_->frames.size() - replay_index_ : 0U; +} + +void EditorSession::step(InputFrame input) { + if (recording_.frames.size() >= maximum_frames) { + playing_ = false; + throw std::invalid_argument("Recording limit reached; export before resetting"); + } + if (replay_) { + if (replay_remaining() == 0U) { + playing_ = false; + throw std::invalid_argument("Replay is complete"); + } + input = replay_->frames[replay_index_]; + } + if (input.move_x < -1 || input.move_x > 1 || input.move_y < -1 || input.move_y > 1) { + throw std::invalid_argument("Movement inputs must be in -1..1"); + } + world_.step(input); + recording_.frames.push_back(input); + if (replay_) { + ++replay_index_; + if (replay_remaining() == 0U) + playing_ = false; + } +} + +void EditorSession::advance(InputFrame input) { + if (playing_) + step(input); +} + +std::string EditorSession::scene_text() const { + return serialize_scene(scene_); +} +std::string EditorSession::replay_text() const { + return serialize_replay(recording_); +} + +Image EditorSession::image(std::uint32_t width, std::uint32_t height) const { + Image image{width, height}; + const Vec2i extent{scene_.maximum.x - scene_.minimum.x, scene_.maximum.y - scene_.minimum.y}; + render_world(image, world_, extent, scene_.minimum); + return image; +} + +} // namespace forge2d diff --git a/src/software_renderer.cpp b/src/software_renderer.cpp index 0f9b8fe..eca80b6 100644 --- a/src/software_renderer.cpp +++ b/src/software_renderer.cpp @@ -92,24 +92,27 @@ std::uint64_t Image::pixel_hash() const noexcept { return hash; } -void render_world(Image& image, const World& world, Vec2i world_size) { +void render_world(Image& image, const World& world, Vec2i world_size, Vec2i world_origin) { if (world_size.x <= 0 || world_size.y <= 0) { throw std::invalid_argument("render world size must be positive"); } image.clear(0x101827FFU); for (const auto& entity : world.snapshot()) { - const auto left = project_coordinate( - static_cast(entity.transform.position.x) - entity.body.half_extent.x, - world_size.x, image.width()); - const auto right = project_coordinate( - static_cast(entity.transform.position.x) + entity.body.half_extent.x, - world_size.x, image.width()); + const auto left = + project_coordinate(static_cast(entity.transform.position.x) - + world_origin.x - entity.body.half_extent.x, + world_size.x, image.width()); + const auto right = + project_coordinate(static_cast(entity.transform.position.x) - + world_origin.x + entity.body.half_extent.x, + world_size.x, image.width()); const auto top = project_coordinate(static_cast(entity.transform.position.y) - - entity.body.half_extent.y, + world_origin.y - entity.body.half_extent.y, world_size.y, image.height()); - const auto bottom = project_coordinate( - static_cast(entity.transform.position.y) + entity.body.half_extent.y, - world_size.y, image.height()); + const auto bottom = + project_coordinate(static_cast(entity.transform.position.y) - + world_origin.y + entity.body.half_extent.y, + world_size.y, image.height()); image.fill_rect(left, top, right, bottom, entity.sprite.rgba); } } diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp new file mode 100644 index 0000000..9782e43 --- /dev/null +++ b/tests/test_editor.cpp @@ -0,0 +1,179 @@ +/** + * File: test_editor.cpp + * Purpose: Independently verify editor scene/replay transitions, input limits and actual rendering. + * Symbols and important variable lines are indexed in docs/code-index.md; all fixtures are + * synthetic. + */ +#include "forge2d/editor.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr std::string_view scene_text = "FORGE2D_SCENE 2\nbounds 0 0 10000 10000\nentities 2\n" + "entity player 1000 1000 0 0 100 100 16711935 10 1 1 2\n" + "entity zone 1150 1000 0 0 100 100 4278190335 5 0 2 1\n"; + +void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} +template void rejects(Function&& operation, const char* message) { + try { + operation(); + } catch (const std::exception&) { + return; + } + throw std::runtime_error(message); +} + +void test_authored_input_and_replay() { + forge2d::EditorSession session{scene_text}; + require(!session.playing() && session.world().tick() == 0U, "editor must begin paused"); + require(session.world().collision_pairs().size() == 1U, "authored compatible zones overlap"); + session.advance({1, 0, false}); + require(session.world().tick() == 0U, "paused timer cannot advance the actual world"); + session.play(); + const std::array inputs{ + {{1, 0, false}, {1, 0, true}, {0, 1, false}, {0, 0, false}}}; + for (const auto input : inputs) + session.advance(input); + session.pause(); + const auto snapshot = session.world().snapshot(); + const auto player = std::find_if(snapshot.begin(), snapshot.end(), + [](const auto& entity) { return entity.player; }); + require(player != snapshot.end() && player->transform.position == forge2d::Vec2i{1280, 1140}, + "four authored player inputs must yield x1280/y1140"); + require(session.recording().frames == + std::vector(inputs.begin(), inputs.end()), + "recording must contain actual applied inputs"); + const auto expected = session.world().state_hash(); + const auto replay = session.replay_text(); + session.load_replay(replay); + require(session.reviewing_replay() && session.replay_remaining() == 4U && !session.playing(), + "replay load must reset and pause actual world"); + session.play(); + for (std::size_t index = 0; index < inputs.size(); ++index) + session.advance({-1, -1, true}); + require(session.world().state_hash() == expected && !session.playing(), + "playback must ignore live input and exactly reproduce original state"); + require(session.replay_remaining() == 0U, "completed replay must have no invented frames"); + rejects([&] { session.play(); }, "completed replay must refuse resumed play"); + rejects([&] { session.step(); }, "completed replay must refuse invented manual frames"); + session.reset(); + require(!session.reviewing_replay() && session.recording().frames.empty() && + session.world().tick() == 0U, + "reset restores authored scene and clears transient input history"); +} + +void test_apply_is_transactional() { + forge2d::EditorSession session{scene_text}; + session.step({1, 0, false}); + const auto hash = session.world().state_hash(); + const auto canonical = session.scene_text(); + rejects([&] { session.apply_scene("bad scene"); }, "invalid text must be refused"); + rejects( + [&] { + session.apply_scene(std::string(forge2d::EditorSession::maximum_input_bytes + 1U, 'x')); + }, + "oversized source must be refused"); + auto invalid = forge2d::parse_scene(scene_text); + invalid.entities[0].transform.position = {0, 0}; + rejects([&] { session.apply_scene(forge2d::serialize_scene(invalid)); }, + "engine must reject a body that does not fit bounds"); + require(session.scene_text() == canonical && session.world().state_hash() == hash && + session.recording().frames.size() == 1U, + "refused apply must preserve authored scene and running/recorded state"); + rejects([&] { session.step({2, 0, false}); }, "invalid live movement must be refused"); + require(session.world().state_hash() == hash, "invalid movement must not mutate actual world"); + auto updated = forge2d::parse_scene(scene_text); + updated.entities[0].body.filter.mask = 0U; + session.apply_scene(forge2d::serialize_scene(updated)); + require(session.world().collision_pairs().empty(), + "applying edited scene must change actual collision filters"); + require(session.world().tick() == 0U && session.recording().frames.empty(), + "accepted edit starts a new paused run"); + session.apply_scene("FORGE2D_SCENE 2\nbounds 0 0 1000 1000\nentities 0\n"); + require(session.world().entity_count() == 0U, "empty project is a valid editor boundary"); +} + +void test_replay_limits() { + forge2d::EditorSession session{scene_text}; + const auto old_hash = session.world().state_hash(); + rejects([&] { session.load_replay("bad replay"); }, "malformed replay must be refused"); + rejects( + [&] { + session.load_replay(std::string(forge2d::EditorSession::maximum_input_bytes + 1U, 'x')); + }, + "oversized replay must be refused"); + forge2d::Replay replay{0U, 120U, {}}; + rejects([&] { session.load_replay(forge2d::serialize_replay(replay)); }, + "native editor must not reinterpret a different tick rate"); + replay.tick_rate = 60U; + replay.frames.resize(forge2d::EditorSession::maximum_frames + 1U); + rejects([&] { session.load_replay(forge2d::serialize_replay(replay)); }, + "native replay cap must be checked before state mutation"); + require(session.world().state_hash() == old_hash, "refused replay must preserve project state"); + replay.frames.clear(); + session.load_replay(forge2d::serialize_replay(replay)); + rejects([&] { session.play(); }, "zero-frame replay must remain explicit and paused"); + session.reset(); + for (std::size_t index = 0; index < forge2d::EditorSession::maximum_frames; ++index) + session.step(); + rejects([&] { session.step(); }, "recording must not silently discard input on overflow"); + rejects([&] { session.play(); }, "full recording must not restart without reset/export"); + require(session.world().tick() == forge2d::EditorSession::maximum_frames, + "failed overflow must not advance simulation"); +} + +void test_entities_and_offset_rendering() { + forge2d::EditorSession session{scene_text}; + auto scene = forge2d::parse_scene(scene_text); + const auto prototype = scene.entities.back(); + scene.entities.clear(); + for (std::size_t index = 0; index < forge2d::EditorSession::maximum_entities + 1U; ++index) { + auto entity = prototype; + entity.name = "entity_" + std::to_string(index); + scene.entities.push_back(entity); + } + rejects([&] { session.apply_scene(forge2d::serialize_scene(scene)); }, + "native entity budget must be enforced"); + scene.entities.pop_back(); + session.apply_scene(forge2d::serialize_scene(scene)); + require(session.world().entity_count() == forge2d::EditorSession::maximum_entities, + "exact native entity boundary must remain accepted"); + session.apply_scene(scene_text); + const auto origin_image = session.image(100U, 100U); + scene = forge2d::parse_scene(scene_text); + scene.minimum = {-5000, -5000}; + scene.maximum = {5000, 5000}; + for (auto& entity : scene.entities) { + entity.transform.position.x -= 5000; + entity.transform.position.y -= 5000; + } + session.apply_scene(forge2d::serialize_scene(scene)); + require(session.image(100U, 100U).pixel_hash() == origin_image.pixel_hash(), + "translated world origins must preserve actual framebuffer geometry"); + rejects([&] { static_cast(session.image(0U, 100U)); }, + "invalid framebuffers must remain rejected"); +} +} // namespace + +int main() { + try { + test_authored_input_and_replay(); + test_apply_is_transactional(); + test_replay_limits(); + test_entities_and_offset_rendering(); + std::cout << "Editor model: 4 independent test groups passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/test_editor_files.cpp b/tests/test_editor_files.cpp new file mode 100644 index 0000000..e601be6 --- /dev/null +++ b/tests/test_editor_files.cpp @@ -0,0 +1,81 @@ +/** + * File: test_editor_files.cpp + * Purpose: Exercise real Windows handles, bounded UTF-8 reads, atomic replacement and hardlink + * refusal. Functions/variables and locations: docs/code-index.md. Only a new temporary test + * directory is used. + */ +#include "../apps/editor_files.hpp" + +#include +#include +#include + +namespace { +void require(bool value) { + if (!value) { + throw std::runtime_error("native file invariant failed"); + } +} + +template void refused(Operation operation) { + bool rejected{}; + try { + operation(); + } catch (const std::exception&) { + rejected = true; + } + require(rejected); +} +} // namespace + +int main() { + using namespace forge2d::desktop; + wchar_t temporary[MAX_PATH]{}; + wchar_t name[MAX_PATH]{}; + if (GetTempPathW(MAX_PATH, temporary) == 0U || + GetTempFileNameW(temporary, L"f2d", 0, name) == 0U) { + return 2; + } + DeleteFileW(name); + const std::filesystem::path root{name}; + try { + ensure_profile(root); + const auto file = root / L"scene.scene"; + atomic_text(file, "first\n"); + require(read_text(file) == "first\n"); + atomic_text(file, "replacement\n"); + require(read_text(file) == "replacement\n"); + const auto linked = root / L"linked.scene"; + require(CreateHardLinkW(linked.c_str(), file.c_str(), nullptr) != FALSE); + refused([&] { (void)read_text(file); }); + refused([&] { atomic_text(file, "must not replace"); }); + require(DeleteFileW(linked.c_str()) != FALSE); + require(read_text(file) == "replacement\n"); + refused([&] { (void)read_text(root); }); + refused([&] { (void)read_text(root / L"missing.scene"); }); + { + std::ofstream oversized{root / L"large.scene", std::ios::binary}; + oversized << std::string(1'048'577U, 'x'); + } + refused([&] { (void)read_text(root / L"large.scene"); }); + refused([&] { atomic_text(file, std::string(1'048'577U, 'x')); }); + require(read_text(file) == "replacement\n"); + atomic_text(file, std::string{"\xff\xfe", 2}); + refused([&] { (void)read_text(file); }); + const auto unicode = std::string{"unit \xe2\x86\x92 value"}; + require(utf8(wide(unicode)) == unicode); + atomic_text(file, "final\n"); + require(read_text(file) == "final\n"); + for (const auto& member : std::filesystem::directory_iterator(root)) { + require(member.path().filename().wstring().find(L".tmp-") == std::wstring::npos); + } + std::filesystem::remove_all(root); + std::cout + << "Native file boundary: actual save/replace/read/link/UTF-8/budget cases passed.\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + std::filesystem::remove_all(root); + return 1; + } +}