Compare commits
6
Commits
cf5b702233
...
9e921a5b3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e921a5b3b | ||
|
|
6446f8917e | ||
|
|
45c5d2d188 | ||
|
|
77c145fa02 | ||
|
|
4a434f7855 | ||
|
|
d3217838cb |
@@ -96,3 +96,4 @@ jobs:
|
||||
python-version: '3.12'
|
||||
- run: python -m pip install -r docs/requirements.txt
|
||||
- run: python -m mkdocs build --strict
|
||||
- run: python tests/verify_playable_exports_test.py
|
||||
|
||||
@@ -72,8 +72,8 @@ jobs:
|
||||
run: ctest --preset windows-debug --timeout 180
|
||||
- name: Real Release exports, 2D and 3D execution, incremental Debug rebuild
|
||||
run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e
|
||||
- name: Export and relocate both checked-in playable games
|
||||
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports
|
||||
- name: Export and relocate C++ and Lua playable games
|
||||
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports --include-lua
|
||||
- name: Preserve graphics and export evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
|
||||
+4
-1
@@ -74,13 +74,16 @@ if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_D
|
||||
endif()
|
||||
if(TARGET faset_editor_commands AND TARGET faset_build_service)
|
||||
include(cmake/Plugins.cmake)
|
||||
add_library(faset_editor_session STATIC src/editor/session.cpp)
|
||||
add_library(faset_editor_session STATIC src/editor/session.cpp src/editor/autosave.cpp)
|
||||
target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(faset_editor_session_tests tests/editor_session_tests.cpp)
|
||||
target_link_libraries(faset_editor_session_tests PRIVATE faset_editor_session)
|
||||
target_compile_definitions(faset_editor_session_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME editor_session_settings COMMAND faset_editor_session_tests)
|
||||
add_executable(faset_editor_autosave_tests tests/editor_autosave_tests.cpp)
|
||||
target_link_libraries(faset_editor_autosave_tests PRIVATE faset_editor_session)
|
||||
add_test(NAME editor_autosave COMMAND faset_editor_autosave_tests)
|
||||
endif()
|
||||
endif()
|
||||
foreach(module UI EditorUI Editor Applications)
|
||||
|
||||
@@ -23,5 +23,12 @@ if(BUILD_TESTING)
|
||||
target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring faset_editor_commands)
|
||||
add_dependencies(faset_build_schema_tests faset_build_schema_tool)
|
||||
add_test(NAME build_schema_publication COMMAND faset_build_schema_tests $<TARGET_FILE:faset_build_schema_tool> ${PROJECT_SOURCE_DIR})
|
||||
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
|
||||
# This fixture performs many full cache invalidations and process launches.
|
||||
# Windows runner startup and antivirus overhead exceed 60 seconds even when
|
||||
# individual native fixture commands finish normally.
|
||||
if(WIN32)
|
||||
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 240)
|
||||
else()
|
||||
set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Developer diagnostics
|
||||
|
||||
## Build errors and source navigation
|
||||
|
||||
Build and export jobs report compiler, clang-cl/MSVC, and Lua errors as structured
|
||||
Console rows. Each row shows severity, message, and a one-based source location.
|
||||
Choose **Open source** to open a file under the current project's `Scripts/`
|
||||
directory at that line and column. Diagnostics from engine or external files remain
|
||||
visible but have no source action. The **Jobs** tab shows whether a verified build
|
||||
generation was reused, the elapsed phases, and an expandable raw process log;
|
||||
**Copy full log** retains the complete available output for troubleshooting.
|
||||
|
||||
The same action is available over Editor MCP:
|
||||
|
||||
```json
|
||||
{"command":"faset_source_open","arguments":{"path":"Scripts/Gameplay.cpp","line":17,"column":4}}
|
||||
```
|
||||
|
||||
`faset_script_open` remains Lua-only. Both commands use the project's optional
|
||||
`editor.script_editor` argv array, or Zed by default. The tokens `{file}`,
|
||||
`{line}`, `{column}`, and `{project}` may appear within one argument, for
|
||||
example `["zed", "{file}:{line}:{column}"]`. Faset passes each argument
|
||||
literally to the editor process without shell expansion. An invalid editor
|
||||
command produces a source-editor error without changing the build result.
|
||||
|
||||
The optional Dear ImGui overlay shows renderer counters inside the Editor. The Editor's normal interface remains the retained Faset UI. Enable the diagnostic build explicitly:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -67,6 +67,20 @@ resume and single-step process controls; it cannot read or modify game entities.
|
||||
scenes that have never been saved. Restoring an already open document requires its
|
||||
current revision. Recovery refuses to overwrite an externally changed scene file.
|
||||
|
||||
Long-lived headless and graphical Editor sessions also poll the same autosave
|
||||
controller. With `editor.autosave` enabled (the default), a named dirty scene is
|
||||
saved after two seconds idle. `faset_autosave_status {}` reports `enabled` and each
|
||||
open document's `state`, `revision`, `path`, and `error`. States include `saved`,
|
||||
`pending`, `saving`, `conflict`, `failed`, `save_as_required`, and `disabled`.
|
||||
An unnamed scene remains in recovery until you give it an explicit path. An
|
||||
external disk edit produces `conflict` without overwriting that edit. For scripted
|
||||
workflows, use `faset_document_save` with `expected_revision` when you need a
|
||||
definite save boundary instead of waiting for idle autosave. If a conflict occurs,
|
||||
save to a new project-relative path and compare the two files. Change the
|
||||
preference through `faset_project_settings_get` and `faset_project_settings_set`
|
||||
with the returned project revision and `{"editor":{"autosave":false}}`; the
|
||||
new value applies to the current session and persists for the next Editor launch.
|
||||
|
||||
## Single-command CLI
|
||||
|
||||
The CLI is useful for scripts that do not need an MCP session:
|
||||
|
||||
@@ -50,6 +50,19 @@ outside the Editor, ordinary Save reports a conflict instead of overwriting it.
|
||||
Save your in-memory work to a new path and compare the two versions before replacing
|
||||
anything.
|
||||
|
||||
Autosave is enabled by default. After a named scene has been idle for two seconds,
|
||||
the Editor saves its current revision to that scene file. The status bar shows
|
||||
**Pending autosave**, **Saving**, or **Saved**. An unnamed scene shows **Save As
|
||||
required** until you choose its path. The status bar's **Save As...** action opens
|
||||
an editable, new project-relative path; it never overwrites the conflicting file.
|
||||
|
||||
If the disk file changes outside Faset, autosave stops for that revision and shows
|
||||
**Save conflict**. Use **Save As...** to keep both versions, then compare or reload
|
||||
the original. **Save failed** preserves the unsaved scene in the recovery journal;
|
||||
correct the write error or use Save As. Autosave does not clear Undo history or
|
||||
replace the [recovery journal](#recover-unsaved-work). Recovery still protects
|
||||
unnamed scenes and edits before the idle deadline.
|
||||
|
||||
## Navigate and transform
|
||||
|
||||
- **Right drag:** orbit the 3D view; pan in a 2D scene.
|
||||
@@ -119,13 +132,16 @@ for the C++ iteration loop.
|
||||
## Project settings
|
||||
|
||||
Open **File → Project settings** to change the project name, initial **2D / 3D** type,
|
||||
and start-scene path. Save a scene first, then choose it from the saved-scene list or
|
||||
enter its project-relative path. Choose **Save project** to update
|
||||
`project.faset.json` explicitly. A missing start scene is rejected and the dialog
|
||||
stays open.
|
||||
start-scene path, or **Autosave named scenes** preference. Save a scene first, then
|
||||
choose it from the saved-scene list or enter its project-relative path. Choose
|
||||
**Save project** to update `project.faset.json` explicitly. A missing start scene is
|
||||
rejected and the dialog stays open. The Autosave preference applies immediately;
|
||||
when disabled, the status bar says **Autosave off** and you can still use Ctrl+S or
|
||||
Save As. The project file stores it as `editor.autosave`; omitting it means enabled.
|
||||
|
||||
These settings are used when the project opens again; they do not convert the active
|
||||
scene, switch its dimension, restart the Player, or add a scene Undo entry.
|
||||
The project name, default dimension, and start scene are used when the project opens
|
||||
again; they do not convert the active scene, switch its dimension, restart the Player,
|
||||
or add a scene Undo entry.
|
||||
**Cancel** or **Escape** discards this form's draft. If another writer changes the
|
||||
project settings while the dialog is open, Save reports a revision conflict and
|
||||
keeps your draft. **Reload saved** deliberately discards it and reloads the latest
|
||||
|
||||
@@ -40,7 +40,7 @@ check. For sanitizers, configure with `-DFASET_SANITIZERS=ON`, build the
|
||||
`faset_lua_tests`, `faset_lua_safety_tests`, and `faset_schema_exporter` targets, then
|
||||
run `ctest --test-dir <build> --output-on-failure -R '^lua_'`.
|
||||
|
||||
## Not verified here
|
||||
## Not verified in the September 18 run
|
||||
|
||||
- Windows compilation or execution of the new module.
|
||||
- Graphical/window interaction and real-time Lua reload in a running rendered game.
|
||||
@@ -53,3 +53,20 @@ run `ctest --test-dir <build> --output-on-failure -R '^lua_'`.
|
||||
The renderer-linked CPU checks used the existing Vulkan loader, repo-pinned Vulkan
|
||||
headers and cached Slang, with SDL X11/Wayland disabled. No system graphics packages
|
||||
were installed. This proves linkage and CPU validation, not graphics compatibility.
|
||||
|
||||
## September 24 Release follow-up
|
||||
|
||||
The [Release execution record](lua-release-2026-09-24/README.md) closes the
|
||||
Linux offscreen export gap above. At source commit `846f1f4`, a Release Editor
|
||||
exported the checked-in Lua-only sample into a disposable Unicode-path project.
|
||||
The package was relocated, the source project path was hidden, and its Player
|
||||
validated and rendered 120 frames on an NVIDIA GeForce RTX 2080 Ti with driver
|
||||
595.84 and Khronos validation enabled; zero validation errors were reported.
|
||||
The package manifest has `lua_enabled: true`, declared Lua source and its license,
|
||||
and no project C++ gameplay source. The five focused Release Lua/schema tests
|
||||
passed. This verifies an offscreen run on Linux; a separate physical Windows GPU
|
||||
and manual window interaction have not been tested by this record. Windows
|
||||
SwiftShader execution is configured in CI and needs a passing run before it is
|
||||
claimed as covered.
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Lua-only Release game: relocated execution
|
||||
|
||||
This is a local Linux result for source revision
|
||||
`846f1f459d61ee0f878ab9ad116ac0e75d74d588` on 2026-09-24. The
|
||||
[machine-readable report](report.json) records the exact source input hashes,
|
||||
Release package, Editor binary hash, command arguments and elapsed times. The
|
||||
Editor itself was built with the `linux-release` preset. The exported Player
|
||||
and package also identify their configuration as Release.
|
||||
|
||||
| Check | Observed result |
|
||||
|---|---|
|
||||
| Disposable source | `examples/lua` copied to a Unicode-path temporary project outside the engine tree |
|
||||
| Package | `lua_enabled: true`; 32 hash-checked files, including Lua gameplay source and `Notices/lua/LICENSE.txt`; no project `Gameplay.cpp` or `.hpp` |
|
||||
| Relocation | Generation copied outside the project/engine; original project path hidden before Player launch |
|
||||
| CPU validation | Packaged Player `--validate` succeeded from an unrelated empty working directory |
|
||||
| GPU execution | 120/120 headless 1280×720 frames, 0 validation errors |
|
||||
| Device | NVIDIA GeForce RTX 2080 Ti; proprietary driver 595.84, Vulkan 1.4.329; selected-device details in the report and [Vulkan probe](vulkaninfo-summary.txt) |
|
||||
| Image | Four expected scene colors, including cyan player and gold collectible; raw [PPM](lua.ppm) SHA-256 `e2f4c3b84de75dadd2e31d388d0ba51abc1861f1ec76eb7ba51a4a2dcac720a7` |
|
||||
| Focused Release CTest | 5/5: `lua_contracts`, `lua_safety_contracts`, `lua_cli_contracts`, `lua_player_reload`, `build_schema_publication` |
|
||||
|
||||
The [manifest](lua-manifest.json) binds the package file checksums. The
|
||||
[Player profile](lua-profile.json) contains all 120 per-frame samples and
|
||||
resource/timing definitions; the [validate](lua-validate.json),
|
||||
[render](lua-run.json) and [export](lua-export.json) logs retain raw process
|
||||
results. The [PNG preview](lua.png) is a lossless conversion of the checked
|
||||
PPM.
|
||||
|
||||
To repeat this check from a built checkout, choose a **new, empty** output
|
||||
directory:
|
||||
|
||||
```sh
|
||||
cmake --preset linux-release
|
||||
cmake --build --preset linux-release --target faset_editor --parallel 4
|
||||
python3 tools/verify_playable_exports.py --editor build/linux-release/faset_editor \
|
||||
--output .cache/lua-release-check --only-lua
|
||||
cmake --build --preset linux-release --target faset_player faset_schema_exporter \
|
||||
faset_lua_tests faset_lua_safety_tests faset_build_schema_tests --parallel 4
|
||||
ctest --test-dir build/linux-release --no-tests=error --output-on-failure \
|
||||
-R '^(lua_contracts|lua_safety_contracts|lua_cli_contracts|lua_player_reload|build_schema_publication)$'
|
||||
```
|
||||
|
||||
The render used the Player's synthetic fixed timestep and offscreen Vulkan
|
||||
presentation. It does not establish manual window interaction or physical
|
||||
Windows GPU compatibility. The Windows graphics workflow now includes this Lua
|
||||
package beside both C++ examples under a pinned SwiftShader Vulkan driver; its
|
||||
result must be recorded separately after CI completes.
|
||||
|
||||

|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"asset_generations": {},
|
||||
"build_fingerprint": "55f8ff15cb5e4f241fcf7ee436a52a5a1eed36f2031f12bf44070276d796927e",
|
||||
"configuration": "Release",
|
||||
"executable": "faset_player",
|
||||
"files": [
|
||||
{
|
||||
"path": "README.txt",
|
||||
"sha256": "82bf72bc542643f6c4090e0a5de69b0014ca3fa17c6f9dfb12cc41e44c8b8db6",
|
||||
"size": 262
|
||||
},
|
||||
{
|
||||
"path": "Notices/Faset-NOTICE.txt",
|
||||
"sha256": "30c2e7e80f5153b31ab57675e07246aef9165965d64e36022b426a26449ece3f",
|
||||
"size": 204
|
||||
},
|
||||
{
|
||||
"path": "Notices/dependencies.json",
|
||||
"sha256": "262e8f63cf21c516829b1fedb0900ac90e76d62aa8f78d9ad02d8f24e0359e74",
|
||||
"size": 2468
|
||||
},
|
||||
{
|
||||
"path": "Notices/lua/LICENSE.txt",
|
||||
"sha256": "e42d195c77d3ad7255ee9c874772d3c53998e7f60f9acf453d4630fa161e0bfe",
|
||||
"size": 1126
|
||||
},
|
||||
{
|
||||
"path": "Notices/stb/LICENSE",
|
||||
"sha256": "bebfe904b14301657e4e5d655c811d51fd31b97c455b9cc2d8600d6bac6cff63",
|
||||
"size": 2510
|
||||
},
|
||||
{
|
||||
"path": "Notices/json/LICENSE.MIT",
|
||||
"sha256": "46a65cffd1ea955132d95a8dd921640714a8d6b537d2e4e482d31145ae95b603",
|
||||
"size": 1076
|
||||
},
|
||||
{
|
||||
"path": "Notices/box3d/LICENSE",
|
||||
"sha256": "da5e31a26bf3cfd5ba5c96d6823e480128c81e76c107ef9d3ee5d94789184b90",
|
||||
"size": 1067
|
||||
},
|
||||
{
|
||||
"path": "Notices/box2d/LICENSE",
|
||||
"sha256": "68a3e676d7e94093b102d5cba0d4e04af812040d6f230c3db67a6664574e43d2",
|
||||
"size": 1067
|
||||
},
|
||||
{
|
||||
"path": "Notices/entt/LICENSE",
|
||||
"sha256": "0785027ce472d7c61f05fba664a1d3ba6639c1593ced351ad9e4bed868765d99",
|
||||
"size": 1097
|
||||
},
|
||||
{
|
||||
"path": "Notices/sdl3/LICENSE.txt",
|
||||
"sha256": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127",
|
||||
"size": 884
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuPostCullMain.reflection.json",
|
||||
"sha256": "eed140bbe09c7d14935b688c770261ac0e81677c0974aa811c88ed67ea779b95",
|
||||
"size": 3591
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuHzbMain.reflection.json",
|
||||
"sha256": "589818f670881c64f3aba726ee3bc82fe4b9d835a347f99134e0e157dc4a112e",
|
||||
"size": 2050
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuCullMain.reflection.json",
|
||||
"sha256": "996fe5c7e31c6b8f316d8f290b3be0ae7063eb46c500a661e8715e9b822a2598",
|
||||
"size": 3587
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuShadowMain.reflection.json",
|
||||
"sha256": "9a03a2b692d2b0c758f63fb27760352cfef20a068372ebc137f1ca1797dcd3c2",
|
||||
"size": 2755
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuVertexMain.reflection.json",
|
||||
"sha256": "9f43472c267dbb6a2ddc69e252f7f00c5ca46f376d3c7c27165c8d12a175179d",
|
||||
"size": 3098
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuPostCullMain.spv",
|
||||
"sha256": "62cb72e356346e1511c6d640a3cecb4a6e5283795338299b357edae5f7f52be4",
|
||||
"size": 12460
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuHzbMain.spv",
|
||||
"sha256": "08682362ee2120ed19d260e613285071747c3e436bb09bb6238e46f9cedc2308",
|
||||
"size": 4140
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuCullMain.spv",
|
||||
"sha256": "7691360ec55f41d40d43652f506b051cef8b3a3ddcdcc7db61a7d4d97f9e87d8",
|
||||
"size": 16180
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuShadowMain.spv",
|
||||
"sha256": "ffcdb91e70c41ce9452094865546a009b9203140cf8eeb3ad1b4e5868d82b815",
|
||||
"size": 2404
|
||||
},
|
||||
{
|
||||
"path": "shaders/gpuVertexMain.spv",
|
||||
"sha256": "4647ef4fe1a68ab623ee9c19dac56631907f825cba0becd30869c5221c2a0385",
|
||||
"size": 4928
|
||||
},
|
||||
{
|
||||
"path": "shaders/shadowMain.reflection.json",
|
||||
"sha256": "e3fd0889cd2f569d410f5d7558a4091d9f57f247a1dcf1788462f67525340819",
|
||||
"size": 2645
|
||||
},
|
||||
{
|
||||
"path": "shaders/fragmentMain.reflection.json",
|
||||
"sha256": "72ebf8d32452830650416dc4717d89a4324e97ece7028dfb97c66803c8dce71f",
|
||||
"size": 2649
|
||||
},
|
||||
{
|
||||
"path": "shaders/vertexMain.reflection.json",
|
||||
"sha256": "c584f3b957ebf194a292451362a98590f85ff3cfbfd8f917b1c1265039381777",
|
||||
"size": 2639
|
||||
},
|
||||
{
|
||||
"path": "shaders/shadowMain.spv",
|
||||
"sha256": "7643b4d688492b5ee923b9606f0b0e70343ca05fa67673838206119a6f86d8d8",
|
||||
"size": 784
|
||||
},
|
||||
{
|
||||
"path": "shaders/fragmentMain.spv",
|
||||
"sha256": "f5c4f289917ecab5d00d543c053aac1d10e6cca311fd172daf06d315160a3505",
|
||||
"size": 7436
|
||||
},
|
||||
{
|
||||
"path": "shaders/vertexMain.spv",
|
||||
"sha256": "1ad2631c35d654f48166321ae43d4165043e1b919b14f1dc61caf97b1ada0898",
|
||||
"size": 1224
|
||||
},
|
||||
{
|
||||
"path": "faset_player",
|
||||
"sha256": "c9f626134e0742eb015da1c02ca2587471f30261042830631f55307b1ec4ac71",
|
||||
"size": 6329192
|
||||
},
|
||||
{
|
||||
"path": "project.faset.json",
|
||||
"sha256": "e07b12047f8141c3c4cc8f0e7d0905a5c635375e4494755e34191db0859ab86f",
|
||||
"size": 175
|
||||
},
|
||||
{
|
||||
"path": "Scripts/util/motion.lua",
|
||||
"sha256": "bad3dcdc1081fd1dec589c12dad8dc175b739d77ea52b957ead9ecb4b7cb88a3",
|
||||
"size": 150
|
||||
},
|
||||
{
|
||||
"path": "Scripts/player.lua",
|
||||
"sha256": "1c51810574a3413c46a7cbd0ea2e84a9b802da1206a2b72bb78f6cc951367a11",
|
||||
"size": 1376
|
||||
},
|
||||
{
|
||||
"path": "Scripts/beacon.lua",
|
||||
"sha256": "44d932ebedf66cadb9620f2d926177be2f8d43d71d56aa83611de686af026231",
|
||||
"size": 820
|
||||
},
|
||||
{
|
||||
"path": "scene.fscene",
|
||||
"sha256": "641ba14a1f9c3966b9ec797814fdfa54f954dc5e0d53ab00c645598edef61baf",
|
||||
"size": 1681
|
||||
}
|
||||
],
|
||||
"format": "faset.export",
|
||||
"generation": "38d65abf-1c6d-4e6e-850a-5a4916e36b16",
|
||||
"lua_enabled": true,
|
||||
"lua_fingerprint": "a9261789bb1e0ca38bd40b08b0454165cd51454cba3ad4725dd357812ced6e35",
|
||||
"platform": "linux",
|
||||
"prerequisites": [
|
||||
"Linux x86_64",
|
||||
"Vulkan 1.3 driver",
|
||||
"Compatible glibc and libstdc++ runtime"
|
||||
],
|
||||
"renderer_profile": {
|
||||
"api": "Vulkan 1.3",
|
||||
"materials": [
|
||||
"base-color factor and texture",
|
||||
"metallic and roughness factors"
|
||||
],
|
||||
"required_features": [
|
||||
"dynamicRendering",
|
||||
"synchronization2"
|
||||
],
|
||||
"shadow_map": {
|
||||
"resolution": 1024,
|
||||
"world_extent": 40
|
||||
},
|
||||
"texture_sampling": "linear clamp, one mip level",
|
||||
"unsupported_material_features": [
|
||||
"normal maps",
|
||||
"metallic-roughness maps",
|
||||
"emissive and occlusion maps",
|
||||
"alpha mode selection",
|
||||
"unlit mode",
|
||||
"per-material face culling"
|
||||
]
|
||||
},
|
||||
"scene_hash": "143724a468d711cbdf9a9700310d7b0acef003ce1b21422ca5204e0e565fa02c",
|
||||
"simulation": {
|
||||
"fixed_delta": 0.016666666666666666,
|
||||
"gravity": [
|
||||
0,
|
||||
-9.81,
|
||||
0
|
||||
],
|
||||
"max_catch_up_ticks": 4,
|
||||
"physics_substeps": 4
|
||||
},
|
||||
"units": {
|
||||
"angle": "radian",
|
||||
"coordinates": "right-handed Y-up",
|
||||
"distance": "metre"
|
||||
},
|
||||
"validation_log": "{\"dimension\":2,\"validated\":true}\n",
|
||||
"version": 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"arguments": [
|
||||
"/tmp/faset-playable-exports-yz70irb6/Faset Café 世界/lua/faset_player",
|
||||
"--headless",
|
||||
"--frames",
|
||||
"120",
|
||||
"--capture",
|
||||
"/tmp/faset-playable-exports-yz70irb6/Faset Café 世界/lua/verification.ppm",
|
||||
"--profile",
|
||||
"/tmp/faset-playable-exports-yz70irb6/Faset Café 世界/lua/profile.json"
|
||||
],
|
||||
"cwd": "/tmp/faset-playable-exports-yz70irb6/empty-working-directory",
|
||||
"seconds": 0.6078518449794501,
|
||||
"exit_code": 0,
|
||||
"timed_out": false,
|
||||
"stdout": "{\"device\":\"NVIDIA GeForce RTX 2080 Ti\",\"dimension\":2,\"effective_visibility_mode\":\"direct\",\"frames\":120,\"gpu_visibility_active\":false,\"ticks\":120,\"validation_errors\":0,\"visibility_mode\":\"direct\"}\n",
|
||||
"stderr": "Lua player ready: A/D move, Space jump, E reset\n"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"arguments": [
|
||||
"/tmp/faset-playable-exports-yz70irb6/Faset Café 世界/lua/faset_player",
|
||||
"--validate"
|
||||
],
|
||||
"cwd": "/tmp/faset-playable-exports-yz70irb6/empty-working-directory",
|
||||
"seconds": 0.0062960259965620935,
|
||||
"exit_code": 0,
|
||||
"timed_out": false,
|
||||
"stdout": "{\"dimension\":2,\"validated\":true}\n",
|
||||
"stderr": ""
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"format": "faset.playable-export-verification",
|
||||
"version": 1,
|
||||
"started_utc": "2026-09-23T23:20:41.969369+00:00",
|
||||
"platform": "linux",
|
||||
"engine": "/home/emil/Desktop/.worktrees/Faset_Engine-p1-lua-export",
|
||||
"editor": "/home/emil/Desktop/.worktrees/Faset_Engine-p1-lua-export/build/linux-release/faset_editor",
|
||||
"standalone_root": "/tmp/faset-playable-exports-yz70irb6",
|
||||
"engine_revision": "846f1f459d61ee0f878ab9ad116ac0e75d74d588",
|
||||
"editor_sha256": "03ac75942f5f1d29e9d6fedb21ecffc6c74f4299401dbe84f01ab1b4e9c176a0",
|
||||
"project_workspace": "/tmp/faset-playable-projects-6xvbjr4b",
|
||||
"vulkaninfo_available": true,
|
||||
"driver_override": null,
|
||||
"frames_per_game": 120,
|
||||
"status": "passed",
|
||||
"projects": [
|
||||
{
|
||||
"name": "lua",
|
||||
"dimension": 2,
|
||||
"language": "lua",
|
||||
"source_inputs": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "8392da3c18916400ea628daed094c77c390c553cbdcf822da227bdcd3d86380c"
|
||||
},
|
||||
{
|
||||
"path": "Scenes/main.scene.json",
|
||||
"sha256": "f1ab54150a58b289c47bcc34566ad7ee3025ad931db81e7f5dce70d9fbcac8ab"
|
||||
},
|
||||
{
|
||||
"path": "Scripts/beacon.lua",
|
||||
"sha256": "44d932ebedf66cadb9620f2d926177be2f8d43d71d56aa83611de686af026231"
|
||||
},
|
||||
{
|
||||
"path": "Scripts/player.lua",
|
||||
"sha256": "1c51810574a3413c46a7cbd0ea2e84a9b802da1206a2b72bb78f6cc951367a11"
|
||||
},
|
||||
{
|
||||
"path": "Scripts/util/motion.lua",
|
||||
"sha256": "bad3dcdc1081fd1dec589c12dad8dc175b739d77ea52b957ead9ecb4b7cb88a3"
|
||||
},
|
||||
{
|
||||
"path": "project.faset.json",
|
||||
"sha256": "7e4c0781c1837208b4499a9991ffb702143edf1f933ae9c7e3d2543fd2577747"
|
||||
}
|
||||
],
|
||||
"generation": "38d65abf-1c6d-4e6e-850a-5a4916e36b16",
|
||||
"configuration": "Release",
|
||||
"standalone_directory": "/tmp/faset-playable-exports-yz70irb6/Faset Café 世界/lua",
|
||||
"executable": "faset_player",
|
||||
"package_file_count": 32,
|
||||
"asset_generations": {},
|
||||
"device": "NVIDIA GeForce RTX 2080 Ti",
|
||||
"validation_enabled": true,
|
||||
"driver": {
|
||||
"apiVersion": "1.4.329",
|
||||
"driverVersion": "595.84.0.0",
|
||||
"deviceName": "NVIDIA GeForce RTX 2080 Ti",
|
||||
"driverID": "DRIVER_ID_NVIDIA_PROPRIETARY",
|
||||
"driverName": "NVIDIA",
|
||||
"driverInfo": "595.84"
|
||||
},
|
||||
"validation_errors": 0,
|
||||
"completed_frames": 120,
|
||||
"summary_ms": {
|
||||
"gpu": {
|
||||
"max": 0.585024,
|
||||
"min": 0.558816,
|
||||
"p50": 0.560416,
|
||||
"p95": 0.577312,
|
||||
"samples": 120
|
||||
},
|
||||
"render_call": {
|
||||
"max": 4.487375,
|
||||
"min": 1.483498,
|
||||
"p50": 1.856402,
|
||||
"p95": 2.145527,
|
||||
"samples": 120
|
||||
},
|
||||
"renderer_cpu": {
|
||||
"max": 4.483287,
|
||||
"min": 1.480312,
|
||||
"p50": 1.851913,
|
||||
"p95": 2.141489,
|
||||
"samples": 120
|
||||
},
|
||||
"renderer_readback_cpu": {
|
||||
"max": 2.631884,
|
||||
"min": 0.383573,
|
||||
"p50": 0.508409,
|
||||
"p95": 0.57264,
|
||||
"samples": 120
|
||||
},
|
||||
"simulation": {
|
||||
"max": 0.21704,
|
||||
"min": 0.084289,
|
||||
"p50": 0.122371,
|
||||
"p95": 0.17531,
|
||||
"samples": 120
|
||||
},
|
||||
"snapshot": {
|
||||
"max": 0.113824,
|
||||
"min": 0.046728,
|
||||
"p50": 0.067627,
|
||||
"p95": 0.102965,
|
||||
"samples": 120
|
||||
},
|
||||
"wall": {
|
||||
"max": 4.747145,
|
||||
"min": 1.658538,
|
||||
"p50": 2.05683,
|
||||
"p95": 2.394717,
|
||||
"samples": 120
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"sampled_colors": 4,
|
||||
"sha256": "e2f4c3b84de75dadd2e31d388d0ba51abc1861f1ec76eb7ba51a4a2dcac720a7"
|
||||
},
|
||||
"source_project_paths_unavailable": true,
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"requested_projects": [
|
||||
"lua"
|
||||
],
|
||||
"finished_utc": "2026-09-23T23:22:48.305164+00:00"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
==========
|
||||
VULKANINFO
|
||||
==========
|
||||
|
||||
Vulkan Instance Version: 1.4.341
|
||||
|
||||
|
||||
Instance Extensions: count = 27
|
||||
-------------------------------
|
||||
VK_EXT_acquire_drm_display : extension revision 1
|
||||
VK_EXT_acquire_xlib_display : extension revision 1
|
||||
VK_EXT_debug_report : extension revision 10
|
||||
VK_EXT_debug_utils : extension revision 2
|
||||
VK_EXT_direct_mode_display : extension revision 1
|
||||
VK_EXT_display_surface_counter : extension revision 1
|
||||
VK_EXT_headless_surface : extension revision 1
|
||||
VK_EXT_layer_settings : extension revision 2
|
||||
VK_EXT_surface_maintenance1 : extension revision 1
|
||||
VK_EXT_swapchain_colorspace : extension revision 5
|
||||
VK_KHR_device_group_creation : extension revision 1
|
||||
VK_KHR_display : extension revision 23
|
||||
VK_KHR_external_fence_capabilities : extension revision 1
|
||||
VK_KHR_external_memory_capabilities : extension revision 1
|
||||
VK_KHR_external_semaphore_capabilities : extension revision 1
|
||||
VK_KHR_get_display_properties2 : extension revision 1
|
||||
VK_KHR_get_physical_device_properties2 : extension revision 2
|
||||
VK_KHR_get_surface_capabilities2 : extension revision 1
|
||||
VK_KHR_portability_enumeration : extension revision 1
|
||||
VK_KHR_surface : extension revision 25
|
||||
VK_KHR_surface_maintenance1 : extension revision 1
|
||||
VK_KHR_surface_protected_capabilities : extension revision 1
|
||||
VK_KHR_wayland_surface : extension revision 6
|
||||
VK_KHR_xcb_surface : extension revision 6
|
||||
VK_KHR_xlib_surface : extension revision 6
|
||||
VK_LUNARG_direct_driver_loading : extension revision 1
|
||||
VK_NV_display_stereo : extension revision 1
|
||||
|
||||
Instance Layers: count = 12
|
||||
---------------------------
|
||||
VK_LAYER_INTEL_nullhw INTEL NULL HW 1.1.73 version 1
|
||||
VK_LAYER_KHRONOS_validation Khronos Validation Layer 1.4.341 version 1
|
||||
VK_LAYER_MESA_anti_lag Open-source implementation of the VK_AMD_anti_lag extension. 1.4.303 version 1
|
||||
VK_LAYER_MESA_device_select Linux device selection layer 1.4.303 version 1
|
||||
VK_LAYER_MESA_overlay Mesa Overlay layer 1.4.303 version 1
|
||||
VK_LAYER_MESA_screenshot Mesa Screenshot layer 1.4.303 version 1
|
||||
VK_LAYER_NV_optimus NVIDIA Optimus layer 1.4.329 version 1
|
||||
VK_LAYER_NV_present NVIDIA Presentation Layer 1.4.329 version 1
|
||||
VK_LAYER_VALVE_steam_fossilize_32 Steam Pipeline Caching Layer 1.3.207 version 1
|
||||
VK_LAYER_VALVE_steam_fossilize_64 Steam Pipeline Caching Layer 1.3.207 version 1
|
||||
VK_LAYER_VALVE_steam_overlay_32 Steam Overlay Layer 1.3.207 version 1
|
||||
VK_LAYER_VALVE_steam_overlay_64 Steam Overlay Layer 1.3.207 version 1
|
||||
|
||||
Devices:
|
||||
========
|
||||
GPU0:
|
||||
apiVersion = 1.4.329
|
||||
driverVersion = 595.84.0.0
|
||||
vendorID = 0x10de
|
||||
deviceID = 0x1e04
|
||||
deviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
|
||||
deviceName = NVIDIA GeForce RTX 2080 Ti
|
||||
driverID = DRIVER_ID_NVIDIA_PROPRIETARY
|
||||
driverName = NVIDIA
|
||||
driverInfo = 595.84
|
||||
conformanceVersion = 1.4.3.3
|
||||
deviceUUID = 2c71c7ba-97ac-c78e-2c06-af362631f3fe
|
||||
driverUUID = 027cbdd1-c478-513e-af69-ebc9eaa7de87
|
||||
GPU1:
|
||||
apiVersion = 1.4.335
|
||||
driverVersion = 26.0.8
|
||||
vendorID = 0x10005
|
||||
deviceID = 0x0000
|
||||
deviceType = PHYSICAL_DEVICE_TYPE_CPU
|
||||
deviceName = llvmpipe (LLVM 21.1.8, 256 bits)
|
||||
driverID = DRIVER_ID_MESA_LLVMPIPE
|
||||
driverName = llvmpipe
|
||||
driverInfo = Mesa 26.0.8-1ubuntu0.3 (LLVM 21.1.8)
|
||||
conformanceVersion = 1.3.1.1
|
||||
deviceUUID = 6d657361-3236-2e30-2e38-2d3175627500
|
||||
driverUUID = 6c6c766d-7069-7065-5555-494400000000
|
||||
WARNING: [Loader Message] Code 0 : ICD for selected physical device does not export vkGetPhysicalDeviceDisplayPlanePropertiesKHR!
|
||||
WARNING: [Loader Message] Code 0 : ICD for selected physical device does not export vkGetPhysicalDeviceDisplayPropertiesKHR!
|
||||
@@ -25,7 +25,8 @@ class AuthoringService {
|
||||
const Json& operations, const std::string& idempotency_key = "");
|
||||
Json undo(const std::string& document, std::uint64_t expected_revision);
|
||||
Json redo(const std::string& document, std::uint64_t expected_revision);
|
||||
Json save(const std::string& document, const std::filesystem::path& relative = {});
|
||||
Json save(const std::string& document, const std::filesystem::path& relative = {},
|
||||
std::optional<std::uint64_t> expected_revision = std::nullopt);
|
||||
Json recovery_documents() const;
|
||||
Json recover(const std::string& document,
|
||||
std::optional<std::uint64_t> expected_revision = std::nullopt);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <faset/core/json.hpp>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace faset::editor {
|
||||
|
||||
class AutosaveController {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using SaveFn = std::function<Json(const std::string&, std::uint64_t)>;
|
||||
explicit AutosaveController(SaveFn save);
|
||||
|
||||
// Called from the Editor event loop with an injected monotonic clock for
|
||||
// deterministic tests. Only a named dirty scene becomes eligible to save.
|
||||
void observe(const Json& documents, Clock::time_point now, bool enabled);
|
||||
Json status() const;
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::uint64_t revision{};
|
||||
std::string path, state = "saved", error;
|
||||
Clock::time_point deadline{};
|
||||
};
|
||||
SaveFn save_;
|
||||
mutable std::mutex mutex_;
|
||||
std::map<std::string, Entry> entries_;
|
||||
bool enabled_ = true;
|
||||
};
|
||||
|
||||
} // namespace faset::editor
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/editor/autosave.hpp>
|
||||
#include <faset/editor/commands.hpp>
|
||||
#include <faset/editor/plugins.hpp>
|
||||
#include <memory>
|
||||
@@ -62,6 +63,8 @@ class Session {
|
||||
Commands commands_;
|
||||
assets::AssetPipeline assets_;
|
||||
BuildService builds_;
|
||||
AutosaveController autosave_;
|
||||
bool autosave_enabled_ = true;
|
||||
std::map<std::string, std::shared_ptr<ImportTask>> imports_;
|
||||
std::vector<std::jthread> workers_;
|
||||
std::vector<std::string> logs_;
|
||||
|
||||
@@ -573,9 +573,12 @@ Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
|
||||
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
|
||||
return history(id, revision, true);
|
||||
}
|
||||
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) {
|
||||
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative,
|
||||
std::optional<std::uint64_t> expected_revision) {
|
||||
std::lock_guard lock(mutex_);
|
||||
auto& current = state(id);
|
||||
if (expected_revision)
|
||||
check_revision(current.revision, *expected_revision);
|
||||
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
|
||||
require(!selected.empty(), "save.path", "Choose a scene path before saving");
|
||||
const auto path = project_path(root_, selected);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#include <faset/editor/autosave.hpp>
|
||||
|
||||
#include <faset/core/error.hpp>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::editor {
|
||||
|
||||
AutosaveController::AutosaveController(SaveFn save) : save_(std::move(save)) {
|
||||
if (!save_)
|
||||
throw std::invalid_argument("Autosave requires a save callback");
|
||||
}
|
||||
|
||||
void AutosaveController::observe(const Json& documents, Clock::time_point now, bool enabled) {
|
||||
std::lock_guard lock(mutex_);
|
||||
enabled_ = enabled;
|
||||
std::set<std::string> present;
|
||||
for (const auto& document : documents) {
|
||||
const auto id = document.at("id").get<std::string>();
|
||||
const auto revision = document.at("revision").get<std::uint64_t>();
|
||||
const auto path = document.value("path", std::string());
|
||||
const auto dirty = document.value("dirty", false);
|
||||
present.insert(id);
|
||||
auto [found, inserted] = entries_.try_emplace(id);
|
||||
auto& entry = found->second;
|
||||
const auto changed = inserted || entry.revision != revision || entry.path != path;
|
||||
const auto prior_state = entry.state;
|
||||
entry.revision = revision;
|
||||
entry.path = path;
|
||||
if (!dirty) {
|
||||
entry.state = "saved";
|
||||
entry.error.clear();
|
||||
continue;
|
||||
}
|
||||
if (path.empty()) {
|
||||
entry.state = "save_as_required";
|
||||
entry.error.clear();
|
||||
continue;
|
||||
}
|
||||
if (!enabled) {
|
||||
entry.state = "disabled";
|
||||
entry.error.clear();
|
||||
continue;
|
||||
}
|
||||
if (changed || prior_state == "disabled" || prior_state == "saved" ||
|
||||
prior_state == "save_as_required") {
|
||||
entry.state = "pending";
|
||||
entry.error.clear();
|
||||
entry.deadline = now + std::chrono::seconds(2);
|
||||
}
|
||||
if (entry.state != "pending" || now < entry.deadline)
|
||||
continue;
|
||||
entry.state = "saving";
|
||||
try {
|
||||
const auto saved = save_(id, revision);
|
||||
if (saved.at("revision").get<std::uint64_t>() != revision ||
|
||||
saved.at("dirty").get<bool>())
|
||||
throw std::runtime_error("Save returned an unexpected document revision");
|
||||
entry.state = "saved";
|
||||
entry.error.clear();
|
||||
} catch (const Error& error) {
|
||||
entry.state = error.code() == "save.disk_conflict" ||
|
||||
error.code() == "revision.conflict"
|
||||
? "conflict"
|
||||
: "failed";
|
||||
entry.error = error.what();
|
||||
} catch (const std::exception& error) {
|
||||
entry.state = "failed";
|
||||
entry.error = error.what();
|
||||
}
|
||||
}
|
||||
for (auto it = entries_.begin(); it != entries_.end();)
|
||||
it = present.contains(it->first) ? std::next(it) : entries_.erase(it);
|
||||
}
|
||||
|
||||
Json AutosaveController::status() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
Json documents = Json::array();
|
||||
for (const auto& [id, entry] : entries_)
|
||||
documents.push_back({{"id", id},
|
||||
{"state", entry.state},
|
||||
{"revision", entry.revision},
|
||||
{"path", entry.path},
|
||||
{"error", entry.error}});
|
||||
return {{"enabled", enabled_}, {"documents", documents}};
|
||||
}
|
||||
|
||||
} // namespace faset::editor
|
||||
@@ -28,7 +28,9 @@ std::string strip_ansi(std::string_view text) {
|
||||
}
|
||||
std::string normalize_path(std::string path) {
|
||||
std::replace(path.begin(), path.end(), '\\', '/');
|
||||
path = std::filesystem::path(path).lexically_normal().generic_string();
|
||||
// Compiler output is UTF-8. Constructing a path from a narrow string on
|
||||
// Windows can decode it through the process code page and lose Unicode.
|
||||
path = generic_path_to_utf8(path_from_utf8(path).lexically_normal());
|
||||
while (path.size() > 1 && path.back() == '/')
|
||||
path.pop_back();
|
||||
return path;
|
||||
@@ -46,7 +48,7 @@ std::string project_source(std::string raw, const std::filesystem::path& project
|
||||
if (raw.starts_with("lua: "))
|
||||
raw.erase(0, 5);
|
||||
std::replace(raw.begin(), raw.end(), '\\', '/');
|
||||
for (const auto& component : std::filesystem::path(raw))
|
||||
for (const auto& component : path_from_utf8(raw))
|
||||
if (component == "..")
|
||||
return {};
|
||||
const auto path = normalize_path(std::move(raw));
|
||||
|
||||
@@ -103,9 +103,13 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
||||
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
|
||||
add("faset_document_save",
|
||||
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
||||
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
|
||||
object_schema({{"document", text}, {"path", text},
|
||||
{"expected_revision", integer}}, {"document"}), [&](const Json& args) {
|
||||
std::optional<std::uint64_t> expected;
|
||||
if (args.contains("expected_revision"))
|
||||
expected = args.at("expected_revision").get<std::uint64_t>();
|
||||
return authoring_.save(args.at("document"),
|
||||
path_from_utf8(args.value("path", std::string())));
|
||||
path_from_utf8(args.value("path", std::string())), expected);
|
||||
});
|
||||
add(
|
||||
"faset_schema",
|
||||
|
||||
+155
-6
@@ -168,7 +168,7 @@ struct EditorUI::Impl {
|
||||
Json instance_selection = Json::array(), template_conflicts = Json::array();
|
||||
std::string document, selected, source_file, asset_filter,
|
||||
active_bottom = "assets", menu, command_name = "faset_documents", status = "Ready",
|
||||
gizmo_mode = "Move";
|
||||
gizmo_mode = "Move", expanded_job_log;
|
||||
Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(),
|
||||
recovery = Json::array();
|
||||
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
|
||||
@@ -186,6 +186,7 @@ struct EditorUI::Impl {
|
||||
bool project_settings_open = false;
|
||||
Json project_settings_state;
|
||||
int project_settings_dimension = 3;
|
||||
bool project_settings_autosave = true;
|
||||
std::string project_settings_error;
|
||||
bool project_switch_enabled = true, project_switch_requested = false,
|
||||
project_switch_warning = false;
|
||||
@@ -601,6 +602,20 @@ struct EditorUI::Impl {
|
||||
schema_status.visible = false;
|
||||
schema_status.enabled = false;
|
||||
schema_status.tooltip = "Choose Build to refresh gameplay fields in Inspector";
|
||||
auto& autosave_status = statusrow.add(Kind::Label, "autosave-status", "Saved");
|
||||
autosave_status.layout.width = 160;
|
||||
autosave_status.enabled = false;
|
||||
auto& autosave_action = button(statusrow, "autosave-save-as", "Save As...", [this] {
|
||||
const auto path = current.value("path", std::string());
|
||||
const auto directory = path.empty() ? std::string("Scenes/")
|
||||
: path.substr(0, path.find_last_of('/') + 1);
|
||||
ui.update_text("save-path",
|
||||
directory + "Recovered-" + new_id().substr(0, 8) + ".scene.json",
|
||||
true);
|
||||
menu = "File";
|
||||
}, 100);
|
||||
autosave_action.visible = false;
|
||||
autosave_action.tooltip = "Save this scene to a new project-relative path";
|
||||
label(statusrow, "renderer-status", "Vulkan", 225);
|
||||
statusrow.find("renderer-status")->enabled = false;
|
||||
build_overlays();
|
||||
@@ -792,12 +807,20 @@ struct EditorUI::Impl {
|
||||
}
|
||||
label(project, "project-settings-start-label", "Start scene (saved project-relative path)");
|
||||
project.add(Kind::TextField, "project-settings-start");
|
||||
auto& autosave = project.add(Kind::Checkbox, "project-settings-autosave",
|
||||
"Autosave named scenes after 2 seconds idle");
|
||||
autosave.layout.height = 30;
|
||||
autosave.checked = true;
|
||||
autosave.on_commit = [this](Widget& widget) {
|
||||
project_settings_autosave = widget.checked;
|
||||
};
|
||||
autosave.tooltip = "Unsaved scenes remain in recovery until you choose Save As";
|
||||
auto& scenes = project.add(Kind::Column, "project-settings-scenes");
|
||||
scenes.layout.height = 104;
|
||||
scenes.layout.scroll = true;
|
||||
scenes.layout.gap = 1;
|
||||
label(project, "project-settings-note",
|
||||
"Applies on next project open. Scene Undo is unchanged.");
|
||||
"Autosave applies now; other settings on next open. Scene Undo is unchanged.");
|
||||
auto& project_actions = project.add(Kind::Row, "project-settings-actions");
|
||||
project_actions.layout.height = 32;
|
||||
button(
|
||||
@@ -1258,6 +1281,42 @@ struct EditorUI::Impl {
|
||||
(current.value("dirty", false) ? " *" : "");
|
||||
ui.find("project-title")->tooltip = project_name + " / " + scene_name;
|
||||
ui.find("status")->text = status;
|
||||
const auto autosave = call("faset_autosave_status");
|
||||
std::string save_state = "saved", save_error;
|
||||
if (!autosave.is_null()) {
|
||||
if (!autosave.value("enabled", true))
|
||||
save_state = "disabled";
|
||||
for (const auto& entry : autosave.at("documents"))
|
||||
if (entry.at("id") == document) {
|
||||
save_state = entry.at("state").get<std::string>();
|
||||
save_error = entry.value("error", std::string());
|
||||
break;
|
||||
}
|
||||
if (!autosave.value("enabled", true) && save_state == "saved")
|
||||
save_state = "disabled";
|
||||
}
|
||||
auto& save_label = *ui.find("autosave-status");
|
||||
save_label.text = save_state == "pending" ? "Pending autosave"
|
||||
: save_state == "saving" ? "Saving"
|
||||
: save_state == "conflict" ? "Save conflict"
|
||||
: save_state == "failed" ? "Save failed"
|
||||
: save_state == "save_as_required" ? "Save As required"
|
||||
: save_state == "disabled" ? "Autosave off"
|
||||
: "Saved";
|
||||
save_label.tooltip = save_state == "conflict"
|
||||
? "Scene file changed outside Faset. Use Save As to keep both "
|
||||
"versions, then compare or reload. " + save_error
|
||||
: save_state == "failed"
|
||||
? "Scene save failed. Your recovery journal remains available. "
|
||||
"Use Save As or correct the error. " + save_error
|
||||
: save_state == "save_as_required"
|
||||
? "Choose Save As; unnamed scenes remain in recovery only"
|
||||
: save_state == "disabled"
|
||||
? "Autosave is off in Project settings; use Save manually"
|
||||
: "Scene autosave status";
|
||||
ui.find("autosave-save-as")->visible =
|
||||
save_state == "conflict" || save_state == "failed" ||
|
||||
save_state == "save_as_required";
|
||||
ui.find("renderer-status")->text =
|
||||
"Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects";
|
||||
}
|
||||
@@ -2043,6 +2102,45 @@ struct EditorUI::Impl {
|
||||
trim_children(conflict_list, conflict_keep);
|
||||
auto& console = *ui.find("console-items");
|
||||
std::set<std::string> keep;
|
||||
std::size_t shown_diagnostics{};
|
||||
for (const auto& job : all_jobs) {
|
||||
const auto id = job.at("id").get<std::string>();
|
||||
const auto diagnostics = job.value("diagnostics", Json::array());
|
||||
if (!diagnostics.is_array())
|
||||
continue;
|
||||
for (std::size_t index = 0; index < diagnostics.size() && shown_diagnostics < 60;
|
||||
++index, ++shown_diagnostics) {
|
||||
const auto& diagnostic = diagnostics[index];
|
||||
const auto row_id = "diagnostic-" + id + "-" + std::to_string(index);
|
||||
auto& row = console.add(Kind::Row, row_id);
|
||||
row.layout.height = 27;
|
||||
row.layout.gap = 8;
|
||||
keep.insert(row_id);
|
||||
const auto severity = diagnostic.value("severity", std::string("error"));
|
||||
label(row, row_id + "-severity", severity, 68);
|
||||
auto message = diagnostic.value("message", std::string());
|
||||
auto& detail = row.add(Kind::Label, row_id + "-message", message);
|
||||
detail.layout.flex = 1;
|
||||
detail.tooltip = diagnostic.value("phase", std::string()) + ": " + message;
|
||||
const auto file = diagnostic.value("file", std::string());
|
||||
const auto line = diagnostic.value("line", 1);
|
||||
const auto column = diagnostic.value("column", 1);
|
||||
const auto location = file.empty()
|
||||
? std::string("—")
|
||||
: file + ":" + std::to_string(line) + ":" +
|
||||
std::to_string(column);
|
||||
label(row, row_id + "-location", location, 235);
|
||||
auto& open = button(
|
||||
row, row_id + "-open", "Open source",
|
||||
[this, file, line, column] {
|
||||
call("faset_source_open",
|
||||
{{"path", file}, {"line", line}, {"column", column}});
|
||||
},
|
||||
115);
|
||||
open.enabled = !file.empty();
|
||||
open.appearance = ui::Appearance::Quiet;
|
||||
}
|
||||
}
|
||||
const auto& logs = session.logs();
|
||||
for (std::size_t i = logs.size() > 150 ? logs.size() - 150 : 0; i < logs.size(); ++i) {
|
||||
std::string line = logs[i];
|
||||
@@ -2065,11 +2163,25 @@ struct EditorUI::Impl {
|
||||
row.layout.height = 28;
|
||||
keep.insert(row.id);
|
||||
const auto state = job.value("state", std::string());
|
||||
const auto result = job.value("result", Json::object());
|
||||
std::string reuse;
|
||||
if (result.is_object() && result.value("schema_cache_hit", false))
|
||||
reuse = " · verified reuse";
|
||||
if (result.is_object() && result.contains("phase_times_ms") &&
|
||||
result.at("phase_times_ms").is_object())
|
||||
reuse += " · " +
|
||||
std::to_string(result.at("phase_times_ms").value("total", 0)) + " ms";
|
||||
auto& text = row.add(Kind::Label, "job-text-" + id,
|
||||
job.value("kind", std::string("Job")) + " · " + state + " · " +
|
||||
job.value("stage", std::string()) + " " +
|
||||
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
|
||||
std::to_string(int(job.value("progress", 0.0) * 100)) + "%" +
|
||||
reuse);
|
||||
text.layout.flex = 1;
|
||||
auto& log_button = button(
|
||||
row, "job-log-toggle-" + id,
|
||||
expanded_job_log == id ? "Hide log" : "View log",
|
||||
[this, id] { expanded_job_log = expanded_job_log == id ? "" : id; }, 86);
|
||||
log_button.appearance = ui::Appearance::Quiet;
|
||||
auto& cancel = button(
|
||||
row, "job-cancel-" + id, "Cancel",
|
||||
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
|
||||
@@ -2078,6 +2190,38 @@ struct EditorUI::Impl {
|
||||
row, "job-review-" + id, "Review removals", [this] { active_bottom = "conflicts"; },
|
||||
135);
|
||||
review.visible = job.value("kind", std::string()) == "import" && state == "conflict";
|
||||
if (expanded_job_log == id) {
|
||||
const auto panel_id = "job-log-panel-" + id;
|
||||
auto& panel = jobs.add(Kind::Column, panel_id);
|
||||
panel.layout.height = 170;
|
||||
panel.layout.scroll = true;
|
||||
panel.layout.gap = 1;
|
||||
keep.insert(panel_id);
|
||||
std::set<std::string> log_keep;
|
||||
const auto raw = job.value("log", std::string());
|
||||
auto& copy = button(panel, panel_id + "-copy", "Copy full log",
|
||||
[this, raw] { renderer.set_clipboard(raw); }, 110);
|
||||
copy.appearance = ui::Appearance::Quiet;
|
||||
log_keep.insert(copy.id);
|
||||
std::vector<std::string> lines;
|
||||
std::size_t begin{};
|
||||
while (begin < raw.size()) {
|
||||
const auto end = raw.find('\n', begin);
|
||||
lines.push_back(raw.substr(begin, end == std::string::npos
|
||||
? std::string::npos
|
||||
: end - begin));
|
||||
if (end == std::string::npos)
|
||||
break;
|
||||
begin = end + 1;
|
||||
}
|
||||
const auto first = lines.size() > 100 ? lines.size() - 100 : 0;
|
||||
for (std::size_t index = first; index < lines.size(); ++index) {
|
||||
const auto line_id = panel_id + "-line-" + std::to_string(index);
|
||||
label(panel, line_id, lines[index].substr(0, 600));
|
||||
log_keep.insert(line_id);
|
||||
}
|
||||
trim_children(panel, log_keep);
|
||||
}
|
||||
}
|
||||
if (keep.empty()) {
|
||||
label(jobs, "jobs-empty", "No active import, build or export jobs.");
|
||||
@@ -2094,7 +2238,7 @@ struct EditorUI::Impl {
|
||||
refresh_project_settings();
|
||||
const bool modal = palette || !recovery.empty() || simulation_open ||
|
||||
project_switch_warning || project_settings_open;
|
||||
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel"})
|
||||
for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"})
|
||||
ui.find(id)->enabled = !modal;
|
||||
const bool file = menu == "File" || menu == "Faset",
|
||||
edit = menu == "Edit" || menu == "Scene",
|
||||
@@ -2149,6 +2293,8 @@ struct EditorUI::Impl {
|
||||
project_settings_state = result;
|
||||
const auto& settings = result.at("settings");
|
||||
project_settings_dimension = settings.value("dimension", 3);
|
||||
project_settings_autosave = settings.at("editor").at("autosave").get<bool>();
|
||||
ui.find("project-settings-autosave")->checked = project_settings_autosave;
|
||||
ui.update_text("project-settings-name", settings.value("name", std::string()), true);
|
||||
ui.update_text("project-settings-start", settings.value("start_scene", std::string()),
|
||||
true);
|
||||
@@ -2215,7 +2361,9 @@ struct EditorUI::Impl {
|
||||
project_settings_error = "Enter a project name.";
|
||||
return;
|
||||
}
|
||||
Json changes = {{"name", name}, {"dimension", project_settings_dimension}};
|
||||
Json changes = {{"name", name},
|
||||
{"dimension", project_settings_dimension},
|
||||
{"editor", {{"autosave", project_settings_autosave}}}};
|
||||
if (!start.empty() ||
|
||||
!project_settings_state.at("settings").value("start_scene", std::string()).empty())
|
||||
changes["start_scene"] = start;
|
||||
@@ -2229,7 +2377,7 @@ struct EditorUI::Impl {
|
||||
project_settings_state = result;
|
||||
project_settings_open = false;
|
||||
project_settings_error.clear();
|
||||
status = "Project settings saved for the next project open";
|
||||
status = "Project settings saved; autosave preference applies now";
|
||||
}
|
||||
void refresh_project_settings() {
|
||||
auto* panel = ui.find("project-settings-panel");
|
||||
@@ -2240,6 +2388,7 @@ struct EditorUI::Impl {
|
||||
panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f);
|
||||
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
|
||||
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
|
||||
ui.find("project-settings-autosave")->checked = project_settings_autosave;
|
||||
ui.find("project-settings-error")->text = project_settings_error;
|
||||
for (auto& choice : ui.find("project-settings-scenes")->children)
|
||||
if (choice->kind == Kind::TreeRow)
|
||||
|
||||
+84
-36
@@ -23,6 +23,52 @@ BuildConfig build_config(const SessionConfig& config) {
|
||||
result.cache_root = config.project_root / ".faset/cache";
|
||||
return result;
|
||||
}
|
||||
struct ProjectState {
|
||||
Json value;
|
||||
std::string revision;
|
||||
};
|
||||
ProjectState read_project_state(const std::filesystem::path& project_root) {
|
||||
const auto path = project_root / "project.faset.json";
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto value = read_json(path);
|
||||
// The revision represents persisted content, including whether a
|
||||
// default-valued setting was explicitly written by another client.
|
||||
const auto revision = sha256(value.dump());
|
||||
require(value.is_object() && value.value("format", "") == "faset.project" &&
|
||||
value.value("version", 0) == 1,
|
||||
"project.version", "Unsupported project format or version");
|
||||
require(value.contains("name") && value.at("name").is_string() &&
|
||||
!value.at("name").get<std::string>().empty(),
|
||||
"project.name", "Project name must be a nonempty string");
|
||||
const auto dimension = value.value("dimension", 3);
|
||||
require(dimension == 2 || dimension == 3, "project.dimension",
|
||||
"Project dimension must be 2 or 3");
|
||||
if (value.contains("start_scene")) {
|
||||
require(value.at("start_scene").is_string(), "project.start_scene",
|
||||
"Project start_scene must be a relative path");
|
||||
const auto scene = value.at("start_scene").get<std::string>();
|
||||
if (!scene.empty())
|
||||
project_path(project_root, path_from_utf8(scene));
|
||||
}
|
||||
if (value.contains("editor"))
|
||||
require(value.at("editor").is_object(), "project.editor",
|
||||
"Project editor settings must be an object");
|
||||
else
|
||||
value["editor"] = Json::object();
|
||||
if (value["editor"].contains("autosave"))
|
||||
require(value["editor"]["autosave"].is_boolean(), "project.autosave",
|
||||
"Project autosave setting must be true or false");
|
||||
else
|
||||
value["editor"]["autosave"] = true;
|
||||
return {std::move(value), revision};
|
||||
}
|
||||
Json value = {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", path_to_utf8(project_root.filename())},
|
||||
{"dimension", 3},
|
||||
{"editor", {{"autosave", true}}}};
|
||||
return {value, sha256(value.dump())};
|
||||
}
|
||||
Json resolved_or_throw(Commands& commands, const std::string& id) {
|
||||
const auto resolved = commands.resolved_scene(id);
|
||||
if (!resolved.at("conflicts").empty())
|
||||
@@ -52,7 +98,13 @@ struct Session::ImportTask {
|
||||
};
|
||||
Session::Session(SessionConfig config)
|
||||
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
|
||||
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
|
||||
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)),
|
||||
autosave_([this](const std::string& document, std::uint64_t revision) {
|
||||
return authoring_.save(document, {}, revision);
|
||||
}) {
|
||||
autosave_enabled_ = project().at("editor").at("autosave").get<bool>();
|
||||
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
|
||||
autosave_enabled_);
|
||||
register_commands();
|
||||
plugins_ = std::make_unique<PluginManager>(
|
||||
commands_, [this](std::string message) { log(std::move(message)); });
|
||||
@@ -83,31 +135,7 @@ void Session::log(std::string value) {
|
||||
logs_.erase(logs_.begin(), logs_.begin() + 100);
|
||||
}
|
||||
Json Session::project() const {
|
||||
const auto path = config_.project_root / "project.faset.json";
|
||||
if (std::filesystem::exists(path)) {
|
||||
const auto value = read_json(path);
|
||||
require(value.is_object() && value.value("format", "") == "faset.project" &&
|
||||
value.value("version", 0) == 1,
|
||||
"project.version", "Unsupported project format or version");
|
||||
require(value.contains("name") && value.at("name").is_string() &&
|
||||
!value.at("name").get<std::string>().empty(),
|
||||
"project.name", "Project name must be a nonempty string");
|
||||
const auto dimension = value.value("dimension", 3);
|
||||
require(dimension == 2 || dimension == 3, "project.dimension",
|
||||
"Project dimension must be 2 or 3");
|
||||
if (value.contains("start_scene")) {
|
||||
require(value.at("start_scene").is_string(), "project.start_scene",
|
||||
"Project start_scene must be a relative path");
|
||||
const auto scene = value.at("start_scene").get<std::string>();
|
||||
if (!scene.empty())
|
||||
project_path(config_.project_root, path_from_utf8(scene));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", path_to_utf8(config_.project_root.filename())},
|
||||
{"dimension", 3}};
|
||||
return read_project_state(config_.project_root).value;
|
||||
}
|
||||
void Session::scaffold(const std::string& name, int dimension) {
|
||||
builds_.scaffold(name, dimension);
|
||||
@@ -297,12 +325,19 @@ void Session::poll() {
|
||||
if (status == "failed" || status == "conflict")
|
||||
log("Asset import " + status + ": " + value.value("error", std::string()));
|
||||
}
|
||||
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
|
||||
autosave_enabled_);
|
||||
}
|
||||
void Session::register_commands() {
|
||||
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
|
||||
auto schema = [](Json properties, Json required = Json::array()) {
|
||||
return Commands::object_schema(std::move(properties), std::move(required));
|
||||
};
|
||||
commands_.add("faset_autosave_status",
|
||||
"Read revision-aware scene autosave state. Unnamed scenes remain in recovery "
|
||||
"until explicitly saved with a path.",
|
||||
schema(Json::object()),
|
||||
[this](const Json&) { return autosave_.status(); }, true);
|
||||
commands_.add(
|
||||
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
|
||||
schema(Json::object()),
|
||||
@@ -336,23 +371,25 @@ void Session::register_commands() {
|
||||
"faset_project_settings_get", "Read project settings and their content revision.",
|
||||
schema(Json::object()),
|
||||
[&](const Json&) {
|
||||
const auto value = project();
|
||||
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
|
||||
const auto state = read_project_state(config_.project_root);
|
||||
return Json{{"settings", state.value}, {"revision", state.revision}};
|
||||
},
|
||||
true);
|
||||
commands_.add(
|
||||
"faset_project_settings_set",
|
||||
"Save project name, initial scene dimension or start scene with an expected content "
|
||||
"revision. Applies on the next project open; does not change the active scene or its Undo "
|
||||
"history.",
|
||||
"Save project name, initial scene dimension, start scene or editor.autosave with an "
|
||||
"expected content revision. Autosave applies immediately; other settings apply on the "
|
||||
"next project open without changing scene Undo history.",
|
||||
schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}),
|
||||
[&](const Json& args) {
|
||||
auto value = project();
|
||||
require(args.at("revision") == sha256(value.dump()), "revision.conflict",
|
||||
auto state = read_project_state(config_.project_root);
|
||||
auto value = std::move(state.value);
|
||||
require(args.at("revision") == state.revision, "revision.conflict",
|
||||
"Project settings changed; reload them before saving");
|
||||
const auto& changes = args.at("settings");
|
||||
for (const auto& [key, field] : changes.items()) {
|
||||
require(key == "name" || key == "dimension" || key == "start_scene",
|
||||
require(key == "name" || key == "dimension" || key == "start_scene" ||
|
||||
key == "editor",
|
||||
"project.setting", "Unknown editable project setting: " + key);
|
||||
if (key == "name")
|
||||
require(field.is_string() && !field.get<std::string>().empty(), "project.name",
|
||||
@@ -360,7 +397,15 @@ void Session::register_commands() {
|
||||
else if (key == "dimension")
|
||||
require(field.is_number_integer() && (field == 2 || field == 3),
|
||||
"project.dimension", "Initial scene dimension must be 2 or 3");
|
||||
else {
|
||||
else if (key == "editor") {
|
||||
require(field.is_object() && field.size() == 1 &&
|
||||
field.contains("autosave"),
|
||||
"project.setting", "Only editor.autosave can be changed here");
|
||||
require(field.at("autosave").is_boolean(), "project.autosave",
|
||||
"Autosave must be enabled or disabled");
|
||||
value["editor"]["autosave"] = field.at("autosave");
|
||||
continue;
|
||||
} else {
|
||||
require(field.is_string() && !field.get<std::string>().empty(),
|
||||
"project.start_scene", "Choose a saved scene inside the project");
|
||||
const auto file = project_path(config_.project_root,
|
||||
@@ -378,7 +423,10 @@ void Session::register_commands() {
|
||||
if (!value.contains("id"))
|
||||
value["id"] = new_id();
|
||||
atomic_write_json(config_.project_root / "project.faset.json", value);
|
||||
log("Project settings saved; changes apply on next project open");
|
||||
autosave_enabled_ = value.at("editor").at("autosave").get<bool>();
|
||||
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
|
||||
autosave_enabled_);
|
||||
log("Project settings saved; autosave preference applies immediately");
|
||||
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
|
||||
});
|
||||
commands_.add(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <faset/editor/build_diagnostics.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -25,7 +26,7 @@ void contracts() {
|
||||
rows[0].at("line") == 6 && rows[0].at("severity") == "error" &&
|
||||
rows[0].at("message") == "unexpected symbol near '='",
|
||||
"Lua syntax error and ANSI stripping work");
|
||||
const auto windows = fs::path(R"(C:\Café)");
|
||||
const auto windows = path_from_utf8(R"(C:\Café)");
|
||||
rows = editor::parse_build_diagnostics(
|
||||
R"(C:\Café\Scripts\Game.cpp(17,4): error C2143: syntax error)" "\n"
|
||||
R"(C:\Café\Scripts\Game.cpp:19:2: warning: suspicious conversion)" "\n",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#include <faset/authoring/service.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/autosave.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
using namespace faset;
|
||||
using namespace std::chrono_literals;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
void check(bool value, std::string_view message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(std::string(message));
|
||||
}
|
||||
void contracts(const fs::path& root) {
|
||||
authoring::AuthoringService service(root);
|
||||
const auto created = service.create("Before", 2);
|
||||
const auto id = created.at("id").get<std::string>();
|
||||
const auto path = root / "Scenes/main.scene.json";
|
||||
service.save(id, "Scenes/main.scene.json");
|
||||
int saves{};
|
||||
editor::AutosaveController autosave([&](const std::string& document, std::uint64_t revision) {
|
||||
++saves;
|
||||
return service.save(document, {}, revision);
|
||||
});
|
||||
using Clock = editor::AutosaveController::Clock;
|
||||
const auto start = Clock::time_point{};
|
||||
autosave.observe(service.documents(), start, true);
|
||||
for (int index = 1; index <= 3; ++index) {
|
||||
const auto revision = service.query(id).at("revision").get<std::uint64_t>();
|
||||
service.transact(id, revision,
|
||||
Json::array({{{"op", "scene.rename"},
|
||||
{"name", "Edit " + std::to_string(index)}}}));
|
||||
autosave.observe(service.documents(), start + index * 100ms, true);
|
||||
}
|
||||
const auto play_snapshot = service.query(id).at("scene");
|
||||
autosave.observe(service.documents(), start + 2299ms, true);
|
||||
check(saves == 0 && read_json(path).at("name") == "Before",
|
||||
"Rapid edits coalesce until the last revision is idle for two seconds");
|
||||
autosave.observe(service.documents(), start + 2301ms, true);
|
||||
check(saves == 1 && read_json(path).at("name") == "Edit 3" &&
|
||||
service.query(id).at("scene") == play_snapshot &&
|
||||
autosave.status().at("documents")[0].at("state") == "saved",
|
||||
"Named scene saves once without changing the Play snapshot");
|
||||
const auto current_revision = service.query(id).at("revision").get<std::uint64_t>();
|
||||
check(service.undo(id, current_revision).at("scene").at("name") == "Edit 2",
|
||||
"Undo remains available after autosave");
|
||||
bool stale_rejected{};
|
||||
try {
|
||||
service.save(id, {}, current_revision);
|
||||
} catch (const Error& error) {
|
||||
stale_rejected = error.code() == "revision.conflict";
|
||||
}
|
||||
check(stale_rejected, "A stale autosave revision cannot save a newer document");
|
||||
|
||||
const auto external = read_text(path) + " \n";
|
||||
atomic_write(path, external);
|
||||
const auto revision = service.query(id).at("revision").get<std::uint64_t>();
|
||||
service.transact(id, revision,
|
||||
Json::array({{{"op", "scene.rename"}, {"name", "After external edit"}}}));
|
||||
autosave.observe(service.documents(), start + 3000ms, true);
|
||||
autosave.observe(service.documents(), start + 5100ms, true);
|
||||
check(saves == 2 && read_text(path) == external &&
|
||||
autosave.status().at("documents")[0].at("state") == "conflict",
|
||||
"Disk conflict does not overwrite external changes");
|
||||
autosave.observe(service.documents(), start + 10000ms, true);
|
||||
check(saves == 2, "A failed autosave does not retry every polling frame");
|
||||
|
||||
const auto unnamed = service.create("Unnamed", 2);
|
||||
autosave.observe(service.documents(), start + 10100ms, true);
|
||||
bool found_unnamed{};
|
||||
const auto unnamed_status = autosave.status();
|
||||
for (const auto& row : unnamed_status.at("documents"))
|
||||
if (row.at("id") == unnamed.at("id"))
|
||||
found_unnamed = row.at("state") == "save_as_required";
|
||||
check(found_unnamed &&
|
||||
fs::is_regular_file(root / ".faset/recovery" /
|
||||
(unnamed.at("id").get<std::string>() + ".json")),
|
||||
"Unnamed scene remains recoverable without receiving an implicit save path");
|
||||
autosave.observe(service.documents(), start + 10200ms, false);
|
||||
check(!autosave.status().at("enabled").get<bool>() && saves == 2,
|
||||
"Disabled autosave leaves recovery journaling active");
|
||||
|
||||
int failed_attempts{};
|
||||
editor::AutosaveController failing([&](const std::string&, std::uint64_t) -> Json {
|
||||
++failed_attempts;
|
||||
throw Error("io.write", "Synthetic read-only destination");
|
||||
});
|
||||
Json fake_documents = Json::array(
|
||||
{{{"id", "failure-fixture"}, {"revision", 1},
|
||||
{"path", "Scenes/fixture.scene.json"}, {"dirty", true}}});
|
||||
failing.observe(fake_documents, start, true);
|
||||
failing.observe(fake_documents, start + 2100ms, true);
|
||||
check(failed_attempts == 1 &&
|
||||
failing.status().at("documents")[0].at("state") == "failed",
|
||||
"Write failures remain visible");
|
||||
failing.observe(fake_documents, start + 10000ms, true);
|
||||
check(failed_attempts == 1, "A failed write does not retry on every frame");
|
||||
fake_documents[0]["revision"] = 2;
|
||||
failing.observe(fake_documents, start + 10100ms, true);
|
||||
failing.observe(fake_documents, start + 12200ms, true);
|
||||
check(failed_attempts == 2, "A newer revision receives a fresh autosave deadline");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const auto root = fs::temp_directory_path() / ("faset-autosave-" + new_id());
|
||||
try {
|
||||
contracts(root);
|
||||
fs::remove_all(root);
|
||||
std::cout << "Revision-aware autosave contracts passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
fs::remove_all(root);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
int test_main(int argc, char** argv) {
|
||||
using namespace faset;
|
||||
if (argc >= 3 && std::string_view(argv[1]) == "--editor-probe") {
|
||||
Json arguments = Json::array();
|
||||
@@ -36,6 +36,8 @@ int main(int argc, char** argv) {
|
||||
require(!session.play_pending() && !session.playing(), "test",
|
||||
"Stop must cancel a pending Play build");
|
||||
const auto initial = commands.call("faset_project_settings_get", Json::object());
|
||||
require(initial.at("settings").at("editor").at("autosave") == true, "test",
|
||||
"Missing autosave setting must read as enabled");
|
||||
auto changed = commands.call("faset_project_settings_set",
|
||||
{{"revision", initial.at("revision")},
|
||||
{"settings",
|
||||
@@ -75,15 +77,57 @@ int main(int argc, char** argv) {
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
|
||||
"revision.conflict");
|
||||
const auto reloaded = commands.call("faset_project_settings_get", Json::object());
|
||||
auto reloaded = commands.call("faset_project_settings_get", Json::object());
|
||||
auto explicit_default = external;
|
||||
explicit_default["editor"]["autosave"] = true;
|
||||
atomic_write_json(root / "project.faset.json", explicit_default);
|
||||
rejects({{"revision", reloaded.at("revision")},
|
||||
{"settings", {{"name", "Lost external write"}}}},
|
||||
"revision.conflict");
|
||||
reloaded = commands.call("faset_project_settings_get", Json::object());
|
||||
changed =
|
||||
commands.call("faset_project_settings_set", {{"revision", reloaded.at("revision")},
|
||||
{"settings", {{"name", "Preserved"}}}});
|
||||
require(changed.at("settings").at("custom_tool").at("keep") == true, "test",
|
||||
"Saving settings erased unknown project metadata");
|
||||
require(changed.at("settings").at("scripting") == external.at("scripting") &&
|
||||
changed.at("settings").at("editor") == external.at("editor"),
|
||||
changed.at("settings").at("editor").at("script_editor") ==
|
||||
external.at("editor").at("script_editor"),
|
||||
"test", "Project settings erased Lua configuration or external editor command");
|
||||
const auto autosave_off = commands.call(
|
||||
"faset_project_settings_set",
|
||||
{{"revision", changed.at("revision")},
|
||||
{"settings", {{"editor", {{"autosave", false}}}}}});
|
||||
require(autosave_off.at("settings").at("editor").at("autosave") == false &&
|
||||
session.project().at("editor").at("script_editor") ==
|
||||
external.at("editor").at("script_editor"),
|
||||
"test", "Autosave setting must merge without erasing the editor command");
|
||||
session.poll();
|
||||
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == false,
|
||||
"test", "Autosave disable must take effect in the current session");
|
||||
{
|
||||
editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), {}});
|
||||
reopened.poll();
|
||||
require(reopened.commands()
|
||||
.call("faset_autosave_status", Json::object())
|
||||
.at("enabled") == false,
|
||||
"test", "Autosave setting must survive Editor reopen");
|
||||
}
|
||||
rejects({{"revision", changed.at("revision")},
|
||||
{"settings", {{"editor", {{"autosave", true}}}}}},
|
||||
"revision.conflict");
|
||||
rejects({{"revision", autosave_off.at("revision")},
|
||||
{"settings", {{"editor", {{"autosave", "sometimes"}}}}}},
|
||||
"project.autosave");
|
||||
rejects({{"revision", autosave_off.at("revision")},
|
||||
{"settings", {{"editor", {{"script_editor", {"other"}}}}}}},
|
||||
"project.setting");
|
||||
changed = commands.call("faset_project_settings_set",
|
||||
{{"revision", autosave_off.at("revision")},
|
||||
{"settings", {{"editor", {{"autosave", true}}}}}});
|
||||
session.poll();
|
||||
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true,
|
||||
"test", "Autosave enable must take effect in the current session");
|
||||
const auto setup = commands.call("faset_lua_setup", Json::object());
|
||||
require(setup.at("configuration_created") == true &&
|
||||
setup.at("scripts_configuration_created") == true &&
|
||||
@@ -157,6 +201,24 @@ int main(int argc, char** argv) {
|
||||
require(invalid_lua_status.at("stale") == true &&
|
||||
!invalid_lua_status.at("error").get<std::string>().empty(),
|
||||
"test", "Invalid Lua manifest did not mark schema stale with diagnostics");
|
||||
const auto autosave_id = document.at("id").get<std::string>();
|
||||
const auto autosave_revision =
|
||||
session.authoring().query(autosave_id).at("revision").get<std::uint64_t>();
|
||||
session.authoring().transact(
|
||||
autosave_id, autosave_revision,
|
||||
Json::array({{{"op", "scene.rename"}, {"name", "Saved by MCP polling"}}}));
|
||||
reject_command("faset_document_save",
|
||||
{{"document", autosave_id},
|
||||
{"expected_revision", autosave_revision}},
|
||||
"revision.conflict");
|
||||
session.poll();
|
||||
require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true,
|
||||
"test", "Autosave status is available through Editor commands");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2100));
|
||||
session.poll();
|
||||
require(read_json(root / path_from_utf8("Scenes/Начало 世界.scene.json")).at("name") ==
|
||||
"Saved by MCP polling",
|
||||
"test", "A long-lived headless Editor poll saves a named scene after idle");
|
||||
external["version"] = 999;
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
bool rejected = false;
|
||||
@@ -175,3 +237,12 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return run_utf8_main(argc, argv, test_main);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return test_main(argc, argv);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
using namespace faset;
|
||||
namespace {
|
||||
void check(bool value, const std::string& message) {
|
||||
@@ -50,6 +52,7 @@ int main() {
|
||||
{"id", new_id()},
|
||||
{"name", "Original project"},
|
||||
{"dimension", 3},
|
||||
{"editor", {{"script_editor", {"zed", "{file}"}}}},
|
||||
{"custom_metadata", {{"preserve", true}}}});
|
||||
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||
render::Renderer renderer({1280, 800, "Project settings acceptance", true, true});
|
||||
@@ -73,6 +76,13 @@ int main() {
|
||||
open(ui);
|
||||
check(ui.widgets().find("project-settings-name")->text == "Original project",
|
||||
"Opening settings reloads current saved values");
|
||||
check(ui.widgets().find("project-settings-autosave")->checked,
|
||||
"Missing autosave setting appears enabled in Project settings");
|
||||
check(ui.widgets().focus("project-settings-autosave"),
|
||||
"Keyboard focus reaches the Autosave toggle");
|
||||
ui.frame({key("Space")});
|
||||
check(!ui.widgets().find("project-settings-autosave")->checked,
|
||||
"Space toggles Autosave from the keyboard");
|
||||
text(ui, "project-settings-name", "Новый проект");
|
||||
click(ui, "project-settings-2d");
|
||||
click(ui, "project-scene-choice-1");
|
||||
@@ -81,11 +91,25 @@ int main() {
|
||||
click(ui, "project-settings-save");
|
||||
auto saved = read_json(project_file);
|
||||
check(saved["name"] == "Новый проект" && saved["dimension"] == 2 &&
|
||||
saved["start_scene"] == "Scenes/Другая.scene.json",
|
||||
saved["start_scene"] == "Scenes/Другая.scene.json" &&
|
||||
saved["editor"]["autosave"] == false,
|
||||
"Explicit Save project persists typed settings");
|
||||
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
|
||||
saved["id"] == project_before["id"],
|
||||
saved["id"] == project_before["id"] &&
|
||||
saved["editor"]["script_editor"] ==
|
||||
project_before["editor"]["script_editor"],
|
||||
"Project settings preserve unknown metadata and project identity");
|
||||
ui.frame({});
|
||||
check(ui.widgets().find("autosave-status")->text == "Autosave off",
|
||||
"Disabling Autosave is visible in the Editor status bar");
|
||||
open(ui);
|
||||
check(!ui.widgets().find("project-settings-autosave")->checked,
|
||||
"Autosave preference is restored when reopening Project settings");
|
||||
click(ui, "project-settings-autosave");
|
||||
click(ui, "project-settings-save");
|
||||
saved = read_json(project_file);
|
||||
check(saved["editor"]["autosave"] == true,
|
||||
"Autosave can be enabled again through Project settings");
|
||||
check(session.authoring().query(ui.current_document()) == document_before,
|
||||
"Project settings do not change current document, dimension, revision or Undo");
|
||||
open(ui);
|
||||
@@ -118,6 +142,51 @@ int main() {
|
||||
"Save after explicit reload uses new content revision");
|
||||
check(session.authoring().query(ui.current_document()) == document_before,
|
||||
"Project settings remain outside scene Undo throughout conflicts");
|
||||
const auto original_document = ui.current_document();
|
||||
const auto revision =
|
||||
session.authoring().query(original_document).at("revision").get<std::uint64_t>();
|
||||
session.authoring().transact(
|
||||
original_document, revision,
|
||||
Json::array({{{"op", "scene.rename"}, {"name", "Pending autosave"}}}));
|
||||
ui.frame({});
|
||||
check(ui.widgets().find("autosave-status")->text == "Pending autosave",
|
||||
"Status bar shows pending scene save after edit");
|
||||
session.authoring().save(original_document);
|
||||
ui.frame({});
|
||||
check(ui.widgets().find("autosave-status")->text == "Saved",
|
||||
"Status bar returns to Saved after the scene reaches disk");
|
||||
session.authoring().transact(
|
||||
original_document,
|
||||
session.authoring().query(original_document).at("revision"),
|
||||
Json::array({{{"op", "scene.rename"}, {"name", "Conflicting autosave"}}}));
|
||||
ui.frame({});
|
||||
auto disk_scene = read_json(root / "Scenes/Главная.scene.json");
|
||||
disk_scene["name"] = "External scene edit";
|
||||
atomic_write_json(root / "Scenes/Главная.scene.json", disk_scene);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2100));
|
||||
ui.frame({});
|
||||
check(ui.widgets().find("autosave-status")->text == "Save conflict" &&
|
||||
ui.widgets().find("autosave-status")->tooltip.find("Save As") !=
|
||||
std::string::npos,
|
||||
"Disk conflict remains visible with Save As guidance");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "autosave-conflict.ppm");
|
||||
check(ui.widgets().focus("autosave-save-as"),
|
||||
"Keyboard focus reaches the recovery Save As action");
|
||||
ui.frame({key("Return")});
|
||||
check(ui.widgets().find("menu-popup")->visible &&
|
||||
ui.widgets().find("save-path")->visible,
|
||||
"Recovery action opens an editable Save As path");
|
||||
ui.frame({key("Escape")});
|
||||
const auto unnamed = session.authoring().create("Unsaved", 2);
|
||||
session.authoring().transact(
|
||||
unnamed.at("id"), unnamed.at("revision"),
|
||||
Json::array({{{"op", "scene.rename"}, {"name", "Needs a path"}}}));
|
||||
ui.select_document(unnamed.at("id"));
|
||||
ui.frame({});
|
||||
check(ui.widgets().find("autosave-status")->text == "Save As required" &&
|
||||
ui.widgets().find("autosave-save-as")->visible,
|
||||
"Unnamed dirty scene is never assigned an implicit path");
|
||||
open(ui);
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "project-settings.ppm");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Small contract checks for the cross-platform export verifier's Lua mode."""
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
from verify_playable_exports import (project_matrix, project_workspace, verify_capture,
|
||||
verify_lua_export, vulkan_device_details)
|
||||
|
||||
|
||||
class ExportProjectMatrixTests(unittest.TestCase):
|
||||
def test_native_project_workspace_is_outside_engine(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
engine = Path(directory) / "engine"
|
||||
engine.mkdir()
|
||||
internal_output = engine / ".cache" / "exports"
|
||||
workspace = project_workspace(engine, internal_output)
|
||||
try:
|
||||
self.assertFalse(workspace.is_relative_to(engine))
|
||||
finally:
|
||||
workspace.rmdir()
|
||||
external_output = Path(directory) / "evidence"
|
||||
self.assertEqual(project_workspace(engine, external_output), external_output)
|
||||
|
||||
def test_lua_is_explicit_and_can_run_alone(self):
|
||||
self.assertEqual([name for name, _, _ in project_matrix(False, False)],
|
||||
["collect-2d", "collect-3d"])
|
||||
self.assertEqual([name for name, _, _ in project_matrix(True, False)],
|
||||
["collect-2d", "collect-3d", "lua"])
|
||||
self.assertEqual(project_matrix(False, True), [("lua", 2, "lua")])
|
||||
|
||||
def test_lua_package_requires_source_and_license_without_cpp_stub(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "Scripts").mkdir()
|
||||
(root / "Notices/lua").mkdir(parents=True)
|
||||
(root / "Scripts/player.lua").write_text("return {}\n", encoding="utf-8")
|
||||
(root / "Notices/lua/LICENSE.txt").write_text("License\n", encoding="utf-8")
|
||||
manifest = {"lua_enabled": True, "files": [
|
||||
{"path": "Scripts/player.lua"}, {"path": "Notices/lua/LICENSE.txt"}]}
|
||||
verify_lua_export(root, manifest)
|
||||
with self.assertRaisesRegex(RuntimeError, "license"):
|
||||
verify_lua_export(root, {**manifest, "files": manifest["files"][:1]})
|
||||
(root / "Scripts/Gameplay.cpp").write_text("// stray\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(RuntimeError, "C\\+\\+"):
|
||||
verify_lua_export(root, manifest)
|
||||
(root / "Scripts/Gameplay.cpp").unlink()
|
||||
(root / "Notices/lua/LICENSE.txt").unlink()
|
||||
with self.assertRaisesRegex(RuntimeError, "license"):
|
||||
verify_lua_export(root, manifest)
|
||||
|
||||
def test_lua_capture_requires_its_small_scene_palette(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "capture.ppm"
|
||||
header = b"P6\n4 1\n255\n"
|
||||
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5ffb233"))
|
||||
self.assertEqual(verify_capture(path, language="lua")["sampled_colors"], 4)
|
||||
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
|
||||
verify_capture(path)
|
||||
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5334c66"))
|
||||
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
|
||||
verify_capture(path, language="lua")
|
||||
|
||||
def test_driver_probe_selects_the_rendering_device(self):
|
||||
summary = ("GPU0:\n deviceName = llvmpipe\n driverName = Mesa\n"
|
||||
"GPU1:\n deviceName = Discrete GPU\n driverVersion = 123\n"
|
||||
" driverName = Vendor\n driverInfo = 1.2.3\n")
|
||||
self.assertEqual(vulkan_device_details(summary, "Discrete GPU"),
|
||||
{"deviceName": "Discrete GPU", "driverVersion": "123",
|
||||
"driverName": "Vendor", "driverInfo": "1.2.3"})
|
||||
self.assertEqual(vulkan_device_details(summary, "Unknown"), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,6 +15,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -41,6 +42,17 @@ def sha256(path: Path) -> str:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def vulkan_device_details(summary: str, device: str) -> dict:
|
||||
"""Return the driver identity from vulkaninfo for the Player-selected device."""
|
||||
for section in re.split(r"(?m)^GPU\d+:\s*$", summary)[1:]:
|
||||
fields = dict(re.findall(
|
||||
r"(?m)^\s*(apiVersion|driverVersion|driverID|driverName|driverInfo|deviceName)"
|
||||
r"\s*=\s*(.+?)\s*$", section))
|
||||
if fields.get("deviceName") == device:
|
||||
return fields
|
||||
return {}
|
||||
|
||||
|
||||
def run(arguments: list[str | Path], cwd: Path, log: Path, timeout: int = 1800) -> dict:
|
||||
command = [str(argument) for argument in arguments]
|
||||
print(f"{log.stem}: {subprocess.list2cmdline(command)}", flush=True)
|
||||
@@ -96,7 +108,7 @@ def verify_package(directory: Path) -> dict:
|
||||
return manifest
|
||||
|
||||
|
||||
def verify_capture(path: Path) -> dict:
|
||||
def verify_capture(path: Path, *, language: str = "cpp") -> dict:
|
||||
header, dimensions, maximum, pixels = path.read_bytes().split(b"\n", 3)
|
||||
require(header == b"P6" and maximum == b"255", "Expected RGB PPM screenshot")
|
||||
width, height = (int(value) for value in dimensions.split())
|
||||
@@ -104,10 +116,50 @@ def verify_capture(path: Path) -> dict:
|
||||
"Incomplete screenshot")
|
||||
step = 3 * max(1, width * height // 10000)
|
||||
colors = len({pixels[index:index + 3] for index in range(0, len(pixels), step)})
|
||||
require(colors >= 6, "Screenshot lacks the expected game geometry/colors")
|
||||
# The checked-in Lua sample deliberately has four flat palette colors.
|
||||
require(colors >= (4 if language == "lua" else 6),
|
||||
"Screenshot lacks the expected game geometry/colors")
|
||||
if language == "lua":
|
||||
sampled = {pixels[index:index + 3] for index in range(0, len(pixels), step)}
|
||||
require(any(r > 220 and g > 120 and b < 100 for r, g, b in sampled) and
|
||||
any(r < 100 and g > 150 and b > 180 for r, g, b in sampled),
|
||||
"Lua screenshot lacks its gold collectible or cyan player")
|
||||
return {"width": width, "height": height, "sampled_colors": colors, "sha256": sha256(path)}
|
||||
|
||||
|
||||
def project_matrix(include_lua: bool, only_lua: bool) -> list[tuple[str, int, str]]:
|
||||
"""Keep the existing two-game default; explicitly opt in to the Lua export."""
|
||||
if only_lua:
|
||||
return [("lua", 2, "lua")]
|
||||
projects = [("collect-2d", 2, "cpp"), ("collect-3d", 3, "cpp")]
|
||||
if include_lua:
|
||||
projects.append(("lua", 2, "lua"))
|
||||
return projects
|
||||
|
||||
|
||||
def project_workspace(engine: Path, output: Path) -> Path:
|
||||
"""Native builds must never be staged under the engine source tree."""
|
||||
if output.is_relative_to(engine):
|
||||
return Path(tempfile.mkdtemp(prefix="faset-playable-projects-"))
|
||||
return output
|
||||
|
||||
|
||||
def verify_lua_export(directory: Path, manifest: dict) -> None:
|
||||
require(manifest.get("lua_enabled") is True, "Lua export did not enable the Lua runtime")
|
||||
scripts = [entry["path"] for entry in manifest["files"]
|
||||
if entry["path"].startswith("Scripts/") and entry["path"].endswith(".lua")]
|
||||
require(scripts and all((directory / path).is_file() for path in scripts),
|
||||
"Lua export has no packaged gameplay source")
|
||||
require("Notices/lua/LICENSE.txt" in {entry["path"] for entry in manifest["files"]} and
|
||||
(directory / "Notices/lua/LICENSE.txt").is_file(),
|
||||
"Lua export is missing its license notice")
|
||||
require(not (directory / "Scripts/Gameplay.cpp").exists() and
|
||||
not (directory / "Scripts/Gameplay.hpp").exists(),
|
||||
"Lua-only export unexpectedly contains C++ gameplay source")
|
||||
require(not (directory / ".luarc.json").exists(),
|
||||
"Lua-only export includes Editor language-server configuration")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
@@ -117,6 +169,10 @@ def main() -> int:
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--standalone-root", type=Path,
|
||||
help="New/empty directory outside the engine and output trees")
|
||||
parser.add_argument("--include-lua", action="store_true",
|
||||
help="Also export, relocate, validate and render examples/lua")
|
||||
parser.add_argument("--only-lua", action="store_true",
|
||||
help="Run only the Lua project (useful for focused local verification)")
|
||||
args = parser.parse_args()
|
||||
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
|
||||
require(editor.is_file(), f"Editor does not exist: {editor}")
|
||||
@@ -129,26 +185,47 @@ def main() -> int:
|
||||
require(not standalone.exists() or not any(standalone.iterdir()),
|
||||
"Standalone root must be new/empty")
|
||||
standalone.mkdir(parents=True, exist_ok=True)
|
||||
evidence, projects = output / "evidence", output / "Faset Café 世界"
|
||||
workspace = project_workspace(engine, output)
|
||||
evidence, projects = output / "evidence", workspace / "Faset Café 世界"
|
||||
evidence.mkdir()
|
||||
projects.mkdir()
|
||||
revision = subprocess.run(["git", "-C", str(engine), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
vulkan_summary = ""
|
||||
if shutil.which("vulkaninfo"):
|
||||
try:
|
||||
probe = subprocess.run(["vulkaninfo", "--summary"], capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace", timeout=30)
|
||||
if probe.returncode == 0:
|
||||
vulkan_summary = probe.stdout
|
||||
(evidence / "vulkaninfo-summary.txt").write_text(
|
||||
probe.stdout + probe.stderr, encoding="utf-8")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
standalone_projects = standalone / "Faset Café 世界"
|
||||
standalone_projects.mkdir()
|
||||
matrix = project_matrix(args.include_lua, args.only_lua)
|
||||
report = {"format": "faset.playable-export-verification", "version": 1,
|
||||
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
|
||||
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
|
||||
"frames_per_game": 120, "status": "running", "projects": []}
|
||||
disabled = output / "projects-offline"
|
||||
"engine_revision": revision, "editor_sha256": sha256(editor),
|
||||
"project_workspace": str(workspace),
|
||||
"vulkaninfo_available": bool(vulkan_summary),
|
||||
"driver_override": os.environ.get("VK_DRIVER_FILES") or os.environ.get("VK_ICD_FILENAMES"),
|
||||
"frames_per_game": 120, "status": "running", "projects": [],
|
||||
"requested_projects": [name for name, _, _ in matrix]}
|
||||
disabled = workspace / "projects-offline"
|
||||
try:
|
||||
for dimension in (2, 3):
|
||||
name = f"collect-{dimension}d"
|
||||
source, project = engine / "examples/projects" / name, projects / name
|
||||
for name, dimension, language in matrix:
|
||||
source = engine / "examples" / ("lua" if language == "lua" else f"projects/{name}")
|
||||
project = projects / name
|
||||
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
|
||||
inputs = [{"path": path.relative_to(project).as_posix(), "sha256": sha256(path)}
|
||||
for path in sorted(project.rglob("*")) if path.is_file()]
|
||||
settings = read_json(project / "project.faset.json")
|
||||
scene = read_json(relative_path(project, settings["start_scene"]))
|
||||
item = {"name": name, "dimension": dimension, "source_inputs": inputs}
|
||||
item = {"name": name, "dimension": dimension, "language": language,
|
||||
"source_inputs": inputs}
|
||||
report["projects"].append(item)
|
||||
if dimension == 3:
|
||||
imported = command(editor, engine, project, "faset_import",
|
||||
@@ -164,18 +241,22 @@ def main() -> int:
|
||||
require(generation == Path(exported["result"]["directory"]).resolve(),
|
||||
"Export response and published pointer disagree")
|
||||
manifest = verify_package(generation)
|
||||
if language == "lua":
|
||||
verify_lua_export(generation, manifest)
|
||||
require(dimension != 3 or bool(manifest["asset_generations"]),
|
||||
"3D game did not package its imported Blender asset")
|
||||
relocated = standalone_projects / name
|
||||
shutil.copytree(generation, relocated)
|
||||
verify_package(relocated)
|
||||
if language == "lua":
|
||||
verify_lua_export(relocated, manifest)
|
||||
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
|
||||
item.update({"generation": pointer["generation"], "configuration": "Release",
|
||||
"standalone_directory": str(relocated), "executable": manifest["executable"],
|
||||
"package_file_count": len(manifest["files"]),
|
||||
"asset_generations": manifest["asset_generations"]})
|
||||
write_json(output / "report.json", report)
|
||||
# Hide exactly our disposable source-project paths while launching both games.
|
||||
# Hide exactly our disposable source-project paths while launching the games.
|
||||
# An empty, unrelated cwd also catches assumptions about the current directory.
|
||||
projects.rename(disabled)
|
||||
working = standalone / "empty-working-directory"
|
||||
@@ -202,8 +283,10 @@ def main() -> int:
|
||||
require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]),
|
||||
"Frame profile lacks Vulkan allocation measurements")
|
||||
item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"],
|
||||
"driver": vulkan_device_details(vulkan_summary, measured["device"]),
|
||||
"validation_errors": 0, "completed_frames": 120,
|
||||
"summary_ms": measured["summary_ms"], "capture": verify_capture(capture),
|
||||
"summary_ms": measured["summary_ms"],
|
||||
"capture": verify_capture(capture, language=item["language"]),
|
||||
"source_project_paths_unavailable": True, "status": "passed"})
|
||||
shutil.copy2(capture, evidence / f"{name}.ppm")
|
||||
shutil.copy2(profile, evidence / f"{name}-profile.json")
|
||||
@@ -217,7 +300,8 @@ def main() -> int:
|
||||
disabled.rename(projects)
|
||||
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
|
||||
write_json(output / "report.json", report)
|
||||
print(f"Both relocated Release games passed: {output / 'report.json'}", flush=True)
|
||||
print(f"{len(matrix)} relocated Release game(s) passed: {output / 'report.json'}",
|
||||
flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user