diff --git a/.editorconfig b/.editorconfig index eec26e9b..a7248336 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,7 +23,6 @@ indent_style = space tab_width = 4 # 新行首选项 -end_of_line = crlf insert_final_newline = false #### .NET 编码约定 #### @@ -253,7 +252,6 @@ dotnet_naming_style.begins_with_underline.capitalization = camel_case csharp_style_prefer_primary_constructors = true:suggestion [*.{cs,vb}] -end_of_line = crlf dotnet_style_qualification_for_field = false:silent dotnet_style_qualification_for_property = false:silent dotnet_style_qualification_for_method = false:silent diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 393804b4..e1a93429 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -38,6 +38,7 @@ env: DOTNET_VERSION: '10.0.x' DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 + ECSVM_REQUIRE_CC: 1 jobs: # ======================================== @@ -47,6 +48,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }} - name: 安装 .NET SDK uses: actions/setup-dotnet@v5 @@ -54,7 +57,7 @@ jobs: dotnet-version: ${{ env.DOTNET_VERSION }} - name: 检查代码格式 - run: dotnet format --verbosity diagnostic + run: dotnet format --verify-no-changes --verbosity diagnostic # ======================================== # 2. 构建 + 测试 (Debug) @@ -62,14 +65,18 @@ jobs: build-and-test: runs-on: windows-latest needs: format + env: + EASYCON_CONNECTION_RENDER_DIR: ${{ github.workspace }}/acceptance-connection + EASYCON_CONNECTION_RESULTS_DIR: ${{ github.workspace }}/TestResultsConnection steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }} - name: 安装 .NET SDK uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ env.DOTNET_VERSION }} - ref: ${{ github.event.workflow_run.head_branch }} - name: 缓存 NuGet 包 uses: actions/cache@v4 @@ -81,13 +88,184 @@ jobs: - name: 还原 NuGet 包 run: dotnet restore - - name: 构建 ecs-vm(无 C 工具链则跳过) + - name: 构建 ecs-vm 普通与 budget 档(必须有 C99 工具链) shell: bash - run: sh ci/build-vm.sh + run: sh ci/build-vm.sh budget - name: Build (Debug) run: dotnet build --no-restore --configuration Debug + - name: 安装并强制执行真实 Maa OCR 试点测试 + shell: pwsh + run: ./ci/prepare-maa-ocr.ps1 -Configuration Debug + - name: 运行 EasyCon 测试 - shell: cmd - run: ci\test.bat Debug + run: dotnet test EasyCon2.slnx --no-build -c Debug --logger trx --results-directory TestResults + + - name: Maa 原生宿主无硬件自检 + run: dotnet run --no-build -c Debug --project src/EasyCon2.CLI -- maa doctor | Tee-Object maa-runtime.json + + - name: 上传宿主与测试验收记录 + if: always() + uses: actions/upload-artifact@v6 + with: + name: host-p1-tests + path: | + TestResults/*.trx + maa-runtime.json + + - name: 串行运行真实窗口保存快捷键回归 + run: >- + dotnet test test/EasyCon2.Avalonia.UiTests/EasyCon2.Avalonia.UiTests.csproj + --configuration Debug --no-build + --filter "FullyQualifiedName~MainWindowSaveTests.CtrlS_SavesLatestEditorTextFromTheFocusedEditor" + + - name: 验证连接区证据完整性门禁的回归用例 + shell: pwsh + run: ./ci/test-connection-evidence.ps1 + + - name: 串行运行连接区 M18 渲染与交互回归 + run: >- + dotnet test test/EasyCon2.Avalonia.UiTests/EasyCon2.Avalonia.UiTests.csproj + --configuration Debug --no-build + --filter "FullyQualifiedName~MainWindowSaveTests.ConnectionPanels_Render" + --logger "trx;LogFileName=connection-m18.trx" + --results-directory "${{ env.EASYCON_CONNECTION_RESULTS_DIR }}" + + - name: 上传前校验完整 M18 证据 + id: connection-evidence + if: always() + shell: pwsh + run: >- + ./ci/verify-connection-evidence.ps1 + -RenderDirectory $env:EASYCON_CONNECTION_RENDER_DIR + -TrxPath (Join-Path $env:EASYCON_CONNECTION_RESULTS_DIR 'connection-m18.trx') + + - name: 上传完整 M18 截图、结构化记录与测试结果 + uses: actions/upload-artifact@v6 + if: ${{ always() && steps.connection-evidence.outcome == 'success' }} + with: + name: connection-m18 + if-no-files-found: error + path: | + ${{ env.EASYCON_CONNECTION_RENDER_DIR }}/*.png + ${{ env.EASYCON_CONNECTION_RENDER_DIR }}/*.json + ${{ env.EASYCON_CONNECTION_RESULTS_DIR }}/connection-m18.trx + + - name: 发布 Windows 验收包 + shell: pwsh + run: | + dotnet publish src/EasyCon2.CLI -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o dist/p2-win-x64 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet publish src/EasyCon2.Avalonia -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o dist/p2-win-x64 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Copy-Item .ci-maa-ocr dist/p2-win-x64/maa-ocr-pilot -Recurse + if (Test-Path assets/fw) { Copy-Item assets/fw dist/p2-win-x64/Firmware -Recurse } + & dist/p2-win-x64/EasyCon2.CLI.exe maa doctor --ocr-profile dist/p2-win-x64/maa-ocr-pilot/ocr-profile.json | Tee-Object dist/p2-win-x64/maa-runtime.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + git rev-parse HEAD | Set-Content dist/p2-win-x64/COMMIT.txt + Compress-Archive dist/p2-win-x64/* dist/EasyCon2-p2-win-x64.zip + - uses: actions/upload-artifact@v6 + with: + name: easycon2-p2-win-x64 + path: dist/EasyCon2-p2-win-x64.zip + retention-days: 30 + + native-abi: + runs-on: ubuntu-latest + needs: format + strategy: + fail-fast: false + matrix: + bits: [64, 32] + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: 安装 32 位 C 运行库与工具链 + if: matrix.bits == 32 + run: sudo apt-get update && sudo apt-get install -y gcc-multilib + - name: 指定实际 C 编译器 + shell: bash + run: | + if [ "${{ matrix.bits }}" = "32" ]; then + chmod +x ci/cc32.sh + echo "ECSVM_CC=$PWD/ci/cc32.sh" >> "$GITHUB_ENV" + else + echo "ECSVM_CC=/usr/bin/gcc" >> "$GITHUB_ENV" + fi + - name: 普通与 budget 构建及容量门禁 + run: sh ci/build-vm.sh budget | tee vm-budget-${{ matrix.bits }}.txt + - name: 执行 C# 与 C VM 差分及 P0 ABI 回归 + run: >- + dotnet test test/EasyCon.Tests/EasyCon.Tests.csproj -c Release + --filter "FullyQualifiedName~Bytecode" + --logger "trx;LogFileName=native-${{ matrix.bits }}.trx" + --results-directory TestResults + - name: 上传原生验收记录 + if: always() + uses: actions/upload-artifact@v6 + with: + name: cvm-p0-${{ matrix.bits }} + path: | + vm-budget-*.txt + TestResults/*.trx + + build-macos: + if: github.event_name != 'workflow_run' + runs-on: macos-14 + needs: format + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + - name: 检查 AVFoundation 真实枚举入口(无设备可返回空列表,不请求权限) + shell: bash + run: | + set -o pipefail + dotnet run --project src/EasyCon2.CLI -c Release -- video --list | tee macos-video-sources.txt + - name: 安装并强制执行真实 Maa OCR 试点测试 + shell: pwsh + run: ./ci/prepare-maa-ocr.ps1 + - name: 运行 P1 选源和 P2 原生识别回归 + shell: bash + run: | + dotnet test test/EasyCon.Tests -c Release --filter "FullyQualifiedName~MaaVisionTests|FullyQualifiedName~MaaOcrTests|FullyQualifiedName~HostProfileRegressionTests" --logger trx --results-directory TestResults + - name: 打包 macOS .app + shell: bash + env: + CI: "true" + run: bash ci/macos-app.sh + - name: 检查发布包的枚举入口并打包 + shell: bash + run: | + set -o pipefail + dist/publish/EasyCon.app/Contents/MacOS/ezcon video --list | tee macos-published-video-sources.txt + cp -R .ci-maa-ocr dist/publish/EasyCon.app/Contents/MacOS/maa-ocr-pilot + dist/publish/EasyCon.app/Contents/MacOS/ezcon maa doctor --ocr-profile dist/publish/EasyCon.app/Contents/MacOS/maa-ocr-pilot/ocr-profile.json | tee macos-published-maa-runtime.json + git rev-parse HEAD > dist/publish/EasyCon.app/Contents/MacOS/COMMIT.txt + version="$(git rev-parse --short HEAD)" + ditto -c -k --sequesterRsrc --keepParent dist/publish/EasyCon.app "dist/EasyCon2-${version}-macos-arm64.zip" + - uses: actions/upload-artifact@v6 + if: always() + with: + name: easycon2-macos-arm64 + path: | + dist/*.zip + macos-video-sources.txt + macos-published-video-sources.txt + macos-published-maa-runtime.json + TestResults/*.trx + if-no-files-found: error + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a98a2fba..295fb909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ env: DOTNET_VERSION: '10.0.x' DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 + ECSVM_REQUIRE_CC: 1 jobs: # ======================================== @@ -74,10 +75,24 @@ jobs: - name: 还原 NuGet 包 run: dotnet restore - - name: 运行 EasyCon 测试 + - name: 构建 ecs-vm 普通与 budget 档 + shell: bash + run: sh ci/build-vm.sh budget + + - name: 安装并强制执行真实 Maa OCR 试点测试 + shell: pwsh + run: ./ci/prepare-maa-ocr.ps1 + + - name: 运行 EasyCon 全量测试 shell: cmd run: ci\test.bat + - name: 串行运行真实窗口保存快捷键回归 + run: >- + dotnet test test/EasyCon2.Avalonia.UiTests/EasyCon2.Avalonia.UiTests.csproj + --configuration Release --no-build + --filter "FullyQualifiedName~MainWindowSaveTests.CtrlS_SavesLatestEditorTextFromTheFocusedEditor" + - name: 发布 EasyCon2 shell: cmd run: ci\windows-x64.bat @@ -113,9 +128,19 @@ jobs: - name: 还原 NuGet 包 run: dotnet restore - - name: 构建 ecs-vm(无 C 工具链则跳过) + - name: 构建 ecs-vm 普通与 budget 档 shell: bash - run: sh ci/build-vm.sh + run: sh ci/build-vm.sh budget + + # macOS 自带 /usr/bin/clang:CvmRunner 能现场编译 ecs-vm, + # 使托管解释器 ↔ C VM 的差分对拍(Cvm/Corpus/Fuzz/FullChain/Abi)真正执行。 + # 这是双端语义漂移的唯一机械防线,勿移除。 + - name: 安装并强制执行真实 Maa OCR 试点测试 + shell: pwsh + run: ./ci/prepare-maa-ocr.ps1 + + - name: 运行 EasyCon 测试(含 C VM 差分对拍) + run: dotnet test EasyCon2.slnx -c Release - name: 打包 macOS .app shell: bash diff --git a/.github/workflows/format-fix.yml b/.github/workflows/format-fix.yml index 8d2eb756..80c9ab8a 100644 --- a/.github/workflows/format-fix.yml +++ b/.github/workflows/format-fix.yml @@ -17,6 +17,7 @@ concurrency: jobs: format-fix: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: windows-latest permissions: contents: write @@ -55,3 +56,24 @@ jobs: git add . git commit -m "style: auto-fix dotnet format [skip ci]" git push + + format-fork-check: + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + runs-on: windows-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: 安装 .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: 检查 fork PR 代码格式 + run: dotnet format EasyCon2.slnx --verify-no-changes --verbosity diagnostic diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..03ad07bd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +name: release + +on: + push: + tags: ["v*", "[0-9]*"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + DOTNET_VERSION: '10.0.x' + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + ECSVM_REQUIRE_CC: 1 + +jobs: + # ======================================== + # 1. Windows x64:测试 + 发布 + zip + # ======================================== + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: 安装 .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: 缓存 NuGet 包 + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: 还原 NuGet 包 + run: dotnet restore + + - name: 构建 ecs-vm 普通与 budget 档 + shell: bash + run: sh ci/build-vm.sh budget + + - name: 安装并强制执行真实 Maa OCR 试点测试 + shell: pwsh + run: ./ci/prepare-maa-ocr.ps1 + + - name: 运行 EasyCon 全量测试 + shell: cmd + run: ci\test.bat + + - name: 发布 Windows x64 + shell: cmd + run: ci\windows-x64.bat + + - name: 打包 zip + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + $gui = Get-ChildItem dist/publish -Filter "EasyCon2.Avalonia.*.exe" | Select-Object -First 1 + if (-not $gui) { throw "EasyCon2.Avalonia exe missing" } + if (-not (Test-Path dist/publish/ezcon.exe)) { throw "ezcon.exe missing" } + tar -a -c -f "dist/EasyCon2-$tag-win-x64.zip" -C dist/publish . + Get-ChildItem dist + + - name: 上传 Windows 产物 + uses: actions/upload-artifact@v6 + with: + name: EasyCon2-release-win-x64 + path: dist/*.zip + retention-days: 7 + + # ======================================== + # 2. macOS arm64:测试(含 C VM 差分对拍)+ .app + zip + # ======================================== + build-macos: + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + + - name: 安装 .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: 缓存 NuGet 包 + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: 还原 NuGet 包 + run: dotnet restore + + - name: 构建 ecs-vm 普通与 budget 档 + shell: bash + run: sh ci/build-vm.sh budget + + - name: 安装并强制执行真实 Maa OCR 试点测试 + shell: pwsh + run: ./ci/prepare-maa-ocr.ps1 + + - name: 运行 EasyCon 全量测试(含 C VM 差分对拍) + run: dotnet test EasyCon2.slnx -c Release + + - name: 打包 macOS .app + shell: bash + env: + CI: "true" + run: bash ci/macos-app.sh + + - name: 打包 zip + shell: bash + run: | + tag="${{ github.ref_name }}" + # .app 必须经 ditto 打包以保留权限与资源元数据,zip 内保留 EasyCon.app 顶层目录 + ditto -c -k --sequesterRsrc --keepParent dist/publish/EasyCon.app "dist/EasyCon2-${tag}-macos-arm64.zip" + + - name: 上传 macOS 产物 + uses: actions/upload-artifact@v6 + with: + name: EasyCon2-release-macos-arm64 + path: dist/*.zip + retention-days: 7 + + # ======================================== + # 3. 发布 GitHub Release(仅 tag 触发;workflow_dispatch 只构建) + # ======================================== + release: + runs-on: ubuntu-latest + needs: [build-windows, build-macos] + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: 下载 Windows 产物 + uses: actions/download-artifact@v6 + with: + name: EasyCon2-release-win-x64 + path: dist/ + + - name: 下载 macOS 产物 + uses: actions/download-artifact@v6 + with: + name: EasyCon2-release-macos-arm64 + path: dist/ + + - name: 创建 Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ github.ref_name }}" dist/*.zip \ + --title "${{ github.ref_name }}" \ + --generate-notes --latest diff --git a/.gitignore b/.gitignore index 22bfc8c9..be88678b 100644 --- a/.gitignore +++ b/.gitignore @@ -371,3 +371,26 @@ PP-OCRv5_mobile_rec.onnx *_rec.onnx *_dict.txt ppocr_keys_v5.txt + +# Local design references / mockups (working material, not shipped) +.design/ + +# macOS 目录元数据 +.DS_Store + +# Python 前端(frontend/,uv 管理):虚拟环境与缓存不入库 +# 注意:pyproject.toml / uv.lock / .python-version 是**要入库**的(环境可复现) +frontend/.venv/ +frontend/**/__pycache__/ +frontend/**/*.pyc +frontend/**/*.egg-info/ + +# 真机测试资产(约定见 test/assets/README.md):模型/截图体积大不入库,本机自备 +test/assets/* +!test/assets/README.md + +# ecs-vm 构建产物(native/) +src/EasyCon.Vm/native/ecs-vm* + +# Fixed Maa OCR assets downloaded explicitly for native regression tests +.ci-maa-ocr/ diff --git a/AGENTS.md b/AGENTS.md index 99d0ca89..a91ca0a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ EasyCon is a Nintendo Switch automation tool with virtual controller, image recognition (OpenCV via OpenCvSharp5), and a custom ECS scripting language. .NET 10.0, C# (LangVersion=preview). -**Entry points**: `src/EasyCon2.Avalonia` (GUI), `src/EasyCon2.CLI` (CLI). +**Entry points**: `src/EasyCon2.Avalonia` (GUI), `src/EasyCon2.CLI` (CLI, published as `ezcon`; `ezcon init` scaffolds the canonical script project layout). ## Build & test commands @@ -33,33 +33,97 @@ dotnet format ci\windows-x64.bat ``` +```bash +# Python 画布前端(frontend/,uv 管理:pyproject.toml + uv.lock,src/ 布局) +# 离线自检(43 项);加 --backend 追加真后端端到端检查(48 项) +cd frontend && uv run easycon-flow --self-test +cd frontend && uv run easycon-flow --self-test --backend http://127.0.0.1:19391 +# 无头 UI 冒烟(建窗/载图/跑图/试跑节点;需要后端在跑) +QT_QPA_PLATFORM=offscreen uv run python -m easycon_flow.uitest --backend http://127.0.0.1:19391 +``` + **Solution file** is `EasyCon2.slnx` (XML-based MSBuild solution, .NET 10+). Not `.sln`. ## Solution structure | Directory | Role | |---|---| -| `src/EasyCon.Core` | Core abstractions; capability model (`Capabilities/`: CapabilitySet + IPadInput/IConsoleIo/IFileSystem/ICaptureSource/IVisionService/IOcrService/IInference); script engine surface (`Script/`: IScriptEngine/IScriptSession) | -| `src/EasyCon.Device` | Hardware device communication (serial) | -| `src/EasyCon.Capture` | Screen/image capture | -| `src/EasyCon.Script` | ECS script parser, compiler, runtime | -| `src/EasyCon.Vm` | Native C VM (ecs-vm, C99) executing .ecx bytecode on MCU | -| `src/EzTesseract` | OCR (Tesseract wrapper) | -| `src/EasyCon.Lsp` | LSP language server for ECS scripts | -| `src/EasyCon.Server` | HTTP/WebSocket server for remote control | -| `src/EasyCon2.Avalonia` | Avalonia GUI (MVVM) | -| `src/EasyCon2.Avalonia.Core` | Shared Avalonia viewmodels/logic | -| `src/EasyCon2.UI.Common` | Shared UI resources, styles | -| `src/EasyCon.WinInput` | Windows input simulation | -| `src/EasyCon.SDLInput` | Cross-platform input via SDL3 | -| `test/EasyCon.Tests` | Core/Script tests (NUnit) | +| `src/EasyCon.Core` | **Composition root** + capability model (`Capabilities/`: CapabilitySet + IPadInput/IConsoleIo/IFileSystem/ICaptureSource/IVisionService/IOcrService/IInference/IEnvironment) + host assembler (`Hosting/`: ScriptHostAssembler/ScriptCompileProfiles) + script engine surface (`Script/`: IScriptEngine/IScriptSession) + execution bridge (`Runner/`: EcxVm) + config (`Config/`: ConfigManager) + node-graph orchestration (`Flow/`: FlowGraph/FlowNodeCatalog/FlowNodeRuntime/FlowExecutor/FlowServiceState/FlowServiceHttp/FlowServiceTools — spec in `docs/Flow.md`) + LLM client (`LLM/`) | +| `src/EasyCon.Device` | Hardware device communication (serial); zero project references | +| `src/EasyCon.Capture` | Screen/image capture; vision matching; OCR engine cache | +| `src/EasyCon.Script` | ECS script parser, binder, SSA, bytecode compiler/linker, CLI-side interpreter; zero project references | +| `src/EasyCon.Vm` | Native C VM (`native/ecs-vm`, C99) executing .ecx bytecode on MCU. **Not in `EasyCon2.slnx`** — built by `ci/build-vm.sh` and on the fly by `test/EasyCon.Tests/Support/CvmRunner.cs` | +| `src/EzTesseract` | OCR (hand-written Tesseract/Leptonica bindings); zero project references | +| `src/EasyCon.Lsp` | LSP language server for ECS scripts (reuses Script's syntax tree only) | +| `src/EasyCon.SDLInput` | Cross-platform input via SDL3 (the only production input backend) | +| `src/EasyCon2.Avalonia` | Avalonia GUI (MVVM), **feature-first layout** (`Shell/` `Monitor/` `FileTree/` `Connection/` `KeyMapping/` `AiAgent/` `Mcp/` `ModelsConfig/` `AlertConfig/` `TagEditor/` `Terminal/` `Editor/` + shared `Services/` `Models/` `Controls/` `Behaviors/` `Converters/`); absorbed former `EasyCon2.Avalonia.Core`; namespaces are `EasyCon2.Avalonia..*` | +| `src/EasyCon2.UI.Common` | Legacy resx resources only (see "Layering" below — mostly vestigial) | +| `test/EasyCon.Tests` | Core/Script tests (NUnit), includes C VM ↔ C# interpreter cross-validation | | `test/EasyCon.Lsp.Tests` | LSP tests | -| `test/EasyCon.WinInput.Tests` | Windows input tests | +| `test/EasyCon.SDLInput.Tests` | Input→HID mapping tests (fake device connection) | +| `test/EasyCon2.Avalonia.Core.Tests` | Agent/MCP/LSP-client/script-service tests | +| `test/EasyCon2.Avalonia.UiTests` | Headless Avalonia render tests | +| `tools/OpenCvDnnDemo` | OpenCvSharp5 DNN experiment (in slnx under `/Demo/`) | +| `frontend/` | **Python** node-graph canvas (PySide6 + NodeGraphQt) for `*.flow.json` orchestration — the only non-.NET code in the repo, added with explicit user approval and confined to this directory. Managed with **uv** (`pyproject.toml` + `uv.lock` + `.python-version`, `src/` layout, console script `easycon-flow`); nodes/types/params all come from the backend (`GET /api/nodes` ← `FlowNodeCatalog`), so no node logic lives here. See `frontend/README.md` and `docs/Flow.md` | + +## Layering and the composition root + +The layering does **not** match the "Core is the bottom layer" reading of its name. The real graph is: + +``` +EasyCon.Script EasyCon.Device EasyCon.Capture ──► EzTesseract (leaf libraries, zero refs) + ▲ ▲ ▲ + └───────────────┴──────────────────┘ + │ + EasyCon.Core ← capability PORTS + COMPOSITION ROOT + ▲ + ┌───────────────┼──────────────────┐ +EasyCon.SDLInput EasyCon.Lsp EasyCon2.Avalonia +EasyCon2.CLI ──► EasyCon.Core + EasyCon.Lsp +``` + +Rules to respect when changing code: + +- **`EasyCon.Core` is the composition root**, not a leaf: it references Script/Device/Capture and pulls + OpenCV/Tesseract through them. Do not add a project reference *to* Core from Script/Device/Capture/EzTesseract — + those four must stay zero-reference leaves. +- **Never construct `CapabilitySet` outside `Hosting/ScriptHostAssembler`.** Hosts (GUI/CLI) supply only + "raw material" (`ScriptHostContext`: pad, frame/ROI/label delegates, console, args); the assembler owns + adapter wrapping, defaults (Environment/Files/OCR/DNN) and resource teardown (`CapabilityLease`). +- **Never hand-write `CompileOptions` literals.** Use `ScriptCompileProfiles.Interactive` + (compile-on-the-fly, process cache only) or `ScriptCompileProfiles.Distributable` + (obj/ content-addressed cache; the image is MCU-distributable). All products share one + format (ECX1 flat + v3 fixed-width encoding); the 255-slot frame budget is shared by + params, outbound windows and live values. Device fit is a burn-time capacity precheck + concern, not a profile. +- DI *container* is deliberately absent: composition is explicit constructor passing from + `App.axaml.cs` (GUI) and `Program.cs` (CLI). +- **AI tool layer: runtime knows capabilities, not integrations.** Tool constructors may only depend on + Core vocabulary (capability ports like `IPadInput`/`ICaptureSource`/`IOcrService`, LLM infra types, + primitives, delegate providers) or a narrow GUI port (`IScriptEditorPort` etc.). The `IToolCallService` + facade is a GUI-internal composition view — never take it as a tool dependency. Editor-buffer tools stay + in the GUI *by vocabulary* (Core has no concept of "editor"), not merely by consumer count. This boundary + is enforced by `ToolPortBoundaryTests`. +- **Tool contracts live in `IAiTool` metadata, not host flags or prose.** `RequiresVision`/`ReturnsImage` + gate vision-dependent tools; the orchestrator resolves model vision from `provider`+`modelId` itself. + Do not reintroduce host-supplied booleans (the old `visionSupported` param let GUI silently claim vision + for non-vision models). Capability absence (e.g. no Pad assembled) is what makes a tool read-only — + `EvalEcsTool` is the reference pattern. +- **Deferred decision (script run control):** `IScriptRunPort` still starts scripts from editor content + (GUI session semantics). When CLI needs to run scripts, introduce an assemblable Core run-control port + (over `IScriptEngine`) and migrate `run_script`/`stop_script` onto it — do not duplicate the GUI path. +- **Accepted wart (MCP transport in Core):** `McpServerHost` (stdio + loopback HTTP) lives in Core via + `ModelContextProtocol.Core` because both CLI and GUI host it. If it ever grows host-specific behavior, + split the transports into a host-shared project and keep only the tool-catalog export in Core. +- One known wart: `EasyCon2.UI.Common` is a vestigial resx-only project. ## Code conventions (enforced by .editorconfig) - **Indent**: 4 spaces, no tabs -- **Line endings**: CRLF +- **Line endings**: not pinned — `.gitattributes` `* text=auto` keeps repo content normalized to LF; + checkout EOL follows each platform's git config (Windows → CRLF, Linux/macOS → LF). + `.editorconfig` sets no `end_of_line`, and `dotnet format` therefore doesn't check EOL. + The only requirement is that a single file stays internally consistent — mixed-EOL files fail format. - **No final newline** (`insert_final_newline = false`) — unusual, don't "fix" it - **Private fields**: `_camelCase` prefix - **Explicit types**: `csharp_style_var_* = false` — prefer explicit types over `var` @@ -74,9 +138,10 @@ ci\windows-x64.bat ## MVVM architecture (Avalonia UI) -The Avalonia GUI (`EasyCon2.Avalonia` / `EasyCon2.Avalonia.Core`) follows strict MVVM: +The Avalonia GUI (`EasyCon2.Avalonia`) follows MVVM: -- **ViewModel must NOT reference any Avalonia control types** (Window, Control, TextBox, etc.) +- **ViewModel should NOT reference Avalonia control types** (Window, Control, TextBox, etc.). This was previously **compiler-enforced** by the separate `EasyCon2.Avalonia.Core` project (since merged into `EasyCon2.Avalonia` at user request), so the constraint is now convention-only — do not reintroduce Avalonia types into ViewModels. +- **View resolution is explicit, not convention-based**: views are declared in axaml with `DataContext="{Binding ...}"`, or constructed by `WindowService`. The former `ViewLocator` (dead code) has been deleted — do not rely on `FooViewModel → FooView` name mapping. Namespaces follow folders: `EasyCon2.Avalonia.`; axaml `using:` and `x:DataType` must match. - **View → ViewModel**: prefer bindings (`{Binding}`, `{x:Bind}`), avoid code-behind event subscriptions - **ViewModel → View**: use `[ObservableProperty]` (CommunityToolkit.Mvvm) or `AvaloniaProperty` with bindings - **Code-behind** is only for: platform APIs (file dialogs, drag-drop), layout (SizeChanged), visual tree init (FoldingManager, LSP). No business logic. @@ -85,15 +150,39 @@ The Avalonia GUI (`EasyCon2.Avalonia` / `EasyCon2.Avalonia.Core`) follows strict ## Testing - **Framework**: NUnit (NOT xUnit or MSTest) -- Test projects: `EasyCon.Tests`, `EasyCon.Lsp.Tests`, `EasyCon.WinInput.Tests`, `EasyCon2.Avalonia.Core.Tests` +- Test projects: `EasyCon.Tests`, `EasyCon.Lsp.Tests`, `EasyCon.SDLInput.Tests`, `EasyCon2.Avalonia.Core.Tests`, `EasyCon2.Avalonia.UiTests` - Use `[Test]` attribute, not `[Fact]` +### Fake device connection (no-hardware device tests) + +Device logic (queue, throttle, report building, serialization) can be exercised without an MCU by injecting a fake connection: + +- `EasyCon.Device` exposes the seam: `IConnection` is `public`, and `NintendoSwitch.CreateConnection(connStr, baudrate)` is `protected virtual` (its default returns `TTLSerialClient`). The production enqueue/dequeue path is unchanged. +- A second seam `NintendoSwitch.CreatePokeldnConnection(connStr)` (default returns `PokeldnSerialClient`) backs the third fallback stage of `TryConnect` (115200 → 9600 → pokeldn 921600). Override it the same way to test the fallback order without hardware; see `PokeldnSerialClientTests`. +- Subclass `NintendoSwitch` in a test project and override `CreateConnection` to return a fake whose `Write(params byte[])` records the payload — that payload is exactly the HID packet sent to the MCU (`SwitchReport.GetBytes()`). Assert on this **dequeue output**, not on internal state. +- The fake must raise `StatusChanged(Status.Connected)` inside `Connect()` so `TryConnect` succeeds and the background device write loop starts. +- The write loop is asynchronous (30 ms `MINIMAL_INTERVAL`); wait for recorded bytes with a timeout instead of asserting immediately. +- `SdlEventLoop` and `SdlKeyboardInputBinder` can be constructed and driven via `HandleKeyEvent` **without loading native SDL**; native SDL is only touched by `SdlEventLoop.Start()`, so never call it in tests. +- Reference implementation: `test/EasyCon.SDLInput.Tests` (`FakeConnection`, `TestSwitch`, `KeyboardMappingHidTests`). Reuse this pattern for any test that needs device logic without hardware. + +### Headless UI tests (Avalonia.Headless) + +`test/EasyCon2.Avalonia.UiTests` renders real controls off-screen (no window server) to assert layout and to produce PNGs for visual review: + +- Initialize once per fixture with `TestAppBuilder.BuildAvaloniaApp().SetupWithoutStarting()`, then add `FluentTheme` plus `Resources/Styles/EasyConWorkbenchTheme.axaml` to `Application.Current.Styles`. +- Build the window, set `DataContext`, call `Show()`, then `CaptureRenderedFrame()` to force a render pass and capture it. +- **Never call `WriteableBitmap.Save` to write a screenshot.** It goes through `Avalonia.Skia.Helpers.ImageSavingHelper` → the SkiaSharp native PNG encoder, which **crashes the test host natively and intermittently** in this headless setup (`Fatal error` at `sk_pngencoder_encode`). The symptom is a run that silently executes fewer tests and exits non-zero, so `ci\test.bat` (and therefore `ci.yml`) turns red at random. Use the `SaveFrame` helper in `KeyMappingWindowRenderTests` instead: it encodes with SkiaSharp directly (`SKImage.FromPixels` + `Encode`), the same path the stable `SvgRenderSmokeTests` uses. Root cause is not yet identified (managed and native SkiaSharp are both 4.148.0, matching `Avalonia.Skia` 12.0.4 — not a version mismatch). +- Screenshot tests carry `[Explicit]` + `[Category("Manual")]` so they never run in CI. Run them on demand with `dotnet test test\EasyCon2.Avalonia.UiTests -c Release --filter "TestCategory=Manual"`; the PNGs land in `%TEMP%\opencode\svgqa\`. + ## CI pipeline - **format** job runs first (blocking): `dotnet format --verify-no-changes` - **Auto Fix Format** workflow auto-commits formatting fixes on PRs to main/dev - Main branch: Release build + test + publish artifact - Dev branch: Debug build + test only +- **Release** (`release.yml`): push a `v*` / `[0-9]*` tag (or run manually) → Windows x64 + macOS arm64 + build with full tests → both zips attached to a GitHub Release via `gh release create --generate-notes`. + `workflow_dispatch` builds and uploads artifacts without publishing. Reuses `ci\windows-x64.bat` / `ci/macos-app.sh`. - PR format commits use `[skip ci]` to avoid recursive triggers ## Native code (OpenCV) @@ -102,9 +191,25 @@ OpenCV bindings come from the `OpenCvSharp5` NuGet packages (OpenCV 5.0); native ## Documentation +**Current-state specs (kept in sync with code — trust these):** + +- `docs/Pipeline.md` — unified compilation pipeline and single-source-of-truth landing points +- `docs/ModuleImportRules.md` — module visibility/import rules R1–R5/R7, automatic-edge convergence, compile order +- `docs/ModuleSystem.md` — interface-based independent compilation + content-addressed cache +- `docs/VM2.md` — bytecode/instruction-set spec (the live VM): ECX1 flat image encoding (§3.1), + frame/register model (§3.3), calling convention (§3.4), and the S-01..S-21 dual-end + (C# interpreter ↔ C VM) semantic contract inline (S-20 pinned constants / S-21 + capability-default degradation implemented) - `docs/Script.md` — ECS scripting language reference - `docs/Functions.md` — script function handbook (script-author-facing, CN) -- `docs/Framework.md` — system architecture -- `docs/VM1.md` / `docs/VM2.md` — virtual machine instruction sets +- `docs/DrawingBoard.md` — Splatoon drawing board user guide (CN) + maintainer notes on `src/EasyCon2.Avalonia/DrawingBoard/` +- `docs/McuBytecodeDelivery.md` — plan of record for compiling and flashing MCU bytecode +- `docs/Flow.md` — node-graph orchestration spec: `*.flow.json` format, node catalog/semantics, + execution model (watchdog/timing/stop/slow sensing), HTTP + MCP API, Python canvas, known gaps + +**Historical / partially stale (verify against code before relying on them):** + +- `docs/Framework.md` — layering overview; has been corrected for the composition-root reality, but still the softest doc +- `ARCHITECTURE_REVIEW_REPORT.md` — 2026-09-26 audit (94 findings). Most P0/P1 items were landed in + `ac7e16c`; read it as a **historical checklist**, not a current defect list. - `docs/GETTING_STARTED.md` — user setup guide -- `src/EasyCon2.Avalonia/DOCUMENTATION_INDEX.md` — UI docs index diff --git a/Directory.Build.props b/Directory.Build.props index 0ff57b52..2d91361b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ net10.0 $(DefaultTargetFramework) strict - 1.7.0-alpha + 1.7.0-rc1 946057081 ca1e;elmagnifico;Nukieberry diff --git a/Directory.Packages.props b/Directory.Packages.props index 17caa396..ab4ce924 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,41 +9,48 @@ true + + + + - - - + + - - + + - - - + + + - - - + + + + - - - + + + + - - + + - - - - - + + + + + + - - - - + + + + + diff --git a/EasyCon2.slnx b/EasyCon2.slnx index 68b5e455..f4681e44 100644 --- a/EasyCon2.slnx +++ b/EasyCon2.slnx @@ -15,10 +15,14 @@ + + - + + + @@ -29,8 +33,6 @@ - - diff --git a/README.md b/README.md index 30e2963e..b9ab3c15 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,10 @@ ## 📚 核心文档 -- **[快速开始](./docs/GETTING_STARTED.md)** - 新手友好的安装教程 +- **[快速开始](./docs/GETTING_STARTED.md)** - 新手友好的安装教程与 `ezcon init` 项目初始化 - **[系统架构](./docs/Framework.md)** - 技术栈与系统架构设计 - **[脚本语法](./docs/Script.md)** - ECS脚本语言完整语法说明 +- **[Maa 接入与旧标签兼容](./docs/maa/P2.md)** - 标签转换、评分适配、OCR 试点与验收范围 - **[文档中心](./docs/README.md)** - 完整的技术文档导航 ## 🤝 参与贡献 @@ -77,6 +78,7 @@ ## 🔧 硬件支持 - **单片机固件**: [EasyMCU](https://github.com/EasyConNS/EasyMCU) - 支持 ESP32、Arduino 等兼容单片机 +- **pokeldn 手柄固件**: [接线与使用说明](docs/Pokeldn.md) - 普通 ESP32 串口直连;ESP32-S3 通过官方本地 BLE 服务,支持在线手柄及电脑端脚本 - **[指令集说明](https://docs.qq.com/sheet/DZm1ydlZadkpncUNo?c=A88A0AZ0)** - 虚拟机指令集完整文档 --- diff --git "a/assets/\346\233\264\346\226\260\350\257\264\346\230\216.txt" "b/assets/\346\233\264\346\226\260\350\257\264\346\230\216.txt" new file mode 100644 index 00000000..646d7b2c --- /dev/null +++ "b/assets/\346\233\264\346\226\260\350\257\264\346\230\216.txt" @@ -0,0 +1,185 @@ +(使用说明见软件的帮助菜单和群946057081内文档) +(如在使用中遇到软件错误请报告群管理员) +(如果遇到打不开的情况请安装dotnet desktop runtime最新版本然后重试) + +v1.7.0-rc1 +- 【更新】UI使用avalonia重构,完全跨平台 +- 【AI】接入agent,内置工具:读写脚本,编译/格式化,查看输出日志 + +v1.6.4-draft +- 【修复】ELIF格式化缩进的问题 +- 【新增】内置函数OCR,FRAME +- 【新增】in运算符 +- 【新增】UNTIL关键字 +- 【优化】其他稳定性优化 + +v1.6.3🎂 +- 【优化】界面设计重构,为2.0 版本准备 +- 【新增】虚拟手柄支持xbox手柄绑定 +- 【优化】原虚拟手柄键盘绑定默认值调整 +- 【优化】配置文件更新,现在统一使用用户本地配置路径,原有配置需手动迁移 +- 【优化】推送系统重构,支持HTTP模板配置。原内置pushplus通过模板支持,新增bark推送模板 +- 【优化】脚本系统更新,支持自动加载lib/目录下的.ecs库 +- 【修复】修复了部分错误写法导致程序崩溃的问题(感谢@路飞数字君) +- 【新增】深色模式 +- 【新增】编辑器更新,支持按住CTRL时通过鼠标滚轮调整文字大小 + +v1.6.1 +- 【优化】and/or操作的短路计算逻辑 +- 【优化】语法解析更加结构化,报错更准确 +- 【修复】删除自动保存的逻辑,现在改成提醒 +- 【修复】识图匹配度的小数结果现在会正确进行向上取整返回 +- 【插件】0.0.2配合v1.6.1实现错误报告提示 + +v1.60.0⭐ +- 【新增】伊机控命令行工具ezcon,支持运行脚本和格式化 +- 【新增】vscode插件,支持脚本高亮(.ecs后缀),定义跳转,一键运行脚本(自动调用命令行工具实现) +- 【新增】支持and/or运算符 + +v1.56-rc1 +- 【修复】运行脚本前自动保存编辑内容 +- 【修复】已知的脚本解析问题 +- 【优化】等待指令(WAIT,按键等)延时更准确(感谢@冰等群友提供实现) +- 【修复】FOR循环变量边界判断,现在FOR..TO语法结束后变量值为TO值+1 +- 【增加】RETURN语句支持返回值,函数定义类型标注语法(FUNC add : INT) +- 【修复】代码高亮增强和部分问题修复(感谢enigma) + +v1.55.0 +- 【重构】脚本引擎重构,支持更多语法 +- 【优化】UI更新 +- 【增加】注释/取消注释代码快捷键 +- 【增加】脚本支持表达式语法 +- 【增加】新增OCR搜图方法 +- 【修复】补充了代码提示缺少的关键字 +- 【修复】修复运行脚本时关闭程序脚本不停止的问题 +- 【修复】修复了极端情况下运行脚本卡死的问题 +- 【破坏】脱机烧录暂不可用 + +v1.53.0 +- 【修复】识图判定的同步问题 +- 【优化】运行脚本时不再卡顿UI,但注意不要连点 +- 【更新】脚本新增RETURN语法,用来从当前函数中直接返回 +- 【增加】代码折叠和关键字提示 +- 【增加】只能说有的搜索和替换 + +v1.52.0 +- 【修复】另存为操作在某些情况不生效的问题 +- 【优化】脚本引擎执行速度提高 +- 【优化】搜图控制台底层更新更流畅 +- 【增加】现在可以在菜单中检查更新了 +- 【修复】移除了LSS/RSS慢推语法说明 +- 【优化】优化了连接单片机时的等待操作,现在不再卡死界面 +- 【修复】修复了没连接采集卡就执行搜图脚本的错误 +- 【兼容】if条件语句用=判断相等现在会自动格式化成==,单个=判断语法将会废弃 +- 【优化】增加了一些常用快捷键 +- 【更新】amiibo数据库更新(2025/11/07) + +v1.50 +- 更新到net8.0平台,此版本是一个长期支持版本可放心食用 +- 更新搜图依赖库opencv,支持obs30+内置虚拟摄像头,无需安装额外插件 +- 搜图标签默认使用文件名做判断,不再使用内部名称 +- 配置界面不再要求token不能为空,使用推送功能请注意安全 +- amiibo数据库更新(2024/06/13) +- 处理了没网情况下推送闪退的问题 + +v1.489 +- 兼容老波特率 +- 增加CH32F103固件 +- 更新STM32F4系列固件 + +v1.488 +- 修复自定义Amiibo报错 +- 修复.net版本检测错误 + +v1.487 +- 调整画板默认延迟 +- 修复画板执行流程不正确 + +v1.486 +- Amiibo支持到20个存储,需要更新esp固件 +- 修复Amiibo nick存储出错 +- 修复自定义Amiibo第二个必出错 +- 画图增加自定义起点,支持部分重绘 + +v1.485 +- Amiibo支持自定义用户名 +- 增加未连接提示 + +v1.484 +- 支持显示自定义bin的Amiibo图片 +- 支持用户自定义登记信息 +- 支持Amiibo过滤选择 +- 改善Windows缩放造成的UI错位 +- 提高画图稳定性,双倍发包 + +v1.483 +- 优化画图速度,估计画图时间,允许调整画图大小,允许反转图片 +- 增加串口打开延迟选项,仅供有问题的esp32使用,普通情况下以最快速度打开串口 +- 增加Amiibo自定义生成 +- 增加Amiibo图片显示 +- 兼容之前自己copy的bin + +v1.482 +- 增加喷射画图功能 +- 预留动森画图 +- 预留鼠标代替摇杆 + +v1.481 +- 取消ESP32设置的模态窗口 +- 增加快捷的Amiibo切换 + +v1.48 +- 修复ESP32连接超时,需要连接两次的问题 +- 增加Amiibo支持,Amiibo存储,Amiibo切换语法 +- JoyCon模拟全功能ok + +v1.473 +- ESP32支持 +- 增加语法 +- 修复各种bug + +v1.472 +- STM32F103C8T6,STM32F401CCU6,STM32F411CEU6完全支持 +- 修复联机卡死 +- 修复默认启动ws +- 修复退出崩溃 + +v1.471 +- STM32F103C6T6完全支持 +- 修复跨线程调用崩溃 +- 修改启动默认config + +v1.47 +- UNO重新支持 +- STM32F103C6T6支持,但是固件和烧录模式暂时不支持 +- 修复二次串口连接永远失败的问题 +- 移除原仓库标记,代码重构 +- 抛弃所有老固件,固件重构,串口波特率修改,全部需要刷固件到V6版本 + +v1.462 +- 【增加】变量支持大更新,详情请看软件内脚本说明 +- 【优化】优化脚本编译,现在可以正确识别嵌套的程序体 +- 【优化】默认初始化搜图标签列表,但搜图前仍需要手动选择搜图图像源 +- 【修复】为了修复与群友之间的感情,下调对固件版本的限制以支持UNO硬件(UNO单片机仍需烧录旧版本固件) +- 【优化】统一推送token设置界面 +- 【优化】虚拟手柄按键映射逻辑,现在按ESC键会清空当前按键绑定 + +v1.46 +- 【兼容】兼容pushplus +- 【增加】增加QQ频道Token和频道远程控制功能 + +v1.45 +- 【优化】增加稳定性,修复部分内存泄漏,若发现程序错误可报告群管理员 +- 【优化】脚本编辑器去除最大字符限制并增加行号与高亮 +- 【增加】设置->烧录自动运行,可控制烧录脚本是否自动运行 +- 【增加】支持obs虚拟摄像头,仅支持插件版,内置虚拟摄像头暂不支持 +- 【增加】增强脚本功能,具体内容请看软件内说明 +- 【修复】菜单->项目源码打开失败,感谢群员指出 + +v1.40 +- 修复某些笔记本摄像头和采集卡序号翻转的问题 +- 修复一些小bug +- 修改部分帮助内容 +- 增加采集卡类型选择 +- 关闭cpu优化不再需要先连接串口 +- 修复一个多串口的情况下自动搜索端口可能报错,导致永远找不到的情况 diff --git a/ci/build-vm.sh b/ci/build-vm.sh index e9a0cb74..9d0946bd 100755 --- a/ci/build-vm.sh +++ b/ci/build-vm.sh @@ -1,21 +1,69 @@ #!/bin/sh -# ecs-vm CI 构建脚本(docs/VmDevPlan.md §5 V7)。 -# CI 环境可能没有 C 工具链:cc/gcc/clang 全部缺失时跳过(退出 0),有则构建并保持 -Wall -Wextra 零警告。 +# ecs-vm CI 构建脚本(docs/VmDevPlan.md §5 V7;容量门禁 docs/VmBudget.md B5)。 +# 用法:build-vm.sh [budget] +# 缺省 :全量档 ecs-vm(零警告 -Wall -Wextra)。 +# budget :额外构建 ecs-vm-budget(C99 switch 派发,arena=768 / frame_seg=384, +# MCU 1KB 预算档,见 docs/VmBudget.md §预算表)并执行容量门禁: +# RAM:ecs_vm 实例(instance = sizeof(ecs_vm) − sizeof(ecs_host))≤ 1024B; +# ROM:文本段 + 只读数据 ≤ 64KB。 +# budget / ECSVM_REQUIRE_CC=1 不允许缺编译器时跳过;ECSVM_CC 可指定编译器或包装脚本。 set -e -CC="" -for c in cc gcc clang; do - if command -v "$c" >/dev/null 2>&1; then - CC="$c" - break - fi -done +case "${1:-}" in ""|budget) ;; *) echo "unknown profile: $1" >&2; exit 1 ;; esac +CC="${ECSVM_CC:-${CC:-}}" +if [ -z "$CC" ]; then + for c in cc gcc clang; do + if command -v "$c" >/dev/null 2>&1; then CC="$c"; break; fi + done +fi if [ -z "$CC" ]; then + if [ "${1:-}" = "budget" ] || [ "${ECSVM_REQUIRE_CC:-0}" = "1" ]; then + echo "FAIL: 未找到 C99 编译器,不能跳过原生容量/语义验证" >&2 + exit 1 + fi echo "skip: 未找到 C 编译器(cc/gcc/clang),ecs-vm 构建跳过" exit 0 fi cd "$(dirname "$0")/../src/EasyCon.Vm/native" -"$CC" -O2 -std=c99 -Wall -Wextra -o ecs-vm ecs_vm.c ecs_main.c -lm +"$CC" -O2 -std=c99 -Wall -Wextra -Werror -o ecs-vm ecs_vm.c ecs_main.c -lm echo "built: $(pwd)/ecs-vm (cc=$CC)" + +if [ "${1:-}" = "budget" ]; then + "$CC" -O2 -std=c99 -Wall -Wextra -Werror -DECS_NO_DIRECT_THREADING=1 \ + -DECS_ARENA_BYTES=768 -DECS_FRAME_SEG_BYTES=384 \ + -o ecs-vm-budget ecs_vm.c ecs_main.c -lm + echo "built: $(pwd)/ecs-vm-budget (cc=$CC, MCU 1KB 预算档)" + + # ---- RAM 门禁:instance ≤ 1024B(docs/VmBudget.md §预算表;跨指针宽度可移植口径) ---- + mem=$(./ecs-vm-budget --mem) + echo "budget mem: $mem" + instance=$(printf '%s' "$mem" | sed -n 's/.*instance=\([0-9]*\).*/\1/p') + if [ -z "$instance" ] || [ "$instance" -gt 1024 ]; then + echo "FAIL: VM 预算档实例 ${instance:-?}B 超出 1024B 上限" + exit 1 + fi + + # ---- ROM 门禁:ecs_vm.c 纯文本段 ≤ 64KB(固件只编入 VM 本体;ecs_main.c 是 PC 参考宿主, + # 其 printf/CLI 不代表 MCU 占用,故以目标文件为口径)。x86-64 gcc -O2 为代理平台。 ---- + "$CC" -O2 -std=c99 -Wall -Wextra -Werror -DECS_NO_DIRECT_THREADING=1 \ + -DECS_ARENA_BYTES=768 -DECS_FRAME_SEG_BYTES=384 \ + -c ecs_vm.c -o ecs-vm-budget.o + trap 'rm -f ecs-vm-budget.o' 0 + SIZE="${ECSVM_SIZE:-}" + if [ -z "$SIZE" ]; then + for s in size llvm-size; do + if command -v "$s" >/dev/null 2>&1; then SIZE="$s"; break; fi + done + fi + if [ -z "$SIZE" ]; then echo "FAIL: 未找到 size/llvm-size" >&2; exit 1; fi + text=$("$SIZE" ecs-vm-budget.o | awk 'NR==2 {print $1}') + rm -f ecs-vm-budget.o + echo "budget rom: ecs_vm text=${text}B (limit 65536B)" + if [ -z "$text" ] || [ "$text" -gt 65536 ]; then + echo "FAIL: VM 预算档文本段 ${text:-?}B 超出 64KB 上限" + exit 1 + fi + echo "budget gates: OK (rom ${text}B ≤ 64KB, ram ${instance}B ≤ 1KB)" +fi diff --git a/ci/cc32.sh b/ci/cc32.sh new file mode 100755 index 00000000..f046a6b0 --- /dev/null +++ b/ci/cc32.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Linux CI 的 C99 32 位包装器;ECSVM_CC 指向本文件,CvmRunner 与 budget 共用。 +exec gcc -m32 "$@" diff --git a/ci/prepare-maa-ocr.ps1 b/ci/prepare-maa-ocr.ps1 new file mode 100644 index 00000000..02d3ab9c --- /dev/null +++ b/ci/prepare-maa-ocr.ps1 @@ -0,0 +1,13 @@ +param([ValidateSet('Debug', 'Release')][string]$Configuration = 'Release') +$ErrorActionPreference = 'Stop' +$repository = Split-Path $PSScriptRoot -Parent +$models = Join-Path $repository '.ci-maa-ocr' +dotnet run --project (Join-Path $repository 'src/EasyCon2.CLI') -c $Configuration -- maa models install $models +if ($LASTEXITCODE -ne 0) { throw 'Maa OCR model installation failed' } +$profile = Join-Path $models 'ocr-profile.json' +$env:EASYCON_TEST_MAA_OCR_PROFILE = $profile +$env:EASYCON_MAA_REQUIRE_OCR_TESTS = '1' +if ($env:GITHUB_ENV) { + "EASYCON_TEST_MAA_OCR_PROFILE=$profile" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + 'EASYCON_MAA_REQUIRE_OCR_TESTS=1' | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +} diff --git a/ci/test-connection-evidence.ps1 b/ci/test-connection-evidence.ps1 new file mode 100644 index 00000000..25bca31c --- /dev/null +++ b/ci/test-connection-evidence.ps1 @@ -0,0 +1,94 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$verifier = Join-Path $PSScriptRoot 'verify-connection-evidence.ps1' +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$fixtureRoot = Join-Path $tempRoot ('easycon-m18-evidence-' + [Guid]::NewGuid().ToString('N')) +$renderDirectory = Join-Path $fixtureRoot 'render' +$trxPath = Join-Path $fixtureRoot 'connection-m18.trx' + +function Assert-Rejected([string]$Name, [string]$Directory = $renderDirectory, [string]$Results = $trxPath) { + try { & $verifier -RenderDirectory $Directory -TrxPath $Results } + catch { Write-Host "PASS rejects $Name"; return } + throw "M18 evidence verifier incorrectly accepted: $Name" +} + +try { + [IO.Directory]::CreateDirectory($renderDirectory) | Out-Null + $pixel = [Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=') + $testResults = @() + foreach ($layout in @('classic', 'card')) { + foreach ($width in @(1200, 800)) { + $prefix = "$layout-$width" + $records = @( + foreach ($nameIndex in 0..2) { + foreach ($inputConnected in @($false, $true)) { + foreach ($captureConnected in @($false, $true)) { + $image = "$prefix-name$nameIndex-input$inputConnected-capture$captureConnected.png" + [IO.File]::WriteAllBytes((Join-Path $renderDirectory $image), $pixel) + @{ layout = $layout; width = $width; image = $image } + } + } + } + @{ layout = $layout; width = $width; hover = 'passed'; duplicateSelection = 'passed'; fullName = 'USB Video #1' } + ) + [IO.File]::WriteAllBytes((Join-Path $renderDirectory "$prefix-hover.png"), $pixel) + $records | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $renderDirectory "$prefix.json") -Encoding utf8 + $testName = [Security.SecurityElement]::Escape(('ConnectionPanels_RenderShortStatesAndStableWidths_AndExposeFullEndpointNames("{0}",{1})' -f $layout, $width)) + $testResults += "" + } + } + $trxText = '' + ($testResults -join '') + '' + [IO.File]::WriteAllText($trxPath, $trxText) + & $verifier -RenderDirectory $renderDirectory -TrxPath $trxPath + Write-Host 'PASS complete evidence' + + Assert-Rejected 'relative render directory' -Directory 'render' + Assert-Rejected 'relative TRX path' -Results 'connection-m18.trx' + $emptyDirectory = Join-Path $fixtureRoot 'trx-only' + [IO.Directory]::CreateDirectory($emptyDirectory) | Out-Null + Assert-Rejected 'TRX without screenshots/JSON (F6)' -Directory $emptyDirectory + + $pngPath = Join-Path $renderDirectory 'classic-800-hover.png' + [IO.File]::Move($pngPath, "$pngPath.missing") + Assert-Rejected 'one missing PNG' + [IO.File]::Move("$pngPath.missing", $pngPath) + [IO.File]::WriteAllBytes($pngPath, [byte[]]::new(16)) + Assert-Rejected 'invalid PNG with correct file count' + [IO.File]::WriteAllBytes($pngPath, $pixel) + [IO.File]::Move($pngPath, (Join-Path $renderDirectory 'unexpected.png')) + Assert-Rejected 'wrong PNG name with correct file count' + [IO.File]::Move((Join-Path $renderDirectory 'unexpected.png'), $pngPath) + + $jsonPath = Join-Path $renderDirectory 'classic-800.json' + $jsonText = [IO.File]::ReadAllText($jsonPath) + [IO.File]::Move($jsonPath, "$jsonPath.missing") + Assert-Rejected 'one missing JSON' + [IO.File]::Move("$jsonPath.missing", $jsonPath) + [IO.File]::WriteAllText($jsonPath, '[]') + Assert-Rejected 'incomplete state/hover records' + [IO.File]::WriteAllText($jsonPath, $jsonText.Replace('passed', 'failed')) + Assert-Rejected 'failed hover/duplicate selection' + [IO.File]::WriteAllText($jsonPath, $jsonText) + + [IO.File]::Move($trxPath, "$trxPath.missing") + Assert-Rejected 'missing TRX' + [IO.File]::Move("$trxPath.missing", $trxPath) + [IO.File]::WriteAllText($trxPath, $trxText.Replace('outcome="Completed"', 'outcome="Failed"')) + Assert-Rejected 'failed test run with four passed results' + [IO.File]::WriteAllText($trxPath, $trxText.Replace('passed="4"', 'passed="3"')) + Assert-Rejected 'fewer than four passed cases' + [IO.File]::WriteAllText($trxPath, $trxText.Replace('classic",800', 'classic",801')) + Assert-Rejected 'wrong layout/width test case' + [IO.File]::WriteAllText($trxPath, $trxText) + & $verifier -RenderDirectory $renderDirectory -TrxPath $trxPath + Write-Host 'PASS evidence verifier regression: complete evidence accepted; 13 incomplete/invalid cases rejected' +} +finally { + $resolvedFixture = [IO.Path]::GetFullPath($fixtureRoot) + $tempPrefix = $tempRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (!$resolvedFixture.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($resolvedFixture) -notlike 'easycon-m18-evidence-*') { + throw "Refusing to clean an unexpected fixture path: $resolvedFixture" + } + if (Test-Path -LiteralPath $resolvedFixture) { Remove-Item -LiteralPath $resolvedFixture -Recurse -Force } +} \ No newline at end of file diff --git a/ci/verify-connection-evidence.ps1 b/ci/verify-connection-evidence.ps1 new file mode 100644 index 00000000..491218d0 --- /dev/null +++ b/ci/verify-connection-evidence.ps1 @@ -0,0 +1,72 @@ +param( + [Parameter(Mandatory)][string]$RenderDirectory, + [Parameter(Mandatory)][string]$TrxPath +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (![IO.Path]::IsPathFullyQualified($RenderDirectory) -or ![IO.Path]::IsPathFullyQualified($TrxPath)) { + throw 'M18 render directory and TRX path must both be absolute' +} +if (!(Test-Path -LiteralPath $RenderDirectory -PathType Container)) { + throw "M18 render directory is missing: $RenderDirectory" +} +$pngFiles = @(Get-ChildItem -LiteralPath $RenderDirectory -File -Filter '*.png') +$jsonFiles = @(Get-ChildItem -LiteralPath $RenderDirectory -File -Filter '*.json') +if ($pngFiles.Count -ne 52 -or $jsonFiles.Count -ne 4) { + throw "M18 evidence is incomplete: expected 52 PNG and 4 JSON; found $($pngFiles.Count) PNG and $($jsonFiles.Count) JSON" +} +$expectedTestNames = @() +foreach ($layout in @('classic', 'card')) { + foreach ($width in @(1200, 800)) { + $prefix = "$layout-$width" + $jsonPath = Join-Path $RenderDirectory "$prefix.json" + $records = @(ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($jsonPath))) + if ($records.Count -ne 13) { throw "M18 JSON must contain 12 states and one hover record: $jsonPath" } + foreach ($record in $records) { + if ($record.layout -ne $layout -or $record.width -ne $width) { + throw "M18 JSON has mismatched layout/width: $jsonPath" + } + } + $states = @($records | Where-Object { $_.PSObject.Properties.Name -contains 'image' }) + $expectedImages = @( + foreach ($nameIndex in 0..2) { + foreach ($inputConnected in @($false, $true)) { + foreach ($captureConnected in @($false, $true)) { + "$prefix-name$nameIndex-input$inputConnected-capture$captureConnected.png" + } + } + } + ) + if ($states.Count -ne 12 -or + [string]::Join('|', @($states.image | Sort-Object)) -cne [string]::Join('|', @($expectedImages | Sort-Object))) { + throw "M18 JSON does not reference all 12 distinct state images: $jsonPath" + } + $hover = @($records | Where-Object { $_.PSObject.Properties.Name -contains 'hover' }) + if ($hover.Count -ne 1 -or $hover[0].hover -ne 'passed' -or $hover[0].duplicateSelection -ne 'passed' -or + [string]::IsNullOrWhiteSpace($hover[0].fullName)) { + throw "M18 hover/duplicate-selection evidence is incomplete: $jsonPath" + } + foreach ($imageName in @($expectedImages) + @("$prefix-hover.png")) { + $imagePath = Join-Path $RenderDirectory $imageName + $bytes = [IO.File]::ReadAllBytes($imagePath) + if ($bytes.Length -le 8 -or [Convert]::ToHexString($bytes[0..7]) -ne '89504E470D0A1A0A') { + throw "M18 PNG is empty or invalid: $imagePath" + } + } + $expectedTestNames += 'ConnectionPanels_RenderShortStatesAndStableWidths_AndExposeFullEndpointNames("{0}",{1})' -f $layout, $width + } +} + +[xml]$trx = [IO.File]::ReadAllText($TrxPath) +$summary = $trx.TestRun.ResultSummary +$counters = $summary.Counters +$results = @($trx.TestRun.Results.UnitTestResult) +if ($summary.outcome -notin @('Completed', 'Passed') -or [int]$counters.total -ne 4 -or + [int]$counters.executed -ne 4 -or [int]$counters.passed -ne 4 -or [int]$counters.failed -ne 0 -or + [int]$counters.notExecuted -ne 0 -or $results.Count -ne 4 -or + @($results | Where-Object outcome -ne 'Passed').Count -ne 0 -or + [string]::Join('|', @($results.testName | Sort-Object)) -cne [string]::Join('|', @($expectedTestNames | Sort-Object))) { + throw "M18 TRX must report all four layout/width cases passed with a successful run outcome: $TrxPath" +} +Write-Host "M18 evidence verified: 52 PNG, 4 JSON, 4 passed tests; render directory: $RenderDirectory" \ No newline at end of file diff --git a/ci/windows-x64.bat b/ci/windows-x64.bat index c1dcbf3a..9d54aa7c 100644 --- a/ci/windows-x64.bat +++ b/ci/windows-x64.bat @@ -29,7 +29,7 @@ if exist "%PROJ_DIR%\obj" rmdir /s /q "%PROJ_DIR%\obj" dotnet publish "%PROJ_DIR%\%PROJ_NAME%.csproj" --nologo -c Release -r win-x64 -f %TFM% -p:PublishSingleFile=true --self-contained false -o "%PUBLISH_DIR%" if errorlevel 1 ( echo 错误: 编译 "%PROJ_NAME%" 失败 - pause + if "%CI%"=="" pause exit /b 1 ) @@ -44,7 +44,7 @@ if exist "%PROJ_DIR%\obj" rmdir /s /q "%PROJ_DIR%\obj" dotnet publish "%PROJ_DIR%\%PROJ_NAME%.csproj" --nologo -c Release -r win-x64 -f %TFM% -p:PublishSingleFile=true --self-contained false -o "%PUBLISH_DIR%" if errorlevel 1 ( echo 错误: 编译 "%PROJ_NAME%" 失败 - pause + if "%CI%"=="" pause exit /b 1 ) @@ -64,6 +64,9 @@ echo 正在复制额外文件... if exist "%ROOT_DIR%\assets\fw" xcopy /e /y /q "%ROOT_DIR%\assets\fw\*" "%PUBLISH_DIR%\Firmware\" if not exist "%PUBLISH_DIR%\Firmware" mkdir "%PUBLISH_DIR%\Firmware" +:: 复制更新说明 +if exist "%ROOT_DIR%\assets\更新说明.txt" copy /y "%ROOT_DIR%\assets\更新说明.txt" "%PUBLISH_DIR%\更新说明.txt" + :: 删除调试文件 if exist "%PUBLISH_DIR%\*.pdb" del /q "%PUBLISH_DIR%\*.pdb" @@ -71,8 +74,14 @@ if exist "%PUBLISH_DIR%\*.pdb" del /q "%PUBLISH_DIR%\*.pdb" echo 正在移动到dist文件夹... robocopy "%PUBLISH_DIR%" "%DIST_DIR%\publish" /move /e +set "ROBOCOPY_EXIT_CODE=!ERRORLEVEL!" +if !ROBOCOPY_EXIT_CODE! GEQ 8 ( + echo 错误: 复制发布文件失败,Robocopy 退出码 !ROBOCOPY_EXIT_CODE! + exit /b !ROBOCOPY_EXIT_CODE! +) if exist "%PUBLISH_DIR%" rmdir /s /q "%PUBLISH_DIR%" echo 构建完成! echo 输出目录: "%DIST_DIR%\publish" -if "%CI%"=="" pause \ No newline at end of file +if "%CI%"=="" pause +exit /b 0 diff --git a/docs/AudioLabels.md b/docs/AudioLabels.md new file mode 100644 index 00000000..2badde3a --- /dev/null +++ b/docs/AudioLabels.md @@ -0,0 +1,155 @@ +# 声音标签 + +在主窗口的“游戏音频”面板点击“声音标签”,可以录制新声音,也可以从最近的游戏声音创建音效样本。经典布局和卡片布局均提供入口。 + +第一次在脚本里使用时,先按[第一次在 ECS 中使用](#第一次在-ecs-中使用)准备目录;遇到加载或运行失败,查看[常见报错排查](#常见报错排查)。 + +## 创建标签 + +1. 选择并连接游戏音频输入。应用持续在内存缓存最近最多 15 秒,无需提前打开标签窗口。 +2. 在“声音标签”窗口点击“开始录制”,再去游戏中触发目标音效,结束后点击同一位置的“停止录制”。按钮旁显示已录时长和本次上限,停止后生成可编辑波形。每次最多录制 15 秒,同时受 16 MiB 音频数据上限约束;高采样率、多声道时按显示的较短时长自动停止,保留从点击开始起的声音。若目标声音已经响过,也可以打开窗口或点击“截取最近 10 秒”回看最近声音;尚未积累 10 秒时使用现有数据,并显示实际时长。 +3. 在波形上拖动框选完整音效,拖动左右边界调整,或直接输入开始、结束时间。用于 ECS 或实时匹配测试的选区必须为 0.15~10 秒,且包含有效声音特征;录制上限 15 秒不代表可以用 15 秒的标签匹配。波形显示所有声道的幅度包络;试听仍保留原始声道。 +4. 点击“试听选区”。声音通过 Windows 默认输出设备播放,红色游标和下方读数随实际播放位置更新,时间刻度对应整段录音。例如试听第 3~5 秒时,游标从第 3 秒开始。播放期间按钮变为“暂停试听”,点击后声音和游标停止,再点击“继续试听”从游标处接着听。输入源没有扬声器监听;只有主动试听才播放。 +5. 输入名称,点击“保存选区为标签”。当前已打开且已保存 ECS 时,直接保存到脚本旁的 `audiolabel`,不存在就自动创建,无需选择目录。没有打开脚本或脚本尚未保存时,选择准备存放 ECS 的文件夹,程序在里面创建 `audiolabel/<名称>.audiolabel`。保存后显示完整路径;已有同名标签不会被覆盖,修改后请使用新名称保存。 + +这里的“尚未保存”指新建脚本从未保存、尚无文件路径;已有 ECS 即使有未保存的内容修改,标签也会按其现有文件路径保存。 + +“打开 WAV / 声音标签”可载入 PCM 8/16/24/32 bit 或 float32 WAV(包括 WAVE_FORMAT_EXTENSIBLE 封装),或打开已保存标签中的 `label.json`。音频数据上限为 16 MiB。不支持压缩 WAV。非 Windows 平台可以打开、裁剪和保存已有文件,但暂不提供实时采集与试听。 + +录制期间不能截取历史、打开文件、编辑选区、试听或保存,完成后恢复。录制中断、重连、收到无效音频或超过 1 秒没有新数据时会报错,已有音频和选区会保留;没有录到数据时停止也不会生成空标签。静音采样仍是有效音频。 + +录制和截取都是独立副本,编辑期间采集继续运行。裁剪按完整采样帧对齐,保留原始采样率、声道和位深,不重采样、不做增益或降噪。保存始终使用完整选区,试听时移动游标不会改变保存内容。关闭窗口会取消录制、停止试听并释放编辑副本;音频源继续采集。请在关闭前保存需要保留的选区。 + +## 第一次在 ECS 中使用 + +ECS 根据**已保存的入口脚本所在目录**查找声音标签。直接在窗口里打开或测试过标签,并不会把它自动关联到脚本;软件安装目录和最近打开文件的位置也不决定查找路径。 + +以标签“闪光音效”为例,按以下步骤准备: + +1. 新建自己的脚本目录,例如 `D:\游戏脚本\闪光测试`,把脚本保存为其中的 `test.ecs`,并保持这个脚本打开。后续运行这个入口文件。 +2. 新制作标签时,在编辑器中输入名称 `闪光音效`,点击“保存选区为标签”。程序直接在 `D:\游戏脚本\闪光测试\audiolabel` 中创建 `闪光音效.audiolabel`;缺失的 `audiolabel` 会自动创建,不再弹出目录选择框。保存位置以点击保存时当前打开的脚本为准。 +3. 若标签已经做好,把整个 `闪光音效.audiolabel` 文件夹复制到脚本旁的 `audiolabel`,保留其中的两个文件;手动复制时若没有 `audiolabel`,先新建这一层。 +4. 核对最终目录结构,再在主窗口选择并连接游戏音频输入。声音匹配测试通过后,回到 `test.ecs` 运行下方脚本,并在等待期间触发游戏里的目标声音。 + +```text +D:\游戏脚本\闪光测试\ +├─ test.ecs +└─ audiolabel\ + └─ 闪光音效.audiolabel\ + ├─ label.json + └─ audio.wav +``` + +本例应当能在资源管理器中找到 `D:\游戏脚本\闪光测试\audiolabel\闪光音效.audiolabel\label.json` 和同目录的 `audio.wav`。`test.ecs` 与 `audiolabel` 是同级;`.audiolabel` 是装有两个文件的文件夹。 + +如果先制作标签,还没有打开或保存 ECS,点击保存后会提示“选择脚本所在文件夹(自动创建 audiolabel)”。选择 `D:\游戏脚本\闪光测试` 即可,最终保存位置仍是上图;稍后将 ECS 保存到这个文件夹再运行。取消选择不会创建目录,也不会丢失当前选区。 + +目录选择框始终选择准备存放 ECS 的文件夹,不要进入它下面用于存放标签的 `audiolabel` 子文件夹。程序始终在所选目录下面创建或复用 `audiolabel`;即使脚本目录本身恰好叫 `audiolabel`,也按这一规则处理,保持与 ECS 的查找路径一致。 + +```ecs +AUDIO_LOAD "闪光音效" +PRINT "开始识别,请在接下来的 8 秒内触发目标声音" +WAIT 8000 +$score = AUDIO_SCORE("闪光音效") +PRINT "本轮最高分:" & $score +AUDIO_UNLOAD "闪光音效" +``` + +函数中只填制作时的名称 `"闪光音效"`,不填文件路径、`.audiolabel` 后缀或 `@`。此例在脚本 WAIT 期间后台识别,8 秒后读取已完成分析的累计最高分;不会命中后自动退出。尚无完整分析结果时为 0;加载失败或输入中断会报错,处理方法见[常见报错排查](#常见报错排查)。更多控制流程见[函数手册](Functions.md#声音匹配桌面-gui需连接游戏音频)。 + +常见放错方式如下,`…` 表示前面的父目录: + +| 放置方式 | 问题与处理 | +|---|---| +| `…\闪光音效.audiolabel\test.ecs` | 脚本保存在标签内部,会从这里继续拼接 `audiolabel\…`。把入口脚本另存到上图的脚本目录,标签完整复制到其 `audiolabel` 下。 | +| `…\闪光测试\闪光音效.audiolabel\label.json` | 少了固定的 `audiolabel` 文件夹。在脚本同级创建这一层,再复制完整标签目录进去。 | +| `…\闪光测试\audiolabel\label.json` | 少了 `闪光音效.audiolabel` 这一层。保留标签文件夹及其中的两个文件。 | +| `…\闪光测试\audiolabel\audiolabel\闪光音效.audiolabel\label.json` | 多套了一层 `audiolabel`。按上图只保留一层。 | + +导入的子脚本调用音频函数时,也使用入口脚本的目录。把 ECS 另存到其他目录或交给另一台电脑测试时,应一起复制包含 `test.ecs` 和 `audiolabel` 的整个脚本目录,保持两者的相对位置;只复制 ECS 文件不包含声音标签。 + +### 名称、时长与阈值 + +- 名称必须与标签内部保存的名称完全一致,包括英文大小写。只把 `闪光音效.audiolabel` 改名为 `闪光.audiolabel`,再调用 `AUDIO_LOAD "闪光"`,会因内部名称仍是“闪光音效”而失败。需要改名时,在编辑器打开原标签,修改名称并另存,然后在脚本中使用新名称。 +- ECS 匹配和测试窗口使用同样的模板要求:0.15~10 秒,有足够的声音特征。可以保存或试听的片段未必满足匹配要求;静音、过短、过长的片段需要重新选区。 +- 测试窗口调好的阈值不会保存到标签或自动同步到脚本。例如校准后采用 `92`,脚本应写 `IF AUDIO_SCORE("闪光音效") >= 92`。分数是 0~100 的整数,百分制向下取整:84.9 得到 84,85.0 得到 85;应以实际正、负样本校准为准。 + +## 暂停、定位与快捷键 + +- 暂停后单击波形,或拖动红色游标,可以前后定位。“继续试听”从新位置播放到选区末尾。例如选区是第 3~5 秒,在第 4.2 秒暂停后单击第 3.6 秒,继续就从第 3.6 秒开始;保存仍保留第 3~5 秒。 +- 游标只能定位到选区内,点击外侧会停在最近的选区边界。游标与裁剪边界重合时,可拖动顶部红色小手柄移动游标,拖动下方边界线调整裁剪。拖动其他波形区域仍用于重新框选,开始、结束时间也可以直接输入。改变选区会结束暂停状态,下一次试听从新选区起点开始。 +- “停止试听”结束本次试听,保留当前位置供参考;下一次试听从选区起点开始。停止后也可单击波形指定新的试听起点。播放到选区末尾自动结束;在末尾继续不会自动循环。 +- 空格切换播放/暂停/继续,长按只执行一次。即使焦点在“截取最近 10 秒”“开始录制”等按钮上,也不会触发这些按钮;尚无音频或正在录制时,空格不执行操作。标签名称和时间输入框保留自身的输入行为,要用空格控制试听时先点击波形或试听按钮。 + +## 实时匹配测试 + +1. 在主窗口的“游戏音频”面板连接输入,点击“声音匹配测试”。经典布局和卡片布局都有入口。 +2. 点击“打开 WAV / 声音标签”,选择标签目录里的 `label.json`,也可以直接打开 WAV。测试模板需为 0.15~10 秒,包含完整且清晰的目标音效;静音和缺少有效特征的文件会被拒绝。 +3. 点击“开始监听”,等候积累完整音效长度的新音频,然后在游戏中触发目标声音。监听从点击开始后的新数据计算,不会拿制作标签时的历史声音重复测试。 +4. 查看“当前匹配分数”“本次最高分”和命中记录。当前分数是最近一批音频(通常约 100 ms)中最佳对齐位置的分数;没有完整、有效音频时显示 `—`。命中记录保存首次达到阈值时的分数、当时阈值和电脑检测时间,最近 100 条保留在内存。 +5. 初始阈值是 85(整数范围 10~100,步长 1),可以在监听时调整。连续高分只计一次;分数下降到阈值以下 5 分后才允许再次触发,并受“命中间隔”限制(默认 1 秒)。调整只影响后续音频,不会重新判定旧结果。 +6. 点击“停止监听”保留结果;再次开始会清空本次结果。“清空结果”只重置界面统计,不重新触发正在响的同一次音效。关闭测试窗口会停止匹配,不会断开主窗口的音频输入。 + +先重复触发目标声音 5~10 次,检查是否漏报;再播放普通游戏声音和背景音乐,检查是否误报。根据两组分数调整阈值。如果分数混在一起,仅改阈值无法可靠区分,应重新截取背景干扰更少的模板。不要仅通过在电脑上回放模板来认定游戏识别已验证:真实游戏混音和音量变化还需单独测试。 + +匹配使用托管频谱模板比较:带滤波的 8 kHz 重采样、32 ms Hann 窗、10 ms 步长,合并各声道的频谱功率后提取归一化特征。左右声道反相不会因直接混为单声道而抵消。特征主要覆盖约 94~4000 Hz,对外分数为 0~100 的整数,表示相似度,不是出闪概率。归一化能减轻音量变化的影响,不能分离重叠的背景音乐、恢复被噪声淹没的音效或适应任意速度变化。 + +实时匹配复用已有采集设备,在后台线程处理。输入队列限制为 2 秒、4 MiB 或 256 块;积压、无效样本、断线、会话或格式变化以及超过 1 秒未收到新回调时清空旧数据,重新积累完整窗口。采集接口没有硬件缺包标记或精确样本时间戳,不能保证发现所有短暂中断;检测时间也不是精确的游戏事件时间。非 Windows 平台可以加载模板,暂不支持实时监听。 + +## 文件格式 + +每个标签是一个可整体移动的 `<名称>.audiolabel` 目录,包含: + +- `audio.wav`:裁剪后的原始音频。 +- `label.json`:版本(当前为 1)、名称、音频格式、采样帧数及 UTC 创建时间。 + +保存先写入临时目录,两个文件完整写入后一次性发布目录;文件写入失败不会留下一个看似完整的新标签,也不会覆盖旧标签。打开时校验 JSON 与 WAV 的格式和长度是否一致。修改标签后以新名称保存,保留原标签。 + +## 范围与边界 + +当前提供样本制作、实时匹配测试及 ECS 的 `AUDIO_LOAD` / `AUDIO_SCORE` / `AUDIO_UNLOAD` 函数。实时匹配窗口只展示分数和命中记录;脚本通过这些函数读取结果并明确决定后续动作。仍需使用实际游戏的正、负样本校准。背景音乐与目标音效同时出现时,裁剪不能把两者分离,应尽量采集背景干扰小的样本。 + +编辑器缓存借用现有 `IAudioCaptureSource`,不创建第二个采集设备。编辑器按完整采样帧裁剪,同时受 15 秒与 16 MiB 上限约束。断开、重连、格式变化、无效数据或超过 1 秒没有新回调时丢弃旧历史;陈旧数据不能作为“最近音频”截取。编辑器缓存不参与脚本检测;每个加载标签使用独立后台识别,峰值不受编辑器历史缓存影响。 + +## ECS 识别生命周期与限制 + +函数签名和完整流程见[函数手册](Functions.md#声音匹配桌面-gui需连接游戏音频)。可直接使用三个示例:[边按键边识别](../examples/audio-load-score.ecs)、[观察并打印峰值](../examples/audio-observe.ecs)、[卸载后重新开始一轮](../examples/audio-reload.ecs)。示例标签名为“医疗”,需要按相同目录规则准备;按键示例另需连接手柄。 + +- `AUDIO_LOAD(name)` 加载模板并建立本轮后台匹配后继续执行,不等待命中。识别在按键、普通 WAIT、循环期间持续运行,没有内置超时或自动退出。文件读取和模板准备需要时间,目标操作应放在 LOAD 后。本轮监听建立之前的声音不纳入结果。 +- `AUDIO_SCORE(name)` 返回 INT,范围 0~100,为本次 LOAD 后已完成分析的累计最高相似度。读取不做现场分析、不等待、不清零、不消费结果,也不停止监听。尚无首个完整结果时为 0;有效静音也可得 0。60、92、40 的结果序列保留 92;经过 15 秒或目标声音结束后,只要输入持续有效,峰值仍保留。 +- `AUDIO_UNLOAD(name)` 停止该名称的后台工作并释放本轮资源与结果。重新 LOAD 从 0 开始;重复 LOAD 已加载名称会报错,需先卸载。SCORE/UNLOAD 未加载或已卸载名称也报错。不同名称可同时加载,各自独立;没有固定标签数量上限,资源消耗随加载数量增加。 +- `BREAK` 只退出循环,后台识别继续到 UNLOAD。脚本正常结束、异常或停止时自动清理全部本次运行的监听和后台任务;从不停止共享音频源。卸载会取消当前分析,并等后台退出后才完成,旧回调不能写入重载后的结果。 +- 模板长度 0.15~10 秒决定单次匹配上下文,不限制监听生命周期。后台复用测试窗口的增量匹配器,每个标签的待处理队列最多 2 秒、4 MiB 或 256 块,另外只保留模板长度的特征和一个峰值标量;没有用于脚本回看的滚动历史。 +- 输入断线/重连、格式变化、无效采样、队列积压、时间戳异常或超过 1 秒没有新数据,会终止本轮识别并保留错误。后续音频调用及脚本正常结束检查会报告错误,不返回旧高分或把错误当成 0。恢复输入后重新运行脚本;诊断窗口可自动重新积累音频,脚本不会自动恢复失效的一轮。 +- 采集回调只复制有界音频数据,后台线程执行重采样、FFT 和比较。算法内部继续使用 0~1 的浮点分数,在匹配结果出口转换为百分制并向下取整一次。脚本读取、UI 分数与峰值、命中记录及阈值比较共用这个 0~100 的整数结果。命中时间保留毫秒,命中间隔仍以秒计并保留一位小数。诊断窗口的命中去重和冷却只影响界面记录,不改变脚本峰值。 + +每块音频携带不可变设备会话标识。监听起点使用单调回调时钟,跨越 LOAD 边界的首块按采样帧裁掉旧部分,迟到的旧设备数据和已经结束的轮次不会进入新轮。回调时间不是硬件采样时间戳,短暂硬件缺包和精确游戏事件时刻仍受驱动限制。 + +SCORE 只提供读取时已完成的结果。声音刚响起或分析尚未完成时不会等待补算;需要更多观察时间,由脚本作者安排 WAIT 或继续循环。后台不会延长脚本指定的 WAIT,也没有命中后的自动退出或超时宽限。当前 GUI 复用已连接的游戏音频;CLI 和单片机缺少此能力,会明确拒跑。 + +## 常见报错排查 + +报错中的完整路径就是脚本实际查找的位置,先用它与资源管理器里的实际文件对照。以下问题会报运行错误,不能作为“没有匹配到声音”继续处理。 + +| 日志或现象 | 检查与处理 | +|---|---| +| “请先保存脚本” | 先将入口脚本保存为 `.ecs` 文件,再按[首次使用步骤](#第一次在-ecs-中使用)放置标签并重新运行。 | +| “声音标签…无法加载”且提示找不到 `label.json` | 核对入口脚本位置、固定的 `audiolabel` 层、`<名称>.audiolabel` 层和调用名称。不要把 ECS 保存在标签文件夹里。 | +| 找不到 `audio.wav`,或 JSON 与 WAV 不一致 | 重新复制完整标签目录,两个文件必须来自同一次保存;也可从原始录音重新制作标签。不要只复制 JSON 或单独替换 WAV。 | +| “目录名称与标签元数据中的名称不一致” | 使用制作标签时的原名称(包括大小写),或在编辑器修改名称后另存;仅改文件夹名不能完成标签改名。 | +| “同名标签已存在” | 根据提示中的完整路径确认旧标签,修改编辑器中的名称后再保存;程序不会覆盖旧标签。 | +| 模板要求 “0.15 to 10 seconds” 或“标签没有足够的声音特征” | 重新截取 0.15~10 秒且包含完整、清晰音效的选区,先在匹配测试窗口确认能加载。 | +| 未连接音频、断开或重新连接、超过 1 秒没有新数据 | 检查主窗口音频输入及连接状态,恢复连续采集后重新运行脚本;重连不能续用旧一轮的检测结果。 | +| “尚未加载或已卸载;请先 AUDIO_LOAD” | 在读取或卸载前先加载同名标签;各次脚本运行互不继承。 | +| “已加载;请先 AUDIO_UNLOAD” | 本轮需要重新开始时先卸载再加载;重复 LOAD 不会偷偷清零峰值。 | +| “后台识别失败”或“分析积压” | 检查采集状态和处理负载,减少不需要同时识别的标签,恢复后重新运行。 | + +刚 LOAD 后尚无完整分析结果时,SCORE 返回初始 0;持续有效静音完成分析后也可以得 0。设备没有送来音频包超过 1 秒则报错,不能通过降低阈值解决。 + +## 验证 + +自动化测试覆盖缓存回绕、原始样本裁剪、重连隔离、陈旧数据、文件往返、拒绝覆盖,以及编辑器试听/保存选区一致性与取消清理。手动录制另外验证起点裁剪、仅采新音频、手动停止、时间/内存上限、异常保留旧选区和订阅释放。无头界面测试验证空格不误触按钮、长按去重、输入框行为、暂停后单击/拖动游标再继续、边界裁剪、录制按钮与计时,以及两种语言与亮暗主题;手动截图用例可用于视觉检查。真实采集卡、实际扬声器试听和设备热拔插仍需实机验收。 + +匹配测试另外覆盖不同采样率和 PCM 格式、音量变化、反相立体声、任意分块边界、无关音效与静音、重复命中抑制、队列积压、断线后过期结果作废,以及窗口关闭时的取消与订阅清理。合成音频测试不能替代实际游戏音效的识别率验证。 + +ECS 测试覆盖新语法实际编译运行、旧接口拒绝、按键和 WAIT 期间后台识别、初始 0、峰值长期保留与重复读取、多个标签独立、标签路径、卸载重载、跨运行隔离、后台异常与停止清理。起点和长时间保留使用可控时钟验证;三份发布示例均参与编译运行,C VM 对缺失音频能力在加载期明确拒跑。 \ No newline at end of file diff --git a/docs/AudioSource.md b/docs/AudioSource.md new file mode 100644 index 00000000..2dde3058 --- /dev/null +++ b/docs/AudioSource.md @@ -0,0 +1,37 @@ +# 游戏音频源 + +EasyCon 的 Avalonia 主窗口现在可以独立连接 Windows WASAPI 录音输入。音频源与视频源、单片机和虚拟手柄分别管理:选择设备后点击“连接音频源”,收到第一块数据才显示“已连接”;设备移除、采集停止或 15 秒内没有首包会显示连接失败。 + +## 当前范围 + +- 枚举当前可用的 Windows 录音输入端点,不自动选择默认麦克风。 +- 使用 NAudio WASAPI 原语和自有采集线程读取共享模式 PCM 数据;读取、停止和释放异常均转为连接失败通知,避免音频服务停止时异常逃逸线程。 +- 发布采样格式、连续 PCM 数据块、峰值/RMS 电平和连接状态。 +- 在主窗口的经典布局和卡片布局中显示设备、格式、连接状态及峰值 dBFS。 +- 应用退出或主窗口关闭时停止采集并释放 WASAPI 资源。 + +音频面板还提供[声音标签编辑器和匹配测试](AudioLabels.md),可以截取、裁剪、试听、保存和校准样本。桌面 ECS 用 `AUDIO_LOAD` 启动后台识别,按键和 WAIT 期间持续匹配;`AUDIO_SCORE` 非阻塞读取本轮累计最高相似度(INT,0~100,向下取整),`AUDIO_UNLOAD` 结束本轮。先按[首次接入步骤](AudioLabels.md#第一次在-ecs-中使用)把完整标签放到入口 ECS 同级的 `audiolabel` 文件夹;函数调用和按键流程示例见[函数手册](Functions.md#声音匹配桌面-gui需连接游戏音频)。标签编辑器、匹配测试和脚本复用这一个已连接的音频源,不自动选择麦克风或另开采集设备;原有按键、普通等待和图像标签语义不变。 + +## 数据模型 + +`EasyCon.Capture.Audio.IAudioCaptureSource` 提供以下接口: + +- `GetAvailableDevices()` 返回 `AudioDeviceInfo`(稳定端点 ID、名称和 `wasapi` 后端标识)。 +- `TryConnect(deviceId, cancellationToken)` 开始连接;状态在打开原生设备前变为 `Connecting`,首个非空数据块到达后变为 `Connected`。取消令牌可终止等待启停锁或阻止迟到的设备安装。 +- `DisconnectAsync()` 停止当前采集并回到 `Disconnected`。 +- `DisconnectIfConnectingAsync()` 只在打开设备或仍等待首包时停止连接;先原子地使本次连接失效,再等待原生资源释放,用于避免首包与超时同时到达时误断开已连接的源。 +- `StateChanged` 在后台队列中依次通知 `AudioCaptureState`,不占用启停锁或采集线程;失败时包含错误文本。界面忽略旧尝试的迟到终态通知。 +- `DataAvailable` 通知 `AudioChunk`。`Pcm` 是设备实际格式的交错小端字节,不重采样、不自动增益、不降噪。`Session` 是生成该块时的不可变 Connected 状态对象,可用引用身份排除重连之前的迟到块;脚本检测要求来源提供该标识。 +- `LevelChanged` 通知 `AudioLevel`。界面按峰值计算 dBFS,显示范围下限为 -60 dBFS;静音表示仍有数据到达,并不表示断流。 + +当前支持 PCM 8/16/24/32 bit 和 IEEE float 32 bit。匹配算法自行进行托管重采样和特征提取,采集层保留原始数据。`TimestampNs` 来自 Stopwatch 单调回调时钟,并非硬件样本时钟;脚本在 LOAD 时建立独立监听,并按回调时间与采样数裁掉起点之前的部分;具体限制见[识别生命周期说明](AudioLabels.md#ecs-识别生命周期与限制)。 + +首包超时从点击连接时起计算,覆盖原生设备打开阶段。超时后界面立即恢复为连接失败并允许重试;若驱动调用暂时阻塞,原生释放会在调用返回后完成,旧连接不能再次启动。数据和电平消费者在采集回调上执行,应保持简短;需要重连时请异步调度,回调内同步 `TryConnect` 会被拒绝。回调内 `Dispose` 或原生打开仍在进行时的 `Dispose` 会先阻止后续数据,再在后台完成资源释放。 + +## 平台限制 + +WASAPI 仅在 Windows 上启用;其它平台的设备列表为空,连接会进入失败状态。设备必须作为 Windows 录音端点可见;只通过特殊 DirectShow 采集图暴露的音频不会出现在列表中。Windows 麦克风隐私权限或设备被其它程序独占也可能导致连接失败。 + +## 验证 + +在 Windows 上连接采集卡音频输入,确认面板显示设备名、实际采样率/声道/位深和实时 dBFS。拔出设备或停止音频服务后,确认状态变为失败;保持输入设备连接但播放静音时,应继续收到静音包。自动化测试覆盖 PCM 电平及各格式静音包、空/非法格式、设备选择、状态重入释放、并发断开、慢速打开超时和迟到结果、超时边界首包、旧通知与重试、连接调用返回前成功后立即失败,以及原生读取/停止/释放失败。显式手动诊断还保留了旧 NAudio 线程停止异常的无硬件复现。真实采集卡和不同驱动格式仍需硬件验收。 \ No newline at end of file diff --git a/docs/DESIGN_DOCUMENT.md b/docs/DESIGN_DOCUMENT.md deleted file mode 100644 index 114ba439..00000000 --- a/docs/DESIGN_DOCUMENT.md +++ /dev/null @@ -1,385 +0,0 @@ -# EasyCon2 项目架构设计文档 - -## 1. 项目概述 - -EasyCon2 是一个面向游戏主机(主要是任天堂Switch)的自动化脚本执行平台。该系统允许用户通过自定义脚本语言控制游戏主机,实现自动化游戏操作、图像识别、虚拟手柄映射等功能。 - -### 1.1 项目目标 -- 提供跨平台的游戏控制器自动化解决方案 -- 支持图像识别和基于视觉的自动化操作 -- 实现虚拟手柄与真实手柄的无缝映射 -- 提供友好的用户界面和脚本开发体验 - -### 1.2 技术栈 -- **UI框架**: Avalonia (跨平台), WinForms (Windows) -- **编程语言**: C# (.NET) -- **图像处理**: OpenCV -- **通信协议**: 串口通信、蓝牙、TCP/IP -- **脚本引擎**: 自定义脚本语言解析器 - -## 2. 整体架构 - -### 2.1 分层架构 - -``` -┌─────────────────────────────────────────────────────────┐ -│ 用户界面层 (UI Layer) │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ Avalonia UI │ │ WinForms UI │ │ -│ │ (跨平台) │ │ (Windows) │ │ -│ └──────────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 业务逻辑层 (Business Logic) │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ ViewModels │ │ Services │ │ -│ │ (MVVM模式) │ │ (业务服务) │ │ -│ └──────────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 核心层 (Core Layer) │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ EasyCon.Core │ │ -│ │ (脚本运行器、项目管理、配置管理) │ │ -│ └──────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 模块层 (Module Layer) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Device │ │ Capture │ │ Script │ │ VPad │ │ -│ │ (设备通信)│ │(图像处理)│ │(脚本解析)│ │(虚拟手柄)│ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 硬件层 (Hardware Layer) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ 单片机设备 │ │ 采集卡 │ │ 手柄设备 │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 2.2 项目模块划分 - -#### 2.2.6 UI模块 -- **EasyCon2.Avalonia**: 跨平台UI实现 -- **EasyCon2**: Windows专用WinForms实现 -- **EasyCon2.UI.Common**: 通用UI组件和资源 - -#### 2.2.1 核心模块 (EasyCon.Core) -- **职责**: 提供脚本执行、项目管理、配置管理等核心功能 -- **主要组件**: - - `ECCore`: 核心功能入口 - - `Scripter`: 脚本管理器 - - `ProjectManager`: 项目管理器 - - `Config`: 配置管理 - - `Runner`: 脚本运行器(支持多种脚本类型) - -#### 2.2.2 设备通信模块 (EasyCon.Device) -- **职责**: 处理与下位机设备的通信 -- **主要功能**: - - 设备发现与连接 - - 串口/蓝牙/WIFI通信 - - 协议实现(Modbus/Serial/TCP) - -#### 2.2.3 图像采集模块 (EasyCon.Capture) -- **职责**: 提供图像采集和处理功能 -- **主要功能**: - - 采集卡/虚拟摄像头驱动 - - 视频帧数据获取 - - 图像识别(模板匹配、OCR) - -#### 2.2.4 脚本解析模块 (EasyCon.Script) -- **职责**: 实现自定义脚本语言的解析和执行 -- **主要功能**: - - 词法分析(Syntax/Lexer) - - 语法解析(Syntax/Parser) - - 绑定与类型检查(Binding/Binder) - - 树解释执行(Evaluator) - - 单片机字节码生成(Assembly/Assembler) - -#### 2.2.5 虚拟手柄模块 (EasyCon.VPad) -- **职责**: 实现虚拟手柄UI和按键映射 -- **主要功能**: - - 手柄UI界面 - - 按键映射配置 - - 跨平台手柄支持 - -#### 2.2.6 UI模块 -- **EasyCon2.Avalonia**: 跨平台UI实现 -- **EasyCon2**: Windows专用WinForms实现 -- **EasyCon2.UI.Common**: 通用UI组件 - -#### 2.2.7 命令行工具 (EasyCon2.CLI) -- **职责**: 提供命令行操作接口 -- **主要功能**: - - 脚本批量执行 - - 自动化测试 - - 系统管理 - -## 3. 数据流设计 - -### 3.1 脚本执行流程 - -``` -用户操作 → UI层 → ViewModel → Service → Core层 → Script模块 → Device模块 - ↓ -用户反馈 ← UI层 ← ViewModel ← Service ← Core层 ← Device模块 ← 硬件设备 -``` - -### 3.2 设备通信流程 - -``` -Service层 → Device模块 → 协议层 → 物理连接 → 硬件设备 - ↓ -状态更新 → 事件通知 → UI更新 -``` - -### 3.3 图像处理流程 - -``` -Service层 → Capture模块 → OpenCV → 图像采集 - ↓ -图像数据 → 图像识别 → 结果返回 → 脚本决策 -``` - -## 4. 接口设计 - -### 4.1 设备服务接口 - -```csharp -public interface IDeviceService -{ - bool IsConnected { get } - event Action? ConnectionLost; - string[] GetAvailablePorts(); - bool TryConnect(string port); - string? AutoConnect(); - void Disconnect(); - NintendoSwitch GetDevice(); -} -``` - -### 4.2 图像采集接口 - -```csharp -public interface ICaptureService -{ - bool IsConnected { get } - event Action? ConnectionLost; - string[] GetAvailableSources(); - bool TryConnect(string sourceName); - void Disconnect(); - OpenCVCapture? GetCapture(); -} -``` - -### 4.3 控制器服务接口 - -```csharp -public interface IControllerService -{ - bool IsConnected { get } - string[] GetAvailableSources(); - bool TryConnect(string sourceName); - void Disconnect(); -} -``` - -### 4.4 脚本服务接口 - -```csharp -public interface IScriptService -{ - bool IsRunning { get } - event Action IsRunningChanged; - void Run(string scriptPath); - void Stop(); -} -``` - -### 4.5 日志服务接口 - -```csharp -public interface ILogService -{ - event Action LogAppended; - void AddLog(string message); - void Clear(); -} -``` - -## 5. MVVM架构实现 - -### 5.1 ViewModel层设计 - -#### MainWindowViewModel -- **职责**: 主窗口的ViewModel,协调各个服务模块 -- **主要功能**: - - 管理设备连接状态 - - 处理脚本执行 - - 日志显示管理 - - UI状态同步 - -#### ViewModelBase -- **职责**: 提供ViewModel基础功能 -- **主要功能**: - - 属性变更通知 - - 通用验证逻辑 - - 生命周期管理 - -#### ThemeManager -- **职责**: 管理应用主题 -- **主要功能**: - - 主题切换 - - 颜色方案管理 - -### 5.2 View层设计 - -#### MainWindow -- **布局**: - - 顶部:脚本路径和打开按钮 - - 中部:日志输出区域 - - 底部:模块连接器区域 - -#### KeyMappingWindow -- **功能**: 按键映射配置窗口 -- **特性**: 模态对话框,支持实时按键映射编辑 - -## 6. 事件与消息传递 - -### 6.1 事件驱动架构 - -```csharp -// 设备连接状态变更事件 -_deviceService.ConnectionLost += () => { - // 更新UI状态 -}; - -// 日志追加事件 -_logService.LogAppended += text => { - // 更新日志显示 -}; - -// 脚本运行状态变更事件 -_scriptService.IsRunningChanged += running => { - // 更新运行状态UI -}; -``` - -### 6.2 线程调度 - -- UI线程操作通过 `Dispatcher.UIThread.Post()` 调度 -- 后台任务使用 `Task.Run()` 执行 -- 定时器使用 `System.Timers.Timer` 实现 - -## 7. 依赖注入与配置 - -### 7.1 服务注册 - -```csharp -// 在App.axaml.cs中注册服务 -// Services are registered and resolved in the application setup -``` - -### 7.2 配置管理 - -- 应用配置存储在 `EasyCon.Core/Config.cs` -- 支持多种配置源(文件、注册表、环境变量) - -## 8. 扩展性设计 - -### 8.1 插件化架构 -- 模块间通过接口通信,支持模块替换 -- 脚本引擎支持多种脚本语言 -- 设备驱动支持多种通信方式 - -### 8.2 跨平台支持 -- UI层使用Avalonia实现跨平台 -- 核心逻辑与平台无关 -- 设备驱动抽象层支持不同平台 - -## 9. 性能优化 - -### 9.1 日志系统优化 -- 使用StringBuilder批量合并日志 -- 限制最大日志长度(100,000字符) -- 定期清理过期日志 - -### 9.2 图像处理优化 -- 使用OpenCV的高效图像处理算法 -- 支持图像缓存和重用 -- 异步图像采集处理 - -### 9.3 内存管理 -- 及时释放设备资源 -- 使用弱引用避免内存泄漏 -- 定期垃圾回收优化 - -## 10. 错误处理与容错 - -### 10.1 异常处理策略 -- 服务层捕获并转换异常 -- 用户友好的错误消息显示 -- 详细的日志记录 - -### 10.2 连接容错 -- 自动重连机制 -- 连接状态监控 -- 优雅降级处理 - -## 11. 安全性考虑 - -### 11.1 输入验证 -- 文件路径验证 -- 脚本内容安全检查 -- 设备连接参数验证 - -### 11.2 权限管理 -- 设备访问权限控制 -- 文件系统操作权限 -- 网络通信权限 - -## 12. 测试策略 - -### 12.1 单元测试 -- 每个模块的独立测试 -- 模拟接口测试 -- 边界条件测试 - -### 12.2 集成测试 -- 模块间协作测试 -- 端到端功能测试 -- 性能测试 - -## 13. 部署与发布 - -### 13.1 构建配置 -- Debug配置:包含诊断工具 -- Release配置:优化性能,移除调试信息 - -### 13.2 发布策略 -- 多平台二进制发布 -- 安装包制作 -- 自动更新机制 - -## 14. 未来发展方向 - -### 14.1 功能扩展 -- 支持更多游戏主机平台 -- 增强图像识别能力 -- 云端脚本共享平台 - -### 14.2 技术升级 -- 迁移到.NET最新版本 -- 采用更现代的UI框架特性 -- 优化性能和内存使用 - ---- - -**文档版本**: 1.0 -**最后更新**: 2026年4月16日 -**维护者**: EasyCon2开发团队 \ No newline at end of file diff --git a/docs/DeviceConnections.md b/docs/DeviceConnections.md new file mode 100644 index 00000000..f23ab927 --- /dev/null +++ b/docs/DeviceConnections.md @@ -0,0 +1,95 @@ +# 通用设备连接 + +**状态:已实施。** 串口协议和桥接协议分开注册;地址、设备列表和来源占用不依赖固件名称。 + +## 选择传输 + +| 传输方式 | 地址示例 | 连接行为 | +|---|---|---| +| `Serial` | `COM3`、`/dev/ttyUSB0` | 在同一串口按注册顺序探测协议,失败后关闭连接再尝试下一项 | +| `TcpBridge` | `tcp://127.0.0.1:47800`、`tcp://[::1]:31789` | 直接访问指定桥接服务;不尝试打开串口 | + +`ECDevice.GetPortNames()` 只枚举真实串口,自动连接也只扫描这些端口。桥接地址由用户配置, +GUI 在设备列表中保留该选择;「刷新」不会把它标记成丢失的串口。服务是否可用在连接时确认。 + +默认串口顺序为原生 EasyCon 协议 115200、9600,再尝试 pokeldn-pad 协议 921600。 +默认桥接适配器实现 `pokeldn.pad.service` 的 JSON 行协议。`tcp://` 只表示传输地址, +不意味着任意 TCP / 蓝牙服务均可互通;采用其他消息格式的服务需要注册相应的协议适配器。 +蓝牙扫描、权限、配对和链路维护由桥接服务负责。 + +## 配置桥接地址 + +GUI 使用现有的 `controller_profile`。Windows 上先退出 EasyCon,再编辑 +`%APPDATA%\easycon\config.json` 中的这一项,保留其他设置: + +```json +{ + "controller_profile": { + "Endpoint": "tcp://127.0.0.1:47800", + "Transport": "TcpBridge" + } +} +``` + +重启后在设备列表中选择该地址,点击连接。切换到普通串口并保存时,`Transport` 会更新为 `Serial`。 +其他平台的配置路径由 `AppPaths.ConfigFile` 确定(用户 ApplicationData 目录下的 `easycon/config.json`)。 +此配置仅设置连接目标,不启动或安装服务。 + +CLI 可直接指定地址,也可复用同一份配置: + +```powershell +ezcon run script.ecs --port COM3 +ezcon run script.ecs --port tcp://127.0.0.1:47800 +ezcon run script.ecs --profiles profiles.json +``` + +TCP 地址必须包含主机与 1–65535 的端口,不接受用户名、密码、路径(可有结尾 `/`)、查询或片段。 +配置文件的 `Transport` 必须与地址一致;CLI 的显式 `--port` 覆盖会同时更新传输方式,保留采集配置。 + +## 注册协议 + +`IConnectionProvider.GetCandidates` 按端点返回连接工厂。`SerialConnectionProvider` 管理 +串口协议与波特率,`BridgeConnectionProvider` 管理桥接协议。工厂延迟创建,确保探测失败时 +先释放旧句柄;成功后停止探测。 + +默认注册集中在 `NintendoSwitchConnections.cs`。宿主可通过构造函数提供自己的协议集合, +替换默认注册;例如以下工厂由宿主提供,分别实现自己的串口帧和桥接消息格式: + +```csharp +NintendoSwitch CreateController( + Func serialFactory, + Func bridgeFactory) => new( +[ + new SerialConnectionProvider([new SerialProtocol(115200, serialFactory)]), + new BridgeConnectionProvider([bridgeFactory]) +]); +``` + +连接实现继承 `IConnection`,承担连接/断开、状态事件、输入编码、写入确认和队列清理。 +连接成功须上报 `Status.Connected`;`WriteReport` 接收通用 `SwitchReport`,由适配器编码。 +`BytesSent` 的报告事件保留 EasyCon 打包 HID 作为确认载荷,以便 `ResetAndWaitForWriteAsync` +验证中立报告已写出;该确认不代表 NS / 游戏已处理输入。 +仅支持在线输入的协议应声明 `SupportsLegacyCommands = false`,拒绝离线烧录等原生专有命令。 + +扩展协议不需要在 Core、GUI 或 CLI 添加固件名称分支。`NintendoSwitch.CreateConnection` +和已有的 `CreatePokeldnConnection` 测试接缝仍保留,便于复用现有的无硬件测试。 + +## 端点与独占 + +`ConnectionEndpoint` 同时提供实际连接地址和来源占用键;`PadSession` 在打开设备前取得 +`SourceOwnership`,用于同机 EasyCon 会话的进程间互斥: + +- 去掉首尾空白;Windows 的 `COM3`、`com3`、`\\.\COM3` 使用相同串口标识。 +- Unix 的绝对串口路径解析符号链接,避免 `/dev/serial/by-id/...` 与目标路径重复占用。 +- TCP 统一主机名大小写、结尾斜杠和等价 IPv6 表示;相同端点的这些写法共享占用锁。 + +地址规范化不执行 DNS 查询,也不合并不同主机名或 IPv4 / IPv6 监听地址。多个客户端应使用 +同一服务地址;第三方客户端或多个桥接服务访问同一蓝牙板的互斥仍需桥接端负责。 + +## 验证边界 + +`ConnectionProviderTests` 覆盖延迟探测、失败释放、注册自定义协议、串口与桥接隔离和地址校验。 +`PokeldnServiceTests` 通过真实本机 TCP 与模拟服务核对请求/响应。 +Core / GUI 测试覆盖别名锁、配置读写、CLI 覆盖及显式桥接选择。 +这些测试不依赖实物,也不替代板子和 NS 的验收;两种 ESP32 的接线、固件与实测边界见 +[pokeldn 适配说明](Pokeldn.md)。 diff --git a/docs/DrawingBoard.md b/docs/DrawingBoard.md new file mode 100644 index 00000000..a1660a7f --- /dev/null +++ b/docs/DrawingBoard.md @@ -0,0 +1,261 @@ +# 喷射战士画板 + +画板把一张图片转成黑白点阵,通过虚拟手柄在《喷射战士》(Splatoon)的投稿画板上自动逐点画出来。画布固定为 **320×120** 个点;开画前先把光标归位到左上角,再用方向键逐格移动,按住 A 画、按住 B 擦。 + +入口:主窗口“功能中心”→“喷射战士”。 + +- [快速上手](#快速上手) +- [界面](#界面) +- [图片处理](#图片处理) +- [绘制设置](#绘制设置) +- [绘制过程](#绘制过程) +- [耗时与提速](#耗时与提速) +- [常见问题](#常见问题) +- [实现说明(维护者)](#实现说明维护者) + +## 快速上手 + +1. 在主窗口连接单片机,确认手柄能控制 Switch。 +2. 打开“功能中心”→“喷射战士”,点击“加载图片”,或直接把图片拖进窗口。 +3. 看预览:黑点就是要画的点。黑点太多或太少时,在“黑白”页调整阈值。 +4. 在游戏里进入画画界面,**把笔刷调到最小**。光标不用手动对位,开画前程序会自动把它归位到左上角。 +5. 点击“开始画图”。绘制期间手柄由画板独占,不要操作游戏。 + +画一张图通常需要几十分钟到两个多小时,预览下方会显示预计耗时。 + +## 界面 + +从上到下依次是: + +| 区域 | 内容 | +|---|---| +| 工具栏 | 加载图片、保存图片、显示原图、重置;开始画图、暂停 / 继续、停止 | +| 预览 | 320×120 的结果,拉伸到窗口宽度显示。未载入图片时中间提示可以拖入图片;绘制中显示进度和光标 | +| 状态行 | 左侧是绘制区域,右侧是预计耗时,图片或参数一变就自动更新 | +| 进度条 | 百分比、完成段数、已用时间、剩余时间 | +| 参数标签页 | 图片 / 明暗 / 黑白 / 绘制,顺序与处理顺序一致 | +| 底部提示 | 开始前的准备步骤 | + +窗口可以自由缩放,预览会跟着等比放大缩小。窗口太窄、控件放不下时,最小高度会自动增加。 + +**重置**会把所有选项恢复为默认值,已载入的图片保留。画图过程中不能重置。 + +**保存图片**把预览中的 320×120 图片存为 PNG: +- 默认保存黑白结果。下次直接载入这张图,用默认参数就能得到完全相同的点阵,适合保存调好的结果。 +- 勾选“显示原图”时,保存的是旋转、适配后的彩色原图。 + +## 图片处理 + +图片依次经过以下步骤处理,每一步都可以在预览里看到效果: + +```text +原图 → 旋转 / 翻转 → 适配到 320×120 → 明暗调整 → 阈值 / 抖动 → 去杂点 → 轮廓 → 反转黑白 +``` + +透明像素一律当作白色(不画)。 + +### 支持的格式 + +PNG、JPG、BMP、GIF(取第一帧)、WebP、ICO、WBMP。 + +### “图片”页 + +- **适配**:任意尺寸的图片都会缩放到 320×120。 + - **等比留白**(默认):整张图完整放进画布,不变形,多出的部分留白。 + - **等比裁切**:铺满画布,不变形,超出的部分裁掉。选中后会多出一行取景调整: + - 角度:任意角度旋转,范围 −180° 到 180°。 + - 缩放:100% 到 400%。 + - 左右、上下:调整取景位置。 + - 重置取景:恢复居中、不旋转、不放大。 + - **拉伸**:直接拉成 320×120,比例会变。 +- **原图尺寸**:适配方式旁边显示原图的宽高。 +- **旋转、水平翻转、垂直翻转**:以 90° 为单位顺时针旋转原图,翻转按旋转后的方向进行。 +- **像素画(最近邻缩放)**:缩放时不做平滑处理。像素画、点阵图放大后边缘不会出现灰色过渡,线条不会变粗或断开。照片不建议勾选。 + +### “明暗”页 + +在转成黑白之前调整图片的明暗,适合偏暗或偏灰的照片。 + +| 选项 | 范围 | 作用 | +|---|---|---| +| 亮度 | −100 到 100 | 整体变亮,墨点变少;整体变暗,墨点变多 | +| 对比度 | −100 到 100 | 调高时明暗差别拉大,灰色更容易分到黑或白 | +| 伽马 | 0.2 到 3(默认 1) | 大于 1 提亮暗部,小于 1 压暗亮部;纯黑和纯白不受影响 | + +“重置明暗”把这三项恢复为默认值。 + +### “黑白”页 + +- **阈值**(默认 50%):比这个亮度暗的点画成黑点。调高会多画,调低会少画。 +- **抖动**:用黑点的疏密表现灰度,照片效果更好,但点更碎、画得更久。 + + | 算法 | 特点 | + |---|---| + | 关闭(默认) | 只按阈值分黑白,线段最少,最快 | + | Floyd–Steinberg | 最常用的误差扩散 | + | Floyd–Steinberg(蛇形) | 逐行换向扫描,减少斜向纹理 | + | Atkinson | 点最少、对比强,抖动算法里最快 | + | Jarvis–Judice–Ninke | 过渡最平滑 | + | Stucki | 与 JJN 接近,略锐利 | + | Bayer 有序 | 规则的网格图案 | + +- **去杂点**(0 到 20,0 为关闭):去掉总点数不超过这个值的孤立墨点块(上下左右和斜向相连算一块)。用来清理抖动后浅色区域的零散点,能明显减少线段数和耗时。 +- **轮廓模式(只画边缘)**:只保留墨点区域的边缘,实心部分挖空。线条和单点不受影响;画布边缘不算轮廓。 +- **反转黑白**:最后一步,把处理完的结果黑白互换。例如轮廓模式加反转,会得到黑底白线。 + +## 绘制设置 + +在“绘制”页。 + +### 按键持续与延迟 + +| 参数 | 默认值 | 范围 | 说明 | +|---|---|---|---| +| 按键持续 | 50 毫秒 | 1 到 10000 | 每次点按方向键时按住的时间 | +| 延迟 | 45 毫秒 | 0 到 10000 | 每次按键后的等待时间 | + +默认值在有线连接的 ESP32-S3 上验证过。调得太小,游戏可能来不及响应,出现漏格、错位;蓝牙连接的设备尚未验证,出现错位时请先调大这两项。输入不是有效数字时,右下角显示“参数有误”,开始画图时会提示是哪一项、允许什么范围。 + +### 橡皮擦模式 + +适用于“黑多白少”的图:先在游戏里把画布整个涂黑,再让程序按住 **B(橡皮擦)** 擦出图中的白色部分,最终效果与画笔模式相同。 + +准备步骤(鼠标悬停在复选框上也能看到): + +1. 在游戏里用最大笔刷把整张画布涂黑。 +2. 把笔刷调到最小。 +3. 勾选“橡皮擦模式”后开始画图。 + +勾选后右下角的耗时会按要擦的点重新估算,可以对比两种模式选更快的一种。 + +### 开画前自动归位 + +默认勾选。开画前自动把光标归位到画布左上角,并防止开头的输入因丢帧而失效,会多花几秒。 + +**强烈建议保持勾选。** 取消勾选时会弹出风险提示:Switch 可能丢掉开头的输入帧,即使开始前手动把光标移到了左上角,也可能出现不可预测的绘制错位或整行缺失。 + +### 绘制时保持唤醒 + +默认勾选。绘制期间阻止电脑休眠、熄屏和进入屏幕保护,画完或停止后恢复。 + +建议保持勾选:macOS 进入屏幕保护后,程序的计时会明显变慢,实测一张预计 47 分钟的图画了 66 分钟;电脑休眠则会直接中断绘制。 + +### 框选绘制区域 + +默认画整张画布。勾选“框选绘制区域”后,在预览上按住鼠标拖动,就只画框住的部分: +- 预览上会用橙色框标出区域,状态行显示区域的起止列和行。 +- 点击“全图”恢复为整张画布。 +- 绘制中不能修改区域;停止或画完后可以重新拖动框选。 + +无论是否框选,程序都先把光标归位到画布左上角,再空走到区域内。 + +适合补画:先画完一遍,发现某块有漏画或错位,框出那一块再画一次。 + +## 绘制过程 + +点击“开始画图”后: + +- **独占手柄**:画图与脚本运行使用同一套独占机制。 + - 脚本运行时无法开始画图;画图时虚拟手柄、键盘映射等手动输入也被挡住。 + - 画完、停止或出错时,程序会松开所有按键。 +- **保持唤醒**(可关闭,见上):绘制期间电脑不会因无人操作而休眠、熄屏或进入屏幕保护,画完或停止后恢复。 +- **归位**(可关闭,见上):开画前先把光标归位到画布左上角。 +- **顺序**:从上到下逐行画,每行把连续的黑点作为一段,按住 A 一笔画完。每一段从离光标近的一端开始。 +- **进度**:进度条显示百分比、第几段 / 共几段、已用时间、剩余时间。剩余时间按本次实测的每类操作额外耗时计算,见下节。 +- **实时预览**: + + | 显示 | 含义 | + |---|---| + | 黑色 | 已画的点 | + | 灰色 | 还没画的点 | + | 红点和红框 | 光标当前位置 | + | 很浅的灰色 | 框选区域以外的点(本次不画,只作对照) | + + 橡皮擦模式下颜色反过来:已擦的点为白、待擦的点为深灰。 +- **暂停 / 继续**:暂停在下一次按键前生效,手柄仍然被占用。如果暂停时正按着画笔,会先抬笔,继续时在原位重新落笔。暂停的时间不计入用时。 +- **停止**:立即停止并松开按键,进度条显示停止时完成的百分比。 +- **关闭窗口**:同样会立即中止绘制。 + +绘制开始时会取当前图片和参数的快照。画图过程中调整阈值、切换模式等,不会影响正在画的这一张。 + +## 耗时与提速 + +预计耗时与实际发出的按键序列逐项对应,按默认参数: + +- 光标每移动一格:按键持续 + 延迟 = **95 毫秒**。 +- 每画一段线的固定开销:6 次延迟 = **270 毫秒**(落笔、抬笔,以及每次移动换方向后的等待)。 + +另有开画前归位约 4 秒。计划耗时约等于“4 秒 + 总移动格数 × 95 毫秒 + 线段数 × 270 毫秒”。 + +实际会比计划慢:每次点按、落笔、抬笔都要和设备通信一次,计时也有误差。线段越多(比如抖动图),落笔、抬笔越多,慢得越明显。所以程序按操作类别分别统计额外耗时: + +- 绘制中,每次点按、每次落笔/抬笔、每次延迟各实际多花了多少,都会实时统计。剩余时间等于剩下各类操作的次数乘以对应的实测值,再加上剩余的计划耗时。这样不会因为图案前疏后密而估偏。 +- 画完或停止后,日志里会记一条“画板实测额外耗时”。之后的预计耗时会把这些额外耗时算进去。实测值在程序运行期间有效,重启后从 0 重新统计,因此第一次画图前的估算仍是计划耗时。 + +想让第一次估算也准一些,可以先画一张小图,或者画一会儿再停止,让程序先测一次。 + +下面的参考数据都是**计划耗时**(整张画布,默认参数)。实际用时取决于设备和连接方式,会更长;线段越多,多出来的越多: + +| 图案 | 线段数 | 耗时 | +|---|---|---| +| 全黑 | 120 | 约 61 分钟 | +| 棋盘格(最坏情况) | 19200 | 约 147 分钟 | + +400×400 径向渐变图(等比裁切)在各抖动算法下的计划耗时: + +| 抖动 | 线段数 | 耗时 | +|---|---|---| +| 关闭 | 120 | 约 28 分钟 | +| Atkinson | 7066 | 约 81 分钟 | +| Floyd–Steinberg | 10494 | 约 103 分钟 | +| Bayer | 10571 | 约 103 分钟 | + +抖动后再开启去杂点,同类图片的耗时实测还能减少约 20%。 + +提速的办法: +- 线段越少越快。能不用抖动就不用;必须用时选 Atkinson,并开启去杂点。 +- 黑多白少的图改用橡皮擦模式。 +- 只需补画一部分时,用框选绘制区域。 +- 有线连接且稳定时,可以尝试调小延迟,但要留意漏格。 + +## 常见问题 + +| 现象 | 原因与处理 | +|---|---| +| 提示“串口未连接” | 先在主窗口连接单片机 | +| 提示手柄正被占用 | 有脚本在运行,先停止脚本 | +| 提示图片加载失败 | 格式不支持,转换成 PNG 或 JPG 后再试 | +| 画出来整体错位 | 笔刷不是最小;开画时不在画画界面,归位没有生效;或关闭了自动归位,开头的输入被丢掉 | +| 第一行没画出来 | Switch 丢掉了开头的输入帧。勾选“开画前自动归位” | +| 越画越偏、漏格 | 游戏没跟上按键。调大延迟或按键持续,蓝牙设备尤其注意 | +| 画到一半停了 | 进度条显示停止时的百分比,出错时会弹框说明原因并写入日志;可以框选未完成的区域补画 | +| 预览一片黑或一片白 | 阈值太高或太低,或勾选了反转黑白;也可以点“重置”从默认值重新调 | +| 抖动后耗时特别长 | 换 Atkinson,开启去杂点,或关闭抖动 | +| 实际用时比预计耗时长很多 | 确认勾选了“绘制时保持唤醒”;如果仍然偏慢,检查电脑是否同时在跑别的重任务 | +| 实际用时比预计耗时长(第一次画) | 程序启动后第一次画图时,还没测过额外耗时,估算只是计划耗时。画过一次(或画一会儿再停止)之后,估算会算上实测的额外耗时 | + +## 实现说明(维护者) + +代码位于 `src/EasyCon2.Avalonia/DrawingBoard/`,从 1.6.3 WinForms 版 `DrawingBoard` 移植,修复了旧版缺陷(停止不立即生效、关窗后继续画、第 0 列单点画成整行、耗时少算、透明当黑等)。 + +| 文件 | 职责 | +|---|---| +| `SplatoonDrawing.cs` | 扫描(`SearchLines`)、估算(`EstimateCost`,用空手柄跑同一套按键序列,得到计划耗时与各类操作次数)、绘制(`Draw` / `Home` / `MoveTo` / 暂停)、额外耗时实测(`Overhead`,按点按 / 落笔抬笔 / 延迟分类) | +| `InkMask.cs`、`InkMask.Filters.cs` | 二值化、反转、去杂点(8 连通)、轮廓(4 邻域,画布边缘不算轮廓) | +| `Dithering.cs`、`ToneAdjust.cs` | 抖动算法;明暗调整 | +| `PreviewOverlay.cs` | 绘制进度图与区域框 | +| `DrawingRunner.cs` | `IDrawingRunner`:经 `HostRunCoordinator` 取运行租约,与脚本互斥;勾选“绘制时保持唤醒”时,绘制期间持有 `KeepAwake` | +| `KeepAwake.cs` | 绘制期间阻止空闲休眠、熄屏与屏幕保护:macOS 用 `NSProcessInfo.beginActivityWithOptions`(UserInitiated、IdleDisplaySleepDisabled、LatencyCritical),Windows 用 `SetThreadExecutionState`(按线程生效,须同线程获取与释放);其它平台不处理,失败不影响绘制 | +| `DrawingBoardViewModel*.cs` | 按职责分为 核心 / Picture / Adjust / Settings / Drawing 五个分部文件 | +| `DrawingBoardWindow.axaml(.cs)` | 布局;代码后置只做指针坐标换算与最小高度计算 | + +归位(`Home`):左摇杆以最大角度(X、Y 都为 0)推向左上,先推 50 毫秒,再推住 4 秒,每次回中后等一次延迟。第一下用来“垫帧”:Switch 可能丢掉连接后的第一帧输入,丢的是这一下也无妨;如果第一下就是落笔,丢帧会让第一行整行画不出来(真机上遇到过)。第二下把光标从画布任意位置推到左上角。 + +耗时估算:`DrawingCost` 记计划耗时与点按、落笔/抬笔、延迟三类操作的次数;`DrawingOverhead` 记每类操作的平均额外耗时。实测从上一次操作结束量到本次操作结束,所以进度刷新等程序开销也计入;暂停时间不计入。剩余时间用本次实测值,某类样本不足 20 次时用 `DrawingBoardViewModel.LearnedOverhead`(进程内静态值,画完或停止时更新;测试在每个用例前清零)。 + +线程:绘制在后台线程运行。本地化文本(`T` / `Format`)只能在 UI 线程上取,否则会抛跨线程异常;绘制线程上要写日志或提示时,先投递到 UI 线程再格式化文案。曾因在绘制线程上格式化日志导致停止时闪退,由回归测试 `DrawingThread_NeverReadsLocalizedTextOffTheUiThread` 防护。 + +测试: +- `test/EasyCon2.Avalonia.Core.Tests/DrawingBoard*`、`SplatoonDrawingTests.cs`:算法、图片处理、参数、运行过程,用假手柄记录按键。回放测试把发出的按键在模拟画布上重放,检查按住 A 经过的格子与墨点完全一致(含首末列)。 +- `test/EasyCon2.Avalonia.UiTests/DrawingBoardWindowTests.cs` 等:无头渲染下的布局与交互,检查各标签页在最小尺寸下不溢出、预览缩放与坐标换算。 +- 所有用到的 `Text.DrawingBoard.*` 文案键必须在 `zh_CN` 和 `en_US` 中同时存在,由测试检查。 \ No newline at end of file diff --git a/docs/EasyCon Assist.md b/docs/EasyCon Assist.md deleted file mode 100644 index 0c199325..00000000 --- a/docs/EasyCon Assist.md +++ /dev/null @@ -1,21 +0,0 @@ -# EasyCon Assist - -接入到目前的频道中,后续接入seed bot等 - - - -## 功能 - -- 远程返回log -- 远程切换脚本 -- 远程脚本停止和启动 -- 脚本完成通知 - - - -## 接口 - -- getToken -- getLatestLog -- start/stop -- notification \ No newline at end of file diff --git a/docs/EcmEcxFormat.md b/docs/EcmEcxFormat.md deleted file mode 100644 index d734af9c..00000000 --- a/docs/EcmEcxFormat.md +++ /dev/null @@ -1,143 +0,0 @@ -# ECM / ECX 二进制格式规范 - -> **版本 2.0(精炼重写)**。ECM(模块缓存产物)与 ECX(可执行镜像)的权威位级规格, -> 与实现逐字段同步:C# 写侧 `src/EasyCon.Script/Bytecode/EcxWriter.cs` / `EcmFormat.cs`, -> C 读侧 `src/EasyCon.Vm/native/ecs_vm.c`(`ecs_vm_load`)。改格式必须三处同步并升版本。 -> 指令语义见 `docs/VM2.md` §4;双端语义契约见 `docs/VmSemanticContract.md`。 - -## 1. 通用编码规则 - -- 小端。元数据字符串 = `u16 字节长 + UTF-8`;脚本常量字符串 = `u16 code unit 数 + UTF-16LE`(长度与 C# `string.Length` 同义)。 -- ECX 整体加载地址须 4 字节对齐;各 u32 字段相对镜像起点天然 4 字节对齐(头部定长 0x24,表条目均为 4 的倍数内字段)。 -- 计数上限(编码器与加载器双向强制):槽位 ≤255,Bx ≤65535,单函数指令 ≤2²⁴,常量 ≤65535。 - -## 2. ECX —— 链接后可执行镜像(format_ver = 2) - -``` -偏移 大小 内容 -0x00 4 magic = "ECX2" -0x04 2 format_ver = 2 -0x06 2 flags §2.1 -0x08 1 max_slots 全程序最大帧槽(= max(funcs.nslots),宿主预分配帧区依据) -0x09 1 max_depth 静态调用图最长链(含
;宿主预分配深度) -0x0A 2 feats 特征需求掩码(EcsImageFeatures:IL/CAPTURE/FFI/FILE/VISION;IL 亦由 flags.I 投影;VISION=0x10,C 参考桩不提供 → 加载期拒跑) -0x0C 4×6 const_count / struct_count / global_count / native_count / func_count / debug_count -0x24 ... 常量池 → 类型表 → 全局表 → 原生名表 → 函数表 → 代码区 → 调试区(可选)→ entry:u32(收尾 4 字节) -``` - -代码区总字节数 = Σ code_words(无独立长度字段);`EcxWriter.Write(image, stripDebug: true)` 剥离调试区(flags.D=0、debug_count=0),供 MCU 发布版减容。 - -uvm32 式纯净镜像:核心全局表项定长 2 字节,全局名与函数名同住调试区,运行期按索引访问; -MCU 发布镜像(stripDebug)零名字重量。 - -### 2.1 flags (u16) - -| 位 | 名 | 含义 | -|----|----|------| -| 0 | D | 调试区存在(函数名表 + 全局名表) | -| 1 | K | KeyAction——程序含按键指令,宿主须创建手柄 | -| 2 | I | NeedIL——程序引用图像标签;无 IL 能力的平台加载即拒(`ECS_ERR_IL`) | - - -### 2.2 各表条目 - -``` -常量池: tag:u8 + 载荷(INT/UINT i32;UINT64/PTR i64;DOUBLE f64;STRING units:u16 + UTF-16LE) - 镜像级去重;加载后不可变(实现可置只读段,rc 标记 pinned)。 - -类型表: StructDef := name:utf8 nfields:u8 { Field } - Field := name:utf8 kind:u8 base:u8 elem:u8 ext:u16 - kind: 0=Scalar(1 槽) 1=FixedArray(ext=元素数, elem 槽) - 2=NestedStruct(ext=嵌套 sid, 槽数=嵌套 nslots) 3=Boxed(1 槽, 动态数组声明, 访问→ECS_ERR_TYPE) - 加载器按声明序计算 slot_offset 与总 nslots(嵌套递归展开,环 → ECS_ERR_IMAGE)。 - -全局表: module_idx:u8 type:u8 定长 2 字节,运行期按索引访问;名字在调试区 -原生名表: name:utf8 仅 L3:采集洞 "__xxx__" / EXTERN FFI "库!导出名" / ENCODE / JQ - (L2 文件族/平台 syscall 编号直传 CallN ext,bit31=1,不进本表) - -函数表: 11 字节定长 ×N —— nparams:u8 nslots:u8 attrs:u8(bit0=hasret) code_off:u32 code_words:u32 - code_off 单位=指令字、相对代码区起始;函数索引 = 表序 = Call ext。 - -调试区(flags.D=1;debug_count = func_count + global_count): - 函数名表(按 fid 序)→ 全局名表(按全局槽序)。名字纯诊断用,加载器可读后即弃。 -``` - -### 2.3 加载校验清单(全部通过才可运行,任一失败 → `ECS_ERR_IMAGE`) - -magic/版本 → 特征校验(feats & FEAT_IL → ECS_ERR_IL;feats & ~host->feats → ECS_ERR_FEAT)→ 各节计数与剩余长度一致(末尾恰剩 entry 4 字节)→ entry < func_count → -每个 FuncDef:nslots ≤ max_slots、code_off+code_words ≤ 代码区总字数 → 指令流静态抽查 -(操作码合法、槽位 <255、Jmp 目标在本函数内、Call ext < func_count、CallN ext < native_count、 -LoadK Bx < const_count)→ 类型表嵌套展开无环。校验宁可严格:格式是编译器产物而非手写体。 - -## 3. 运行时值标签与类型码 - -| tag | 名 | 载荷 | | 码 | 类型 | 码 | 类型 | -|-----|----|------|-|----|------|----|------| -| 0/1/2/3/4 | VOID/BOOL/BYTE/INT/UINT | i32 | | 0–5 | void/bool/byte/int/uint/uint64 | -| 5/6 | UINT64/DOUBLE | i64/f64 | | 6/7 | double/string | -| 7/9/12 | STRING/ARRAY/STRUCT | i64 低 32 位=堆句柄 | | 8/9/10/11 | array/ptr/struct/any | - -## 4. ECM —— 模块产物("ECM2" format_ver = 3) - -开发链路上的编译缓存(ModuleSystem.md §6),与 ECX 同族但带名字与接口区(不必最省)。 -当前布局与 `EcmFormat.Write/Read` 逐字段一致: - -``` -"ECM2" ver:u16(=3) flags:u16(HasEval=bit0;与下方标志字节 bit0 同值冗余,读侧以标志字节为准) -链接标志字节:u8 bit0=HasEval bit1=HasInit bit2=KeyAction bit3=NeedIL ← v3 新增:跨缓存存活 -InitFid:u32 函数的模块局部 fid(无 init 时 0) -module_name:utf8 -接口区长度:u32 + ModuleInterfaceFormat 负载(无接口 = 长度 0) - 内容:接口哈希、依赖表(name, ifaceHash)、导出函数表(签名/默认值载荷/extern)、 - 类型表、IL 名表——绑定消费者所需的全部可观察信息(ModuleSystem.md §3) -struct_count { StructDef } # 本模块声明集(与 ECX Field 同构) -global_count { name:utf8 type:u8 }# 模块私有全局 -native_count { name:utf8 } -func_count { name:utf8 nparams:u8 nslots:u8 hasret:u8 code_off:u32 code_words:u32 } -code_bytes:u32 { u32... } -const_count { EcsConst } -import_count { name:utf8 nparams:u8 hasret:u8 } # Call ext = 0x80000000 | importIdx -export_count { name:utf8 local_fid:u32 nparams:u8 hasret:u8 } -il_count { name:utf8 } -``` - -### 4.1 引用规则(编码期闭合矩阵;链接期唯一重写点) - -| 指令 | 编码期写 | 链接期重写为 | -|------|----------|--------------| -| `Call` | 本模块=局部 fid;跨模块=`0x80000000\|importIdx` | 全局 fid | -| `CallN` | 模块局部原生 idx | 镜像原生 idx(合并去重) | -| `LoadK` | 模块常量池 idx | 镜像常量池 idx(去重合并) | -| `LoadG/StoreG` | 模块局部全局槽 | + 模块全局基址 | -| `Jmp/Jpt/Jpf` | 函数内相对偏移 | 不变(PC 相对,平移不变) | - -### 4.2 导入解析 - -导出索引键 = `(name, nparams)`,按链接序(std → vision → 拓扑序依赖 → main)首匹配 -(镜像 Binder 遮蔽语义);同名同签名跨模块导出已在编译期经 `FunctionSymbol` 值相等合并, -不产生二义,仅报 `MD_AMBIGUOUS_EXPORT` 警告。未解析 → `LC_MISSING_FUNC`。 - -## 5. 版本与演进 - -- `format_ver` 单调递增,加载器拒绝更高版本;语义不兼容变更必须升版本(旧缓存经严格版本校验自然失效)。 -- ECX v1 冻结项:32 位定长指令、8 位槽、16 位常量索引、UTF-16 常量串。ECM 当前版本 v3(含链接标志节)。 - -### 5.1 格式 v2 窗口设计备注(统一链路遗留项,实施须与 C VM 协商升版本) - -| # | 项 | 方向 | 前置 | -|---|-----|------|------| -| 1 | 调用约定寄存器化 | 标量实参 `CallR` 直传,句柄维持深拷贝;FuncDef 增参数类别位图 | 双端 ABI 同步;函数表定长扩容 → 升版本 | -| 2 | 镜像 CRC | 尾部 CRC-32,`ecs_vm_load` 末段校验 | 新错误码 `ECS_ERR_CRC` 双端同步;XIP 流式计算 | -| 3 | Link 三遍合一 | 单遍哈希表完成导入解析 + 索引重写 + `
` 合成 | 无格式变更,可独立做;首匹配遮蔽语义逐字保持 | -| 4 | 模块并行编译 | 拓扑分层内并行(temp+rename 原子写已备) | per-key 原子填充防重复编译 | - -## 6. 与实现的对应 - -| 规格条目 | 实现 | -|----------|------| -| ECX 写(含 stripDebug) | `EcxWriter.Write` | -| ECM 写/读 | `EcmFormat.Write/Read` | -| 接口区负载 | `ModuleInterfaceFormat`(Bytecode/ModuleInterface.cs) | -| 链接(§4.1 重写矩阵 + `
`/init 合成 + 死函数消除) | `EcxPipeline.Link`(Bytecode/EcxLinker.cs) | -| 加载校验(§2.3) | `ecs_vm_load`(C) | -| 反汇编(调试) | `EcxDisassembler` | diff --git a/docs/Flow.md b/docs/Flow.md new file mode 100644 index 00000000..562e9e22 --- /dev/null +++ b/docs/Flow.md @@ -0,0 +1,322 @@ +# Flow — 编排图(节点式流程)规范 + +> **定位**:EasyCon PC 侧节点编排系统的**现状规范**——`*.flow.json` 图格式、节点目录与语义、 +> 执行模型(看门狗 / 计时 / 停止 / 慢感知)、HTTP 与 MCP 接口、Python 画布前端。 +> 权威源:`src/EasyCon.Core/Flow/`(`FlowGraph` / `FlowNodeCatalog` / `FlowNodeRuntime` / +> `FlowExecutor` / `FlowServiceState` / `FlowServiceHttp` / `FlowServiceTools`)与 +> `frontend/src/easycon_flow/`。冲突时以代码为准并修本文。 +> 相关文档:能力装配见 [Framework.md](Framework.md),脚本语言见 [Script.md](Script.md), +> 字节码契约见 [VM2.md](VM2.md)。 + +## 0. 边界(先看这条,避免误用) + +1. **图只在 PC 执行**。烧录单片机的产物永远只有纯 ECS 脚本(`.ecx`);图不参与编译、不提供烧录入口。 +2. **图是编排,不是语言**。图里的决策能力只有 `compare` / `text.contains` / `state.step` + (外加感知节点)——复杂逻辑一律走 `script.run` 逃逸阀(写 ECS,拿 `ARG` 传参、`PRINT` 回传)。 +3. **节点功能全部由后端提供**。前端(Python 画布)不含任何节点实现;节点类型、端口、参数 schema + 来自 `GET /api/nodes`,其源头是 `FlowNodeCatalog`。后端加节点不需要改前端。 +4. **危险操作 fail-closed**:单节点试跑拒绝 actuation 层;MCP 默认不导出需要确认的工具。 + +--- + +## 1. flow.json 格式 + +普通 JSON(后缀 **`.flow.json`**),无 schema 版本演进负担(`version` 目前恒为 1)。 +`pos` 仅供画布布局,执行时忽略。 + +```json +{ + "version": 1, + "name": "battle-loop", + "maxSteps": 3000, + "timeoutSec": 1800, + "nodes": [ + { + "id": "cap", + "type": "capture.frame", + "pos": { "x": 10, "y": 40 }, + "params": { "waitForNew": true, "timeoutMs": 2000 }, + "slow": { "everyFrames": 2 }, + "inputs": { + "image": { "node": "cap2", "port": "image" }, + "b": 3 + } + } + ], + "exec": [ ["start", "cap"], ["chk.true", "hit"], ["chk.false", "miss"] ] +} +``` + +| 字段 | 语义 | +|---|---| +| `version` | 格式版本(当前 1) | +| `name` | 图名(报告/界面显示) | +| `maxSteps` | **看门狗**:节点执行步数上限(硬上限 100000,防图作者漏配) | +| `timeoutSec` | **看门狗**:整图总时长上限(秒) | +| `nodes[].id` | 节点唯一 id;**不要含点号**(exec 边用 `id.port` 表示条件出口) | +| `nodes[].type` | 节点类型(见 §2 目录) | +| `nodes[].params` | 节点参数(各类型自有 schema) | +| `nodes[].pos` | 画布坐标 `{x, y}`,执行忽略 | +| `nodes[].slow` | 慢感知策略(见 §4.2),null = 每次都执行 | +| `nodes[].inputs` | 数据入边:端口名 → 值。值为 `{"node":"id","port":"port"}` **引用**,或字面量(string/number/bool) | +| `exec` | exec 边(控制流)。两种形态:`["from","to"]` 与 `["from.outPort","to"]`,也接受 `{"from":..,"to":..}` | + +### 1.1 exec 边的出口端口 + +- 单出口节点(`wait` / `capture.frame` / `ocr.text` / `script.run` / `pad.*` / `start` / `end`) + 写**裸节点 id**:`["cap","ocr"]`。 +- 条件节点写**出口端口名**:`["chk.true","hit"]` / `["chk.false","miss"]`、 + `["step.out","body"]` / `["step.done","after"]`、`["chg.true","next"]`。 +- 出口选择规则(`FlowExecutor.SelectEdge`):节点返回出口端口时**优先精确匹配**该端口, + 其次才回落到无标签边。`["a.out","b"]` 与 `["a","b"]` 在语义上等价,但**无条件单出口必须写裸 id** + (后端对无标签边做兜底匹配)——画布前端已按此规则写入。 +- 无出边的节点执行完即视为整图正常结束(`Completed = true`)。 + +### 1.2 结构校验 + +`FlowGraph.Parse` 拒绝:重复节点 id、空图、exec 边引用不存在的节点、**多个 start 节点**(start 至多一个)。 +起点解析:显式 `start` 节点优先;否则取**唯一**无 exec 入边的节点;全环图取第一个节点; +其余情况报错(提示无 exec 入边的节点列表)。 + +### 1.3 数据入边语义(重要) + +- 引用形式 `{"node":"id","port":"port"}` 在**首次被读取时惰性求值**(递归执行上游节点)。 +- 当次运行内**只求值一次**(memo);exec 边上的节点每次执行都会刷新自身输出, + 因此「数据来自 exec 路径上游」恒为最新帧。 +- 因此:**把感知来源节点也接进 exec 路径**(正常串起来)是推荐画法。若某个感知节点只作为数据来源 + (不在 exec 路径上),它整轮只跑一次——这与「每轮抓新帧」的期望不符,需要时请用 `slow` 明确表达复用意图。 +- 数据边成环会被**显式拒绝**(报「数据入边存在环」),不会递归到栈溢出。 +- 读取一个**不存在**的节点引用会报错。 + +--- + +## 2. 节点目录(`GET /api/nodes`,单一事实源) + +端口列 `kind:type` 含义:`ein`=exec 入、`eout`=exec 出(名字即出口端口)、 +`din`=数据入(可连线或给字面量)、`dout`=数据出。 + + + +| 类型 | 层 | 说明 | 端口 | 参数(默认值) | +|---|---|---|---|---| +| `start` | 控制流 | 起点(图执行的入口) | `out`(eout:exec) | — | +| `end` | 控制流 | 终点(正常结束) | `in`(ein:exec) | — | +| `wait` | 控制流 | 延时 | `in`(ein:exec)、`out`(eout:exec) | `ms`=100(int) | +| `capture.frame` | 感知 | 抓取采集卡当前帧 | `in`(ein)、`out`(eout)、`image`(dout:image)、`frameIndex`(dout:number)、`x`/`y`/`w`/`h`(din:number) | `waitForNew`=false(bool)、`timeoutMs`=2000(int) | +| `image.file` | 感知 | 读取单张图片文件 | `in`(ein)、`out`(eout)、`image`(dout:image) | `path`(path) | +| `ocr.text` | 感知 | OCR 识别文字(整图或 ROI) | `in`(ein)、`out`(eout)、`image`(din:image)、`x`/`y`/`w`/`h`(din:number)、`text`(dout:text)、`conf`(dout:number) | `lang`="eng"、`x`/`y`/`w`/`h`=0(int) | +| `compare` | 决策 | 比较两个值,true/false 双出口 | `in`、`true`(eout)、`false`(eout)、`a`/`b`(din:any) | `op`="=="(`==`/`!=`/`>`/`<`/`>=`/`<=`) | +| `text.contains` | 决策 | 文本判定,双出口 | `in`、`true`、`false`、`text`(din:text)、`pattern`(din:text)、`hit`(dout:number) | `mode`="contains"(`contains`/`startsWith`/`endsWith`/`equals`/`regex`)、`pattern`=""、`ignoreCase`=true | +| `state.step` | 决策 | 运行内命名计数器(循环/重试) | `in`、`out`(eout)、`done`(eout)、`value`(din:number)、`value`(dout:number)、`name`(dout:text) | `name`="step"、`op`="inc"(`inc`/`set`/`reset`)、`value`=1、`max`=0 | +| `vision.changed` | 感知 | 与上次观察到的画面比较,双出口 | `in`、`true`、`false`、`image`(din:image)、`changed`(dout:number) | — | +| `script.run` | 动作 | 执行 ECS 脚本(内联多行或 .ecs 文件;ARG 传参,PRINT 回传) | `in`、`out`、`arg1`/`arg2`/`arg3`(din:text)、`ok`(dout:number)、`logs`(dout:text) | `script`=""(text)、`file`(path) | +| `pad.key` | 动作 | 按键 | `in`、`out` | `key`="A"、`durationMs`=100、`times`=1、`intervalMs`=100 | +| `pad.sequence` | 动作 | 按键序列宏(`"A,100; ↓,200"`) | `in`、`out` | `seq`(string) | + +`/api/nodes` 还返回:`slow`(慢感知字段说明)、`keys`(手柄键名,供 `pad.key` 下拉)、 +`ocrBackends`(`none`/`tesseract`/`ppocr`)。 + +### 2.1 各节点语义要点 + +- **`capture.frame`**:`x/y/w/h` 是**数据入边**(不是参数),缺省 `-1` 表示整帧; + `waitForNew = true` 时等待采集源发布新帧(需 `FrameIndex`;`timeoutMs` 到期按当前帧继续, + 等待可被停止令牌打断);输出 `frameIndex` 供慢感知与诊断使用。 +- **`ocr.text`**:`image` 缺失直接报错(不会静默取当前帧)。ROI 的 `x/y/w/h` 既可连线也可写参数 + (入边优先)。OCR 引擎是**宿主装配**的:服务侧默认 `none`(未装配 → 节点报错), + 用 `POST /api/options` 切换。`conf` 是后端最近一次识别的置信度(0-100)。 +- **`compare`**:数值优先(int/double/可解析字符串),否则字符串序比较;出口 `true`/`false`。 +- **`text.contains`**:`regex` 模式带 200ms 匹配超时(坏正则不挂死整图,按未命中处理并记日志)。 +- **`state.step`**:运行内命名计数器,`inc` 累加 `value`(`value` 是**数据入边优先**,参数为兜底); + `set` 直接设定;`reset` 归零。`max > 0` 且当前值 ≥ `max` 时走 `done` 出口,否则 `out`。 + 状态随本次运行存亡(不上 MCU、不落盘)。 +- **`vision.changed`**:与本节点在本次运行内**上一次观察到的画面**比较;首次执行只建立基线 + (`changed = 0`),因此「等待画面变化」不会在第一帧误触发。指纹取 `image` 入边(内容即输入), + 缺省取当前帧的编码字节。**不要给它配 `slow`**(复用会跳过基线更新)。 +- **`script.run`**:脚本来源二选一——`script`(内联多行 ECS 文本,**非空时优先**于 `file`; + 即写即跑,无脚本目录上下文,不支持模块导入)或 `file`(路径相对按 `__APP__` 解析, + 保留同目录 `lib/` 导入上下文)。`arg1..arg3` 传成 `ARG(1..3)`; + `PRINT` 输出以 `\n` 连接进 `logs`;`ok = 1` 表示执行完成。脚本内的能力(采集/OCR/推理/手柄) + 以**借用**方式来自本次运行(`ScriptHostAssembler` 的 `BorrowResources`), + 内层释放不会拆掉外层还在用的服务。两种来源都按现编档编译(不落盘缓存)。 +- **`pad.key` / `pad.sequence`**:需要单片机(真机或 `mock`);`pad.sequence` 的键名支持 + `↑↓←→` 归一化为 `TOP/DOWN/LEFT/RIGHT`;两者都可用停止令牌打断。 + +--- + +## 3. 执行模型 + +``` +FlowExecutor.Run(token) + ├─ FlowNodeRuntime: 节点语义 + 数据入边(惰性 + memo + slow 复用) + └─ 从 start 沿 exec 边推进,直到无出边(Completed)或出错/看门狗/停止 +``` + +**每步**:执行节点 → 记 `ExecCount` / `ReusedCount` / `LastMs` / `TotalMs` → 写事件 +(`nodeEnd ms`,slow 复用则为 `nodeReuse ...`)→ 选下一条 exec 边。 + +| 机制 | 行为 | +|---|---| +| 看门狗(步数) | `steps >= min(maxSteps, 100000)` → `WatchdogTriggered`,事件 `watchdog maxSteps` | +| 看门狗(时长) | `elapsed >= timeoutSec` → `WatchdogTriggered`,事件 `watchdog timeout` | +| 停止 | 外部 `CancellationToken`;`wait` / 等待新帧 / `pad.*` 间隔都用 `token.WaitHandle.WaitOne`,可被立即打断 | +| 出错 | 记录 `Error` + `ErrorNodeId`,事件 `error `,整图停止(不做隐式重试) | +| 报告 | `Steps` / `TotalMs` / `Records[nodeId]`(含 `LastOutputs`,供 `/api/flow/status` 与试跑回传) | +| 事件流 | `ConcurrentQueue`,服务轮询取快照,前端据此高亮与显示计时 | + +### 3.1 慢感知(`slow`) + +写在**节点上**(不是 `params` 里),把「多贵」与「多新」解耦: + +```json +{ "id": "ocr", "type": "ocr.text", "slow": { "everyFrames": 3, "onChange": true, "intervalMs": 500 } } +``` + +| 字段 | 命中条件(满足即**复用上次输出**、不重复执行) | +|---|---| +| `everyFrames` | 采集源帧号推进 **< N** 帧(源不提供帧号 → 判定不了 → 保守重算) | +| `onChange` | 感知输入指纹与上次**相同**(指纹取 `image` 入边,缺省取当前帧编码字节) | +| `intervalMs` | 距上次执行 **< N 毫秒** | + +多个条件同时给出取**最保守**语义:任一条判定「该重算」就重算;全部判「可复用」且存在上次缓存才复用。 +空对象 `{}` = 永不复用(等于不写 `slow`)。复用时会记 `ReusedCount++` 并发 `nodeReuse` 事件。 +`onChange` 的指纹只在配置了该字段时才计算(否则每步都会白抓一帧)。 + +### 3.2 单节点试跑(`POST /api/node/run`) + +语义 = 「执行画布上这一个节点」,与整图执行**共用同一个 `FlowNodeRuntime`**,所以试跑结果可信。 + +- 独立模式:`{type, params, inputs}`(`inputs` 只接受字面量)。 +- 图中模式:`{graph, nodeId}`——数据入边引用的上游会被一并惰性求值 + (画布上双击 `ocr.text` 会自动先抓到一帧)。 +- 返回 `{ok, ms, reused, port, outputs, error}`;`port` 是 exec 出口端口 + (`compare` 这类条件节点只有出口、没有数据输出)。 +- **只读边界**:actuation 层(`pad.*` / `script.run`)直接拒绝;并且 `ExecuteGuard` + 会拦下**经数据入边间接到达**的 actuation 节点(只校验目标节点的层是不够的)。 +- 超时 15 秒;引擎每次试跑单独构建并释放(服务侧 OCR 引擎是共享的,按借用语义传入)。 + +--- + +## 4. HTTP API(loopback,无鉴权) + +默认端口 **19391**;GUI 内嵌服务用 `EC_FLOW_PORT` 覆盖(0 = 关闭);CLI 用 `serve --port`。 +绑定 `127.0.0.1`,不要暴露到外网。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/health` | `{ok, service}` | +| GET | `/api/nodes` | 节点目录(§2,画布渲染的单一事实源) | +| GET | `/api/options` | `{appDir, ocr:{backend, modelDir, engineCreated, error, options}}` | +| POST | `/api/options` | `{ocrBackend, ocrModelDir}` 切换 OCR 后端(`none`/`tesseract`/`ppocr`;ppocr 必须给目录) | +| GET | `/api/device/video` | `{connected, sources:[{index,name}]}` | +| POST | `/api/device/video/connect` | `{index, api}`(`api` 为 OpenCV VideoCaptureAPIs 数值,默认 0) | +| POST | `/api/device/video/disconnect` | — | +| GET | `/api/device/mcu` | `{connected, ports:[...]}` | +| POST | `/api/device/mcu/connect` | `{port}`(`"mock"` = 无硬件虚拟手柄) | +| POST | `/api/device/mcu/disconnect` | — | +| POST | `/api/flow/run` | `{json}`(图 JSON 字符串)或 `{path}`(图文件路径)→ `{runId}` | +| POST | `/api/flow/stop` | `{runId}` → 请求停止(协作式取消) | +| GET | `/api/flow/status?runId=` | `{runs:[{runId,name,status,steps,totalMs,error,errorNode,events[],records[]}]}`(`runId` 缺省列全部) | +| POST | `/api/node/run` | §3.2 单节点试跑 | + +`status` 取值:`running` / `completed` / `stopped` / `watchdog` / `error`。 +出错时 HTTP 状态码 400 且体里带可读 `error`。 + +### 4.1 MCP / agent 工具(同源) + +`FlowServiceTools.RegisterAll(registry, state)` 注册到同一 `FlowServiceState`,能力与画布对等: + +| 工具 | 需确认 | 说明 | +|---|---|---| +| `flow_device_query` | 否 | `kind=video|mcu|nodes` 查询设备与节点目录 | +| `flow_device_connect` | **是** | 连接/断开视频源与单片机 | +| `flow_run` | **是** | 运行图(`graph` 或 `path`) | +| `flow_control` | 否 | `action=status|stop` | +| `flow_node_run` | 否 | 单节点试跑(actuation 层被拒 → 天然只读) | + +GUI 启动时把 Flow 服务与这些工具一并装配进 GUI 的共享注册中心;MCP 端点仍按 fail-closed 过滤 +需要确认的工具(`flow_device_connect` / `flow_run` 默认不导出)。 + +--- + +## 5. 宿主与装配 + +- **能力来源**:`FlowHostContext` 只带端口(`ICaptureSource` / `IVisionService` / `IOcrService` / + `IInference` / `IPadInput`)与 `AppDir`;null 一律表示「该能力不可用」,相关节点报错而非静默降级。 +- **唯一装配点**:服务侧每次运行/试跑经 `ScriptHostAssembler.Assemble` 组装一次 + (`FlowServiceState.BuildRunContext`),采集源与手柄以**端口**传入,OCR/推理为**共享借用** + (`BorrowResources = true`),由 `FlowServiceState` 自己持有并在 `Dispose` 释放。 + `script.run` 节点同样走装配器(不再手写 `CapabilitySet`)。 +- **采集源**:`FrameStoreCaptureSource`(最新帧 + 单调 `FrameIndex`)——`waitForNew` 与 + `slow.everyFrames` 依赖帧号。旧的委托适配 `DelegateCaptureSource` 不提供帧号,并把 + 「采集卡检查异常」等哨兵字符串归一为 `null`(避免被当成 Base64 图像)。 +- **设备持有**:设备实例单一持有。GUI 宿主下 `FlowServiceState` 经设备桥 + (`FlowDeviceBridge`,纯委托集)直接驱动 GUI 监控页持有的采集卡/单片机实例—— + 画布(`/api/device/*`)、agent(`flow_*` 工具、`get_frame`)与 GUI 面板是**同一实例**, + 连接状态实时一致,一块采集卡/串口只开一次;视频源 `api` 参数在桥接下不生效 + (后端由 GUI 的 CaptureType 配置决定),`mock` 单片机两种模式下都由状态自持。 + 独立宿主(CLI `serve`)不传桥,由 `FlowServiceState` 自己持有连接。 + +### 5.1 命令行 + +```bash +# 跑图(无 UI):--port mock 虚拟手柄,--video -1 不接采集源 +dotnet run --project src/EasyCon2.CLI -- flow examples/battle.flow.json --port mock --video -1 +dotnet run --project src/EasyCon2.CLI -- flow x.flow.json --max-steps 500 --timeout-sec 60 + +# 起服务(画布/外部工具的后端) +dotnet run --project src/EasyCon2.CLI -- serve --port 19391 +``` + +`flow` 子命令走装配器,因此 **OCR 默认可用(tesseract)**;`serve`/GUI 的服务侧 OCR 默认 `none`, +需要 `POST /api/options` 显式开启(避免无人值守服务悄悄加载模型)。 + +--- + +## 6. Python 画布前端(`frontend/`) + +技术栈 PySide6 + NodeGraphQt(不引入 ComfyUI/torch);环境用 **uv** 管理 +(`pyproject.toml` + `uv.lock`,`src/` 布局,console script `easycon-flow`)。 +详见 [`frontend/README.md`](../frontend/README.md)。 + +| 文件 | 职责 | +|---|---| +| `frontend/pyproject.toml` / `uv.lock` / `.python-version` | 依赖与锁定(PEP 621 + uv);Python 固定 3.14 | +| `frontend/src/easycon_flow/flowdoc.py` | flow.json 文档模型 + 画布映射(纯 Python,无 Qt,可单测) | +| `frontend/src/easycon_flow/api.py` | 后端 HTTP 客户端 | +| `frontend/src/easycon_flow/nodes.py` | 目录 → NodeGraphQt 节点类;画布 ↔ 状态搬运(含预置属性名冲突规避 `p_` 前缀);参数在节点体内嵌同值控件(bool/int/enum 直接编辑,path 带「…」选文件,text 带多行编辑对话框,与右侧属性面板双向同步) | +| `frontend/src/easycon_flow/window.py` | 主窗口:画布 / 节点库 / 属性(含 slow) / 设备面板 / 运行面板 | +| `frontend/src/easycon_flow/selftest.py` | 离线自检 + 对真后端的端到端往返(`--backend`) | +| `frontend/src/easycon_flow/uitest.py` | 无头 UI 冒烟(建窗、载图、跑图、试跑节点;需要后端) | + +启动与自检: + +```bash +cd frontend +uv sync # 建 .venv 并按 uv.lock 装依赖 +uv run easycon-flow # 打开界面(等价 ./run.sh) +uv run easycon-flow --self-test # 离线自检(35 项) +uv run easycon-flow --self-test --backend http://127.0.0.1:19391 # +真后端端到端(40 项) +QT_QPA_PLATFORM=offscreen uv run python -m easycon_flow.uitest --backend http://127.0.0.1:19391 +``` + +依赖坑:NodeGraphQt 0.6.x 仍 `import distutils`,Python ≥3.12 已移除该模块 → +`pyproject.toml` 里的 `setuptools` 是**必需项**(提供兼容层)。 + +--- + +## 7. 已知限制与挂账 + +| 项 | 现状 / 原因 | +|---|---| +| `ocr.screen`(det+rec 全屏 → lines 数组) | **挂账**:Core 的 `IOcrService` 由单行识别器支撑(`EngineCacheOcrService.GetOrInit` → `IOcrRecognizer`),全屏多行需要先暴露 `IOcrEngine` 端到端引擎(Capture 层已有,PP-OCR det+rec 按部署约定名解析)。当前 `ocr.text` 走宿主 OCR 通路,`lang`/ROI 由后端决定 | +| `vision.label`(标签匹配) | **挂账**:需要宿主注入手柄式视觉原料(GUI 的 `LabelMatch` 委托)到 `FlowServiceState`;目前服务侧 `Vision = null`,`vision.*` 只有 `vision.changed`(不依赖 `IVisionService`)可用 | +| `switch`(多路分支) | **挂账**:静态端口目录表达不了动态出口;当前用 `compare`/`text.contains` 链或 `state.step` + `script.run` 替代 | +| `ai.agent`(函数调用回环节点) | **挂账**:需要 provider 抽象(ollama/vllm/siliconflow/onnx/script)+ 按图配置的回调工具集,且默认只读、`pad` 类需显式授权 | +| `subgraph`(子图节点) | **挂账**:图内嵌图,需要节点目录支持动态展开 | +| 数据入边新鲜度 | 纯数据边(上游不在 exec 路径上)整轮只求值一次(§1.3);需要逐轮新鲜时把来源节点接进 exec 路径 | +| `slow` 与 GUI 联动(暂停/继续) | 未实现:目前只有停止(协作式取消),没有暂停/单步 | + +> 新增节点必须同时登记 `FlowNodeCatalog`(前端据此渲染)与 `FlowNodeRuntime`(执行语义); +> `FlowNodeCatalogTests.Catalog_EveryNodeTypeIsImplementedByRuntime` 会遍历目录逐个执行, +> 抓出「目录里有、运行时没有」的漂移。 diff --git a/docs/Framework.md b/docs/Framework.md index 798d156e..2fb0baa2 100644 --- a/docs/Framework.md +++ b/docs/Framework.md @@ -1,88 +1,159 @@ # 系统架构设计 -伊机控采用现代化的分层架构设计,实现了高度模块化和跨平台支持。 +> 本文是**现状**架构说明,描述代码里真实成立的分层与职责。改架构先改本文。 +> 编译链路细节见 [Pipeline.md](Pipeline.md),模块系统见 [ModuleSystem.md](ModuleSystem.md), +> 双端语义以对拍测试为准(`test/EasyCon.Tests/Bytecode/`), +> MCU 产物下发见 [McuBytecodeDelivery.md](McuBytecodeDelivery.md)。 -## 技术栈 +## 1. 定位与技术栈 -- **.NET 10.0** - 现代化的跨平台开发框架 -- **Avalonia UI** - 跨平台用户界面框架,基于MVVM架构 -- **CommunityToolkit.Mvvm** - 高效MVVM框架,清晰的职责分离 -- **OpenCV** - 图像处理和计算机视觉 -- **自研脚本引擎** - 完整的编译器-虚拟机实现,支持复杂逻辑 -- **模块化设计** - 松耦合架构,易于扩展维护 +伊机控 EasyCon 是 Nintendo Switch 自动化工具:串口单片机充当虚拟手柄、采集卡/摄像头提供画面、 +自研 ECS 脚本语言编排流程,另有一个内置 AI Agent 可读写脚本与驱动运行。 -## 架构概览 +- .NET 10.0 / C#(`LangVersion=preview`)、Avalonia 12 跨平台 GUI +- OpenCV 5(OpenCvSharp5)视觉匹配、自研 Tesseract 绑定 OCR、可选 ONNX 推理 +- 自研编译器 + 字节码 + 双端解释器(桌面 C# / 单片机 C99) +- 集中包管理(`Directory.Packages.props`)、NUnit 测试 -### 系统架构图 +**入口**:`src/EasyCon2.Avalonia`(GUI)、`src/EasyCon2.CLI`(`ezcon` 命令行)。 +## 2. 分层与依赖方向(真实图) + +```mermaid +graph TD + subgraph 叶子库["叶子库(零 ProjectReference,可独立测试)"] + SCRIPT[EasyCon.Script
编译器 + 字节码 + C# 解释器] + DEVICE[EasyCon.Device
串口 + HID 报文] + EZT[EzTesseract
Tesseract/Leptonica 绑定] + CAPTURE[EasyCon.Capture
采集 + 视觉 + OCR 缓存] + end + CORE["EasyCon.Core
能力端口 + 组合根 + 执行桥"] + SDL[EasyCon.SDLInput] + LSP[EasyCon.Lsp] + ACORE[EasyCon2.Avalonia.Core
纯 VM/逻辑层] + APP[EasyCon2.Avalonia
GUI 宿主] + CLI[EasyCon2.CLI] + + CAPTURE --> EZT + CORE --> SCRIPT & DEVICE & CAPTURE + SDL --> CORE & DEVICE + LSP --> SCRIPT + ACORE --> CORE & LSP + APP --> CORE & ACORE & SDL + CLI --> CORE & LSP ``` -┌─────────────────────────────────────────────────────────┐ -│ 用户界面层 (UI Layer) │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ Avalonia UI │ │ WinForms UI │ │ -│ │ (跨平台) │ │ (Windows) │ │ -│ └──────────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 业务逻辑层 (Business Logic) │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ ViewModels │ │ Services │ │ -│ │ (MVVM模式) │ │ (业务服务) │ │ -│ └──────────────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 核心层 (Core Layer) │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ EasyCon.Core │ │ -│ │ (脚本运行器、项目管理、配置管理) │ │ -│ └──────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 模块层 (Module Layer) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Device │ │ Capture │ │ Script │ │ VPad │ │ -│ │ (设备通信)│ │(图像处理)│ │(脚本解析)│ │(虚拟手柄)│ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 硬件层 (Hardware Layer) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ 单片机设备 │ │ 采集卡 │ │ 手柄设备 │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` -## 架构优势 +**不存在工程级循环依赖**:`EasyCon.Script` / `EasyCon.Device` / `EasyCon.Capture` / `EzTesseract` +四个叶子库内部 `grep EasyCon.Core` 零命中。 + +### 2.1 `EasyCon.Core` 是组合根,不是底层 + +这是最容易读错的一处:`EasyCon.Core` 名字像"核心抽象层",实际是**能力端口 + 组合根**的混合体。 + +- 它**向上**被 SDLInput / Lsp / Avalonia.Core / CLI 依赖; +- 它**向下**引用 Script / Device / Capture 三个实现库,从而把 OpenCV、Tesseract 拉进自己的依赖闭包。 + +因此: + +| 规则 | 原因 | +|---|---| +| 不得从 Script/Device/Capture/EzTesseract 反向引用 Core | 保持四个叶子库零依赖,可独立编译与差分测试 | +| 不得在 `Hosting/ScriptHostAssembler` 之外构造 `CapabilitySet` | 装配规则只能有一处(见 §5.1) | +| 不得手写 `CompileOptions` 字面量 | 档位语义集中在 `Hosting/ScriptCompileProfiles`(见 §5.2) | +| 新增能力端口时同步更新 `CapabilitySet` 与装配器 | 端口与装配必须一起演化 | + +## 3. 六条架构支柱 + +1. **能力模型(Ports & Adapters)**:脚本执行面只认 `EasyCon.Core.Capabilities` 的接口 + (`IPadInput` / `IConsoleIo` / `IHostEnvironment` / `IFileSystem` / `ICaptureSource` / + `IVisionService` / `IOcrService` / `IInference`),`null` 表示"该能力不可用"。 + VM 核(`EcxInterpreter` / `ecs_vm.c`)完全不感知这些类型。 +2. **单一编译链路**:源码 → 模块图 → 逐模块独立编译 → 链接 → `EcxImage`, + 桌面解释器与单片机 C VM 消费同一镜像(细节见 [Pipeline.md](Pipeline.md))。 +3. **内容寻址的模块系统**:接口哈希(Merkle 式)决定失效范围,实现体改动不触发下游重编 + (细节见 [ModuleSystem.md](ModuleSystem.md))。 +4. **双端语义契约**:C# 解释器与 C VM 的行为逐条锁定在 S-01..S-19,并由 corpus 三方对拍 + + 结构化 fuzz 保障(对拍测试组,`test/EasyCon.Tests/Bytecode/`)。 +5. **编译器强制的 MVVM 纯度**:`EasyCon2.Avalonia.Core` 零 Avalonia 包引用, + ViewModel 一旦引入 Avalonia 类型即编译失败。 +6. **单一事实源落点表**:[Pipeline.md §4](Pipeline.md) 为每条"只该存在一处"的知识指定文件, + 改对应知识只动一处(指令格式、syscall 编号、内置函数清单、缓存键选项……)。 + +## 4. 模块职责 + +| 工程 | 职责 | 关键类型 | +|---|---|---| +| `EasyCon.Script` | 词法/语法/AST → 绑定/类型检查 → SSA + 优化 → 字节码编码 → 模块编译与缓存 → 链接 → C# 解释器 | `Compilation`、`Binder`、`SsaOptimizer`、`BytecodeEncoder`、`EcxLinker`、`EcxInterpreter`、`ModuleCache` | +| `EasyCon.Capture` | 采集源枚举与打开、`FrameStore` 租约、模板匹配/边缘检测、OCR 引擎缓存(Tesseract / ONNX 后端) | `FrameStore`/`FrameLease`、`FrameProducer`、`MatchFacts`、`OcrEngineCache` | +| `EasyCon.Device` | 串口协议(0xA5 帧头、Hello 握手、115200→9600 回退)、HID 报文构建、发送队列与节流、录制、Flash | `IConnection`、`TTLSerialClient`、`NintendoSwitch`、`SwitchReport` | +| `EzTesseract` | 手写 Tesseract/Leptonica 绑定(`[LibraryImport]` + `DllImportResolver`,为 AOT 与体积只封 12 个入口) | `NativeLoader`、`Engine`、`Page`、`Pix.Image` | +| `EasyCon.Core` | 能力端口 + 组合根(`Hosting/`)+ 脚本门面(`Script/`)+ 执行桥(`Runner/EcxVm`)+ 配置(`Config/ConfigManager`)+ LLM 客户端(`LLM/`) | `CapabilitySet`、`ScriptHostAssembler`、`ScriptCompileProfiles`、`IScriptEngine`、`EcxVm` | +| `EasyCon.SDLInput` | SDL3 事件循环(不抢焦点全局键盘)、键盘/手柄 → HID 映射、整表原子替换 | `SdlEventLoop`、`SdlKeyboardInputBinder`、`SdlGamepadMapper` | +| `EasyCon.Lsp` | ECS 语言服务:补全/悬停/跳转/符号/诊断;只复用 Script 的语法树,不求值不产码 | `EcsLanguageServer`、`DocumentManager`、`Constants` | +| `EasyCon2.Avalonia.Core` | 纯 VM/逻辑层:脚本服务、日志服务、配置服务、AI Agent 编排、MCP 客户端、标签编辑器、终端、LSP 客户端 | `ScriptService`、`AgentOrchestrator`、`ToolRegistry`、`McpManager`、`LspClientService` | +| `EasyCon2.Avalonia` | GUI 宿主:View/控件、平台服务实现(设备/采集/手柄/对话框/窗口/主题)、`VPad/` 虚拟手柄 | `MainWindowViewModel`、`CaptureService`、`DeviceService`、`ControllerService` | +| `EasyCon2.CLI` | `ezcon`:`init` / `run` / `port` / `video` / `format` / `ir` / `modules` / `compile` / `lsp` / `agent` / `flow` / `maa` / `labels` / `serve` | `Program.cs`、`InitCommand.cs`、`ConsoleOutAdapter` | +| `EasyCon.Vm`(原生,不在 slnx) | 单片机端字节码解释器 + 参考宿主;由 `ci/build-vm.sh` 与测试侧 `CvmRunner` 构建 | `ecs_vm.c/h`、`ecs_main.c` | + +## 5. 组合与生命周期 + +### 5.1 没有 DI 容器,只有显式组合 + +全仓无 `IServiceCollection` / `IServiceProvider`。对象图靠构造函数显式传递,装配点只有两个: + +- GUI:`src/EasyCon2.Avalonia/App.axaml.cs` 的 `OnFrameworkInitializationCompleted` + (日志 → 设备 → 采集 → 脚本 → 手柄 → 对话框/窗口 → `MainWindowViewModel`,退出时统一释放) +- CLI:`src/EasyCon2.CLI/Program.cs` 各子命令的 `SetAction` + +**唯一的能力装配点是 `EasyCon.Core/Hosting/ScriptHostAssembler`。** 宿主只提供"原料" +(`ScriptHostContext`:pad、帧/ROI/标签委托、控制台、参数、AppDir),由装配器负责: + +- 适配器包装(`PadInputAdapter` / `ConsoleIoAdapter` / `DelegateCaptureSource` / `DelegateVisionService`) +- 默认值(`IHostEnvironment` 恒装配;`IFileSystem` 缺省桌面实现;OCR/DNN 缺省装配但可关) +- 生命周期(返回 `CapabilityLease`,`Dispose` 释放 OCR 引擎与推理会话) + +模块划分见 `Capabilities/`(端口)与 `Hosting/`(组合),二者不可互相渗透。 + +### 5.2 编译档位 + +`Hosting/ScriptCompileProfiles` 集中定义缓存策略,宿主不得手写选项。 +(单流化后**没有宽窄两种产物**——原 Desktop/Portable 的格式维度随 `EnablePcWideSlots` 一起删除; +「能否装进设备」由烧录前容量预检判定。) + +| 档位 | 缓存 | 用途 | +|---|---|---| +| `Interactive` | 不落盘、现编(进程缓存兜底) | run/编辑器等高频重编路径 | +| `Distributable` | 启用 `obj/` 内容寻址缓存 | compile/分发产物路径,产物可落 `.ecx` 交 C VM | + +## 6. 三条主链路 + +**脚本执行**:`IScriptEngine.FromSource/LoadFile` → `IScriptSession.Info`(诊断/镜像/符号/KeyAction/NeedIL) +→ `ScriptHostAssembler.Assemble` → `IScriptSession.Run(token, capabilities)` → `EcxVm.Run` +→ `EcxInterpreter` → 经 `EcxHost` 委托回调宿主能力。 -### 跨平台能力 -- **多UI框架**: Avalonia支持全平台,WinForms专精Windows -- **设备抽象**: 硬件无关的接口设计,便于移植 -- **统一API**: 各平台共享核心业务逻辑 +**设备控制**:`IPadInput` → `GamePadAdapter` → `NintendoSwitch`(30 ms 节流)→ `TTLSerialClient` +发送队列 → `SwitchReport.GetBytes()` → 串口 → MCU。 -### 设备兼容性 -- **多协议支持**: Serial、TCP/IP、蓝牙等多种通信协议 -- **自动发现**: 智能设备扫描和连接机制 -- **接口统一**: 标准化的服务接口设计 -- **热插拔**: 支持运行时设备连接和断开 +**图像识别**:`ICaptureSource`/`FrameDelegate` ← `FrameProducer`/`FrameStore` 租约; +`IVisionService.MatchLabel` ← `ImgLabel.Search`(模板匹配 / 边缘 / OCR 分支);帧一律以 Base64 PNG 过接口。 -### 图像识别能力 -- **多引擎支持**: 集成多种图像处理库 -- **算法丰富**: 模板匹配、OCR、颜色检测等 -- **性能优化**: 异步处理、智能缓存 -- **易扩展**: 插件式算法架构 +## 7. 已知短板(改相关代码前先读) -### 代码质量保证 -- **MVVM架构**: 清晰的职责分离,便于单元测试 -- **数据绑定**: 响应式UI更新,减少样板代码 -- **模块化设计**: 松耦合、高内聚,易于维护 -- **完善文档**: 详细的架构设计和API文档 +| 短板 | 位置 | +|---|---| +| MCU 字节码下发链路断裂:GUI 烧录入口抛 `NotImplementedException`(产物可序列化,缺的是入口与传输编排) | [McuBytecodeDelivery.md](McuBytecodeDelivery.md) | +| GUI 与 CLI 各自维护一份"连接-采集-释放"编排(能力装配已收敛,编排尚未) | `App/Services/*` vs `Program.cs` | +| 上帝类:`MainWindowViewModel`(1745)、`EcxInterpreter`(1486)、`AgentOrchestrator`(806) | — | +| 少数 VM 带 Avalonia 渲染类型,位于编译器强制区之外 | `EasyCon2.Avalonia/ViewModels/` | +| 公共面偏大(Script 126 / Avalonia.Core 93 / Core 84 个 public 类型) | — | +| AI Agent 工具无审批闸门,`ApiKey` 明文落盘 | `AiAgent/`、`Core/LLM/` | +| `EasyCon2.UI.Common` 退化为 resx 容器;`ViewLocator` 是死代码 | — | -## 开发工具 +## 8. 相关文档 -- **IDE**: Visual Studio 2026 -- **版本控制**: Git -- **构建工具**: MSBuild \ No newline at end of file +- [Pipeline.md](Pipeline.md) — 统一编译链路与单一事实源落点 +- [ModuleSystem.md](ModuleSystem.md) — 接口式独立编译与缓存 +- [VM2.md](VM2.md) — 虚拟机规格(产物容器规格见 `EcsContainer`/链接器测试) +- [McuBytecodeDelivery.md](McuBytecodeDelivery.md) — MCU 产物编译与烧录方案 +- [Script.md](Script.md) / [Functions.md](Functions.md) — 脚本语言与函数手册 +- `AGENTS.md` — 工程约定与"改代码前必读"的分层规则 diff --git a/docs/Functions.md b/docs/Functions.md index 3e9022a0..e5ab7ee2 100644 --- a/docs/Functions.md +++ b/docs/Functions.md @@ -35,7 +35,7 @@ PRINT "计数: " & $x # 用 & 拼接,输出 计数: 5 - 文本以 `\` 结尾表示续行:下一段输出接在同一行。 ### ALERT(message) -弹出提醒(GUI 弹窗/推送)。单片机上静默忽略。 +向运行日志写入提醒,并通过桌面宿主配置的通知渠道推送。单片机上静默忽略。 | 参数 | 类型 | 说明 | |------|------|------| @@ -45,6 +45,20 @@ PRINT "计数: " & $x # 用 & 拼接,输出 计数: 5 ALERT "任务完成" ``` +在「用户配置 → 推送设置」展开列表中的「qq bot」,填写 QQ 开放平台机器人的 AppID、AppSecret,验证凭据后绑定私聊或群聊接收方,并发送图文测试。使用该项右侧的开关启用通知,最后点击窗口底部的「保存」。QQ 与 PushPlus、Bark、自定义 Webhook 使用同一份推送列表、统一保存和发送入口;「添加配置」也可添加 QQ 推送项。验证、绑定和测试期间的设置只保留在当前编辑项中,点击「取消」不会应用这些修改。绑定码每 60 秒更新,只接受当前六位数字;群聊绑定需 @机器人。机器人须具有对应的私聊/群聊消息权限,开发阶段接收方须在 QQ 开放平台允许的测试范围内。 + +qq bot 编辑区顶部的「使用教程」可打开三阶段图文教程,包含 12 步注册截图、接入说明和 `ALERT` 示例;截图支持放大,返回配置保留当前输入。旁边的 [QQ 开放平台](https://q.qq.com/#/apps) 超链接会在默认浏览器中打开机器人管理页面。也可阅读 [qq bot 使用教程](QQ_BOT_SETUP.md)。 + +QQ 默认发送提醒文字和触发 `ALERT` 时的最新视频帧,图片会压缩为 JPEG;没有视频帧时使用 EasyCon Logo。发送在后台排队,失败会写入日志,不中断脚本。最多等待 20 条通知,满队列时当前通知会被拒绝并记录原因。关闭自动通知或更改凭据会取消旧队列,已提交的消息仍可能送达。 + +AppSecret 默认只在当前应用会话内保留;Windows 可勾选「记住 AppSecret」使用当前 Windows 用户的 DPAPI 加密保存。重启后未保存密钥时,需重新填写并启用通知。图文测试始终包含图片。 + +推送配置统一保存在 `%APPDATA%\easycon\alert.json`,QQ 接入参数属于该文件中的一个推送项。旧版独立 QQ 配置会在首次使用新版本时迁入列表;原有推送配置与已加密保存的凭据会保留。 + +如果列表提示还有配置未显示,点击「显示全部」即可管理迁入的 QQ 项。首次迁移无法写入文件时,软件会继续使用内存配置并提示重新保存,原文件会保留。 + +CLI `run` 使用相同用户配置,也支持 `ALERT` 图文通知,并在脚本结束后等待队列发送完成(最多 60 秒)。单片机独立运行时没有桌面网络与视频源,因此无法发送 QQ 通知。 + ### BEEP(freq, duration) 蜂鸣。仅 Windows 桌面发声;其他平台静默。 @@ -118,7 +132,7 @@ WAIT $w # 随机延时 500~999ms(两步写法,见"实用要 $name = ARG(0) PRINT $name ``` -- CLI 传参:`ecs-run 脚本.ecs -- 参数1 参数2`;GUI 运行时也可填参数。 +- CLI 传参:`ezcon 脚本.ecs -- 参数1 参数2`;GUI 运行时也可填参数。 ### ENV(name): STRING 读取环境变量,不存在返回空串。 @@ -136,11 +150,12 @@ $home = ENV("HOME") ## 5. Amiibo ### AMIIBO(index) -切换到手柄的 Amiibo 槽位。`index > 9` 时静默忽略。 +切换 Amiibo 槽位(v2.3 槽位选择;ESP32 本地执行 / 其他宿主静默)。 +槽位合法范围 0~19,越界静默忽略。 | 参数 | 类型 | 说明 | |------|------|------| -| index | INT | 槽位号(0~9) | +| index | INT | 槽位号(0~19) | ```ecs AMIIBO 3 @@ -379,8 +394,8 @@ $line = FREAD(0, 0) > 实验功能。脚本带 VISION 标记,面向桌面运行;单片机加载期即识别为桌面专属脚本。 > 推理结果与具体模型强相关,模型文件由运行环境提供。 -### NET_LOAD(path): INT -加载 ONNX 模型,返回会话句柄;失败返回 -1。 +### NET_LOAD(path): PTR +加载 ONNX 模型,返回会话句柄(PTR);失败返回 -1。 | 参数 | 类型 | 说明 | |------|------|------| @@ -390,17 +405,29 @@ $line = FREAD(0, 0) $net = NET_LOAD("models/demo.onnx") ``` -### NET_RUN(net, inputArr): INT -把数值数组送入模型推理,返回**输出元素个数**(失败返回 0)。 +### NET_SCALE(tensor, scale, offset): PTR +对张量做逐元素仿射变换 `v * scale + offset`(如 PP-OCR 归一化),返回新张量句柄(PTR)。 + +| 参数 | 类型 | 说明 | +|------|------|------| +| tensor | PTR | NET_IMAGE / PPOCR_TENSOR 返回的张量句柄 | +| scale | DOUBLE | 乘法系数 | +| offset | DOUBLE | 加法偏移 | + +```ecs +$t = NET_SCALE($img, 1.0 / 255, -0.25) +``` + +### NET_RUNH(net, tensor): INT +把张量句柄送入模型推理,返回**输出元素个数**(失败返回 0)。 | 参数 | 类型 | 说明 | |------|------|------| -| net | INT | NET_LOAD 返回的会话句柄 | -| inputArr | 数组 | 输入数据(数值数组,展平成一维) | +| net | PTR | NET_LOAD 返回的会话句柄 | +| tensor | PTR | 张量句柄 | ```ecs -$in = [1, 2, 3] -$n = NET_RUN($net, $in) +$n = NET_RUNH($net, $t) ``` ### NET_OUT(index): DOUBLE @@ -410,12 +437,11 @@ $n = NET_RUN($net, $in) |------|------|------| | index | INT | 输出元素下标(从 0 开始) | -设计为"标量协议":先跑 NET_RUN 拿个数,再逐个 NET_OUT 读取。 +设计为"标量协议":先跑 NET_RUNH 拿个数,再逐个 NET_OUT 读取。 ```ecs IF $net >= 0 - $in = [1, 2, 3] - $n = NET_RUN($net, $in) + $n = NET_RUNH($net, $t) $i = 0 WHILE $i < $n $v = NET_OUT($i) @@ -425,8 +451,272 @@ IF $net >= 0 ENDIF ``` +### NET_IMAGE(x, y, w, h, rw, rh [, mode]): INT +抓当前画面(负坐标 = 全帧,正坐标 = ROI),缩放到 rw×rh 并转成张量,返回**张量句柄**(失败 0)。 +大数组驻留宿主侧,脚本只见句柄。 + +| 参数 | 类型 | 说明 | +|------|------|------| +| x, y, w, h | INT | 采集区域;负值 = 全帧 | +| rw, rh | INT | 目标尺寸(多数 CNN 要求 32 的倍数) | +| mode | STRING | `"gray"`([1,1,rh,rw],缺省)、`"rgb"`([1,3,rh,rw],CHW)或 `"letter"`(YOLO letterbox:等比缩放至目标内、居中、四周垫 114 灰,RGB 通道);`"rgb"`/`"gray"` 可加 `'c'` 后缀(如 `"rgbc"`)改为 cover 缩放 + 居中裁剪——不变形不垫边,分类模型常规 | + +```ecs +$img = NET_IMAGE(-1, -1, -1, -1, 640, 640, "rgb") +``` + +也有 base64 版重载:`NET_IMAGE($imgB64, rw, rh [, mode])`(图像来自 CAPTURE/ROI 结果)。 + +### NET_SCALE(tensor, scale, offset): INT +逐元素变换 t2 = t·scale + offset,返回新张量句柄(原张量不变)。归一化在此组合。 + +```ecs +$t = NET_SCALE($img, 0.003921568627, 0.0) # 0-255 → 0-1 +``` + +### NET_RUNH(net, tensor): INT +以句柄张量为输入推理,返回输出元素个数(失败 0)。输出仍用 NET_OUT(i) 逐元素读取。 + +```ecs +$n = NET_RUNH($net, $t) +``` + +### NET_FREE(handle): INT +释放张量句柄(长脚本建议显式释放;会话结束宿主统一回收)。 + +### NET_UNLOAD(net): INT +卸载模型会话。 + +### NET_ARGMAX(row): INT +读取最近一次推理输出第 `row` 行中**概率最大的列下标**(分类/序列模型的逐行解码原语; +越界或无输出历史返回 -1)。**末维 = 类别维**:`[1,T,C]` 按 T 行 C 列逐行取; +向量输出 `[1,N]`(或 `[N]`)按 1 行 N 列——分类 logits 直接 `NET_ARGMAX(0)` 得类别。 +PP-OCR rec 的 `[1,T,C]` 输出即按此逐帧取字符下标,或直接用 std `CTC_FOLD`(数字子集, +宿主加速)/ std `PP_OCR_TEXT`(整字典,标签表层)一次折叠。 + +### NET_ROWS(): INT +最近一次推理输出的行数(末维 = 类别维:`[1,T,C]` → T、`[1,N]` → 1;无输出历史返回 0)。 + +### NET_NORM(tensor, mean, std): PTR +逐通道归一化 `(x - mean[c]) / std[c]`(ResNet 类模型的硬需求),返回新张量句柄(失败 -1)。 +通道号由宿主按张量形状推断(rank≥4 取 shape[1],rank=3 取 shape[0],rank<3 退化为全局仿射); +mean/std 长度须为 1 或等于通道数。`/255` 量程折叠留给 NET_SCALE 链式承担——每步都是通用原语。 + +```ecs +$t = NET_SCALE(NET_IMAGE($img, 224, 224, "rgbc"), 1.0 / 255, 0) +$t = NET_NORM($t, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) +``` + +### NET_LABELS(path): INT +加载分类标签/字典文件,返回标签数;失败 -1(含文件不存在/空文件)。重复加载整体替换。 +**字典级语义:行号即索引**——逐行原样占位(空行/空白字符行也是条目,如 PP-OCR 字典 +首行的全角空格),仅去行尾 CR 与文件末尾换行。纯字符串处理,不依赖推理能力。 + +### NET_LABEL(index): STRING +第 `index` 个标签;未加载 / 越界返回 ""。CTC 字典配合:class id c → `NET_LABEL(c - 1)`(blank 占位)。 + +### PP-OCR det 文本找框(std 配方 PP_DET_FIND + DET_* 访问器) +det 全图推理**一次**找齐全部文本框(变动布局/未知文本位置的正解;固定 ROI 场景不必用), +再对每框跑 rec 配方: + +```ecs +$det = NET_LOAD("det.onnx") # 部署约定名(代码不绑定模型代际) +$c = PP_DET_FIND($det, $frame, 0, 0, 1920, 1080) # 返回框数(帧形态重载同 rec 配方) +$i = 0 +WHILE $i < DET_COUNT() + $n = PP_OCR_INFER($rec, $frame, DET_X($i), DET_Y($i), DET_W($i), DET_H($i), _REC_H) + $t = CTC_FOLD(0, "0123456789", _DIGITS_BASE) # 窗口起点随模型代际指定(见下) + PRINT $t + $i = $i + 1 +END +``` + +- 预处理:限边缩放(长边 960、32 对齐,小图不放大)→ det 归一化(`PPOCR_NORM "det"`); + 几何(scale/rw/rh)由 std 配方计算并传给洞——宿主不做任何形状假设。 +- `__DET_BOXES__(thresh, scale, roiX, roiY, mapW, mapH)`:阈值二值化 + 水平膨胀(1×15 + 愈合同行间隙)+ 连通域 + unclip(PaddleOCR 同式,v1 ratio=1.0;ratio=2.0 会把大框 + 扩成巨型 rec 输入——上调前先加框尺寸护栏)。框按阅读序(y 优先 x)缓存。 +- 访问器越界返回 -1;`DET_COUNT` 未找过为 0。完整用法见参考示例 + `examples/ppocr-det-find.ecs`(全页 det → 逐框 rec,收集页面数字与文本行)。 + +std 侧已备好的 PP-OCR rec 配方(模型/字典为外部资产,命名约定见 `test/assets/README.md`; +代码不绑定模型代际):`PP_OCR_INFER(net, x, y, w, h, recH): INT`——通用推理步骤(采集 → rec +宽度换算 → 归一化 → 推理,输出驻留宿主,解码自由组合);`PP_OCR_DIGITS(net, base, x, y, w, h, recH): +STRING`(数字子集窗口,宿主 CTC_FOLD 加速;`base` = '0' 的 class 下标,由脚本按模型代际 +指定——PP-OCRv6 字典数字在表头(33 起)、v5 在表尾(16163 起),std 不钉任何代际常数); +`PP_OCR_TEXT(net, x, y, w, h, dictPath, recH): STRING`(整字典文本,标签表层逐帧解码, +如宝可梦状态页名称识别;加载字典会整体替换 NET_LABELS 全局表)。 +`recH` = rec 模型的输入行高,同为代际常数由调用方显式传(PP-OCRv6 资产 48、v5 旧资产 32; +喂错行高 onnxruntime 直接 INVALID_ARGUMENT,OpenCV DNN 不校验形状会静默产出劣化结果)。 +三者均有**共享采集重载**(第二参传 `STRING` 整帧 base64,如 `FRAME()` 结果): +同帧多区域只采集一次,逐区域 `__ROI__` 裁剪在宿主内完成(ROI 走桌面参考回落 +`DesktopVisionService`,与采集路径共享裁剪核,零装配可用)——宝可梦状态页示例 +7 区域仅采集 1 帧。 + +数字窗口下标自查(模型代际无关的通用写法,`NET_LABELS` 返回行数): + +```ecs +$count = NET_LABELS($dict) +$i = 0 +WHILE $i < $count AND NET_LABEL($i) != "0" + $i = $i + 1 +END +# $i < $count 时:$i = '0' 的行号,class 下标 = $i + 1(blank 占 0) +``` + +### NET_IMAGE_FILE(path, rw, rh [, mode]): PTR +本地图像文件 → 同 `NET_IMAGE` 管线(mode 词表相同,含 `"letter"` 与 `'c'` 裁剪后缀), +返回张量句柄(失败 0)。与采集版互补:采集走 `NET_IMAGE`,flow/离线图片走本函数。 + +完整分类链路(ResNet 类模型): + +```ecs +$net = NET_LOAD("resnet18.onnx") +$t = NET_SCALE(NET_IMAGE_FILE("test.png", 224, 224, "rgbc"), 1.0 / 255, 0) +$t = NET_NORM($t, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) +NET_RUNH($net, $t) +NET_LABELS("imagenet_classes.txt") +PRINT NET_LABEL(NET_ARGMAX(0)) # → 类名 +``` + +### YOLO26 检测(std 配方 YOLO_DETECT + YOLO_* 访问器) +面向 **YOLO26 端到端导出**(Ultralytics 默认导出即端到端,免 NMS): +输出 `[1,300,6]` = `[x1, y1, x2, y2, conf, class_id]`,坐标在 640×640 letterbox 空间。 +argmax 已由模型内部完成(列语义异质),NET_ARGMAX 不适用——解码走 std 配方 + 宿主加速洞: + +```ecs +$net = NET_LOAD("models/yolo26n.onnx") +$c = YOLO_DETECT($net, 100, 80, 400, 300, 0.5) # 返回命中数(输出缺失/非端到端形状 → -1) +$i = 0 +WHILE $i < YOLO_COUNT() + $cls = YOLO_CLS($i) + $x = YOLO_X1($i) + PRINT $cls + $i = $i + 1 +END +``` + +- `YOLO_DETECT(net, x, y, w, h, thresh): INT`——采集 ROI → letterbox → 归一化 → 推理 → 解码, + 返回命中数。逆映射 + 内容区裁剪已完成,访问器直接给屏幕坐标。 + 端到端 stride 校验(输出元素数 / 行数 ≠ 6 → -1,防 `end2end=False` 导出错位解码)在配方层。 +- `YOLO_COUNT()`——最近一次解码的命中数(未解码为 0)。 +- `YOLO_CLS(i): INT` / `YOLO_SCORE(i): DOUBLE` / `YOLO_X1(i)` `YOLO_Y1(i)` `YOLO_X2(i)` `YOLO_Y2(i): INT`—— + 按**命中列表**下标读取(非原始 300 行);坐标裁剪回 ROI 内容区(Ultralytics predict 同约定); + 访问器越界返回 -1(分数 -1.0)。 +- 想自实现解码策略:`NET_ROWS` + `NET_OUT(i*6+j)` 扁平寻址原始输出。 + +完整示例见 `examples/pokemon-stats.ecs`(PP-OCRv6 rec 识别宝可梦状态页并计算能力值)。 + --- +## 声音匹配【桌面 GUI,需连接游戏音频】 + +先保存入口 ECS,并连接主窗口的游戏音频。声音标签放在**入口脚本旁**的 `audiolabel` 文件夹里;制作步骤、自动创建目录和排错见[声音标签](AudioLabels.md#第一次在-ecs-中使用)。 + +```text +我的脚本/ +├─ test.ecs +└─ audiolabel/ + └─ 医疗.audiolabel/ + ├─ label.json + └─ audio.wav +``` + +函数只填制作时的名称 `"医疗"`,不填路径、`.audiolabel` 后缀或 `@`。名称与标签内部名称须完全一致,包括大小写。导入的子脚本也以入口 ECS 所在目录为准。换电脑时,将 ECS 和整个 `audiolabel` 一起复制;不要把脚本放在标签文件夹内部。 + +匹配模板支持 **0.15~10 秒**且必须有有效声音特征。这是一次匹配所需的样本长度;识别可以持续到卸载或脚本结束,没有内置监听时长或超时。 + +### AUDIO_LOAD(labelName: STRING) + +加载样本、建立本轮后台识别后继续下一条语句,不等待命中。按键、普通 `WAIT` 和循环期间都持续匹配。开始识别前的声音不计入本轮;加载文件和准备模板本身需要时间,需在目标操作前调用。 + +可写 `AUDIO_LOAD "医疗"` 或 `AUDIO_LOAD("医疗")`,无返回值。同名重复加载会报错,先 `AUDIO_UNLOAD` 才能开始新一轮;不同名称可同时加载,各自保留峰值。 + +### AUDIO_SCORE(labelName: STRING): INT + +立即读取本次成功 `LOAD` 后**已完成分析的累计最高相似度**,返回 **INT,范围 0~100**。百分制分数统一向下取整:84.9 得到 84,85.0 得到 85。读取不等待新声音、不进行现场分析、不清零、不消费结果,也不停止识别。 + +- 尚未产生第一个完整模板的分析结果时返回初始 `0`。此时的 0 表示尚无得分;完成分析的静音也可以得 0。 +- 分数依次为 60、92、40 时,累计最高分仍为 92。声音结束或经过 15 秒后,只要输入持续有效,峰值仍然保留。 +- 没有新得分时重复读取仍返回已保存峰值。要保留不同轮次的结果,先存入脚本变量,再卸载重载。 +- 未加载或已经卸载、输入断线、后台分析失败时明确报错,不用 0 或旧高分掩盖错误。 + +脚本用 `IF AUDIO_SCORE("医疗") >= 85` 自己决定动作。85 是待校准的示例阈值;测试窗口的分数、峰值和命中记录采用同一取整规则,阈值也为整数。窗口阈值不会自动写入脚本。分数是相似度,不是概率。 + +### AUDIO_UNLOAD(labelName: STRING) + +停止该名称的后台识别,释放模板、待处理音频和本轮分数,无返回值。未加载或已卸载时调用会报错。之后重新 `AUDIO_LOAD` 从初始 0 开始,不继承旧声音或旧峰值。 + +`BREAK` 只退出循环,识别继续到 `UNLOAD`。正常结束、运行异常或点击停止脚本时,程序自动清理本次运行的全部音频监听及后台任务,保留共享音频源连接。 + +### 示例:边操作边识别,命中后执行五次 B + +先连接游戏音频和手柄,准备名为“医疗”的标签。此例未命中时持续按 A;命中后按 B 五次,退出外层循环,再卸载。循环中的 `WAIT 800` 由脚本决定检查节奏,后台识别不会修改等待时长或自动打断按键。 + +```ecs +AUDIO_LOAD "医疗" +FOR + IF AUDIO_SCORE("医疗") >= 85 + FOR 5 + B + WAIT 800 + NEXT + BREAK + ENDIF + A + WAIT 800 +NEXT +AUDIO_UNLOAD "医疗" +``` + +完整文件:[audio-load-score.ecs](../examples/audio-load-score.ecs)。刚加载后的首轮 IF 可以直接读取 0;目标声音达到阈值后,脚本执行到下一次 IF 才响应。 + +### 示例:观察 8 秒并打印峰值 + +无需手柄。8 秒由脚本的 `WAIT` 指定;读取只包含当时已算完的结果,不会等排队音频补算,也不会增加宽限时间。 + +```ecs +AUDIO_LOAD "医疗" +PRINT "开始识别,请在接下来的 8 秒内触发目标声音" +WAIT 8000 +$score = AUDIO_SCORE("医疗") +PRINT "本轮已完成分析的最高分:" & $score +IF $score >= 85 + PRINT "达到示例阈值" +ELSE + PRINT "尚未达到示例阈值" +ENDIF +AUDIO_UNLOAD "医疗" +``` + +完整文件:[audio-observe.ecs](../examples/audio-observe.ecs)。如果声音刚在读取前响起,后台可能尚未完成匹配;需要更多观察时间时,由脚本作者调整普通 WAIT 或检查流程。 + +### 示例:卸载后重新开始一轮 + +```ecs +AUDIO_LOAD "医疗" +PRINT "第一轮开始,请触发目标声音" +WAIT 8000 +$first = AUDIO_SCORE("医疗") +PRINT "第一轮最高分:" & $first +AUDIO_UNLOAD "医疗" + +AUDIO_LOAD "医疗" +$initial = AUDIO_SCORE("医疗") +PRINT "第二轮初始分数:" & $initial +PRINT "第二轮开始,请再次触发目标声音" +WAIT 8000 +$second = AUDIO_SCORE("医疗") +PRINT "第二轮最高分:" & $second +AUDIO_UNLOAD "医疗" +``` + +完整文件:[audio-reload.ecs](../examples/audio-reload.ecs)。第一轮结果保存在 `$first`,第二轮只统计重新加载后的新音频。 + +输入断线、重连、无效采样或后台积压使该轮识别失效。错误在后续音频调用或脚本正常结束检查时报告;脚本出错或停止均会清理资源。不能用降低阈值解决标签路径或采集错误,详见[常见报错排查](AudioLabels.md#常见报错排查)。 + +CLI 和单片机宿主未提供后台音频能力,会明确拒跑;不含音频调用的脚本保持原有运行条件。缓存上限、时间边界和算法说明见[识别生命周期与限制](AudioLabels.md#ecs-识别生命周期与限制)。 + ## 10. 实用要点 1. **PRINT/ALERT 的参数是"文本片段"**:`$变量`、`"字符串"`、`&` 拼接直接可用; @@ -455,7 +745,7 @@ ENDIF | RAND([max=100]) | 随机整数 [0,max) | INT | ✔ | ✔ | | ARG(i) | 第 i 个脚本参数 | STRING | ✔ | ✔ | | ENV(name) | 环境变量 | STRING | ✔ | ✔ | -| AMIIBO(i) | 切换 Amiibo 槽位 | - | ✔ | ✔ | +| AMIIBO(i) | 切换 Amiibo 槽位(0~19,越界静默) | - | ✔ | ✔ | | LEN(v) | 字符串/数组长度 | INT | ✔ | ✔ | | APPEND(arr, v) | 追加元素(返回新数组) | ARRAY | ✔ | ✔ | | STRING(v) | 转字符串 | STRING | ✔ | ✔ | @@ -468,15 +758,25 @@ ENDIF | OCR(x,y,w,h[,lang]) | 识别文字 | STRING | ✔ | 镜像拒跑 | | OCR_INIT(lang) | 预初始化识别引擎 | BOOL | ✔ | 镜像拒跑 | | OCR_CONF() | 最近识别置信度 | INT | ✔ | — | +| AUDIO_LOAD(name) | 开启标签后台识别 | - | GUI ✔ | 拒跑 | +| AUDIO_SCORE(name) | 本轮累计最高相似度 0~100,向下取整 | INT | GUI ✔ | 拒跑 | +| AUDIO_UNLOAD(name) | 停止识别并释放本轮结果 | - | GUI ✔ | 拒跑 | | FWRITE(h, data) | 写通道(1 日志 / 2 错误) | INT | ✔ | ✔ | | FREAD(h, count) | 读通道(h=0 读一行输入) | STRING | ✔ | 空串 | | FOPEN/FCLOSE/FEOF | 文件句柄(后续版本开放) | - | 预留 | — | | READFILE/WRITEFILE/APPENDFILE/FILE_EXISTS | 整文件读写(后续版本开放) | - | 预留 | — | -| NET_LOAD(path) | 加载 ONNX 模型 | INT | ✔ | 镜像拒跑 | -| NET_RUN(net, in) | 推理,返回输出个数 | INT | ✔ | 镜像拒跑 | +| NET_LOAD(path) | 加载 ONNX 模型 | PTR | ✔ | 镜像拒跑 | +| NET_RUNH(net, tensor) | 推理,返回输出个数 | INT | ✔ | 镜像拒跑 | | NET_OUT(i) | 读第 i 个输出 | DOUBLE | ✔ | 镜像拒跑 | +| NET_ARGMAX(row) | 第 row 行最大概率列下标(末维=类别维) | INT | ✔ | 镜像拒跑 | +| NET_ROWS() | 最近输出行数 | INT | ✔ | 镜像拒跑 | +| NET_NORM(t, mean, std) | 逐通道归一化,返回新张量 | PTR | ✔ | 镜像拒跑 | +| NET_LABELS(path)/NET_LABEL(i) | 标签表加载/按索引取类名 | INT/STRING | ✔ | — | +| PP_DET_FIND(net,x,y,w,h)/DET_COUNT/DET_X/Y/W/H(i) | det 找框(阅读序),返回框数 | INT | ✔ | 镜像拒跑 | +| YOLO_DETECT(net,x,y,w,h,thresh) | YOLO26 端到端检测,返回命中数 | INT | ✔ | 镜像拒跑 | +| YOLO_COUNT()/CLS/SCORE/X1/Y1/X2/Y2(i) | 读最近解码命中(屏幕坐标) | INT/DOUBLE | ✔ | 镜像拒跑 | > - 桌面 = GUI / CLI 运行;单片机 = 编译 .ecx 烧录运行。 > - "镜像拒跑":桌面专属能力在镜像中带有标记,单片机加载期即识别并拒绝运行——确保桌面/单片机脚本各得其所。 > - "静默":调用不产生任何效果、不报错。 -> - "—":该平台未提供此函数。 +> - "—":该平台未提供此函数。 \ No newline at end of file diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d7f0a26a..78560f73 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -8,6 +8,7 @@ - ✅ 如何编写您的第一个自动化脚本 - ✅ 如何控制Switch游戏主机 - ✅ 如何使用图像识别功能 +- ✅ 如何通过 [qq bot](QQ_BOT_SETUP.md) 接收脚本的图文提醒 --- @@ -40,7 +41,7 @@ - 解压到您喜欢的文件夹(比如桌面或D盘) 2. **直接运行** - - 双击 `EasyCon.exe` 就可以使用了 + - 双击 `EasyCon2.Avalonia.<版本号>.exe` 就可以使用了(发布包内会有一个以版本号结尾的可执行文件) - 不需要安装任何其他软件 ### Mac用户 @@ -135,12 +136,19 @@ PRINT "完成!" # 显示完成消息 3. **选择您刚保存的 `test.ecs` 文件** 4. **点击"运行"按钮** -**如果您使用命令行版本**: +**如果您使用命令行版本**(默认 `ezcon` 已加入 PATH;发布包中的 Windows 可执行文件为 `ezcon.exe`,仓库内构建产物名为 `EasyCon2.CLI.exe`): ```bash +# 初始化脚本项目框架(生成 main.ecs / lib/mod1.ecs / models/ / ImgLabel/) +ezcon init my-project +cd my-project + # 在命令行中运行 -./ezcon run test.ecs +ezcon run main.ecs --port mock ``` +`ezcon init` 的路径可省略(默认当前目录);已存在模板文件时默认拒绝写入, +使用 `--force` 只覆盖 `main.ecs` 与 `lib/mod1.ecs`,不会删除任何目录。 + --- ## 🎯 实用示例:自动对话 @@ -298,6 +306,7 @@ WAIT 5000 # 等待5秒 - **[完整脚本语法](./Script.md)** - 学习更强大的脚本功能 - **[示例脚本库](../examples/)** - 查看更多实用脚本 - **[图像识别详解](./Script.md#图像识别)** - 学习视觉自动化 +- **[声音标签接入 ECS](./AudioLabels.md#第一次在-ecs-中使用)** - 按步骤放置标签,用 LOAD/SCORE/UNLOAD 在按键期间识别声音并读取累计最高分(Windows GUI) ### 实际应用 @@ -347,4 +356,4 @@ WAIT 5000 # 等待5秒 --- -**下一步**: 去[示例脚本库](../examples/)看看其他有趣的应用吧! +**下一步**: 去[示例脚本库](../examples/)看看其他有趣的应用吧! \ No newline at end of file diff --git a/docs/MODULE_DESIGN.md b/docs/MODULE_DESIGN.md deleted file mode 100644 index 4e6c8cf8..00000000 --- a/docs/MODULE_DESIGN.md +++ /dev/null @@ -1,773 +0,0 @@ -# 伊机控 模块详细设计文档 - -## 1. Device模块详细设计 - -### 1.1 模块概述 -Device模块负责处理与伊机控下位机的通讯功能,提供抽象接口给上层应用。 - -### 1.2 核心类设计 - -#### NintendoSwitch类 -```csharp -public class NintendoSwitch -{ - public enum ConnectResult - { - Success, - PortNotFound, - AccessDenied, - Timeout, - DeviceNotResponding - } - - public event Action StatusChanged; - public ConnectResult TryConnect(string port); - public void Disconnect(); - public bool SendCommand(byte[] command); - public byte[] ReceiveData(); -} -``` - -**主要职责**: -- 管理与任天堂Switch下位机的物理连接 -- 处理命令发送和数据接收 -- 监控连接状态变化 - -#### 连接类型支持 - -1. **串口连接 (Serial)** - - 支持标准串口通信 - - 可配置波特率、数据位、停止位 - - 自动检测可用串口 - -2. **蓝牙连接 (Bluetooth)** - - 支持蓝牙串口配置文件 - - 设备发现和配对 - - 连接状态监控 - -3. **网络连接 (TCP/IP)** - - 支持WiFi连接 - - TCP套接字通信 - - 心跳检测机制 - -### 1.3 通信协议设计 - -#### Modbus协议实现 -```csharp -public class ModbusProtocol -{ - public byte[] BuildReadRequest(byte slaveId, ushort startAddress, ushort count); - public byte[] BuildWriteRequest(byte slaveId, ushort startAddress, byte[] data); - public ModbusResponse ParseResponse(byte[] response); -} -``` - -#### 自定义串口协议 -- 帧头标识:`0xAA 0x55` -- 数据长度:2字节 -- 校验码:CRC16 -- 帧尾标识:`0x0D 0x0A` - -### 1.4 设备发现机制 - -#### 自动设备发现 -```csharp -public class DeviceDiscovery -{ - public string[] FindSerialDevices(); - public string[] FindBluetoothDevices(); - public string[] FindNetworkDevices(); - public bool TestDeviceConnection(string devicePath); -} -``` - -### 1.5 错误处理与恢复 - -#### 连接异常处理 -- 连接超时自动重试 -- 异常断开自动重连 -- 错误日志记录 - -#### 状态监控 -- 心跳检测机制 -- 连接质量监控 -- 设备响应时间统计 - ---- - -## 2. Capture模块详细设计 - -### 2.1 模块概述 -Capture模块实现UI无关的图像处理功能,基于OpenCV库实现高效的图像采集和处理。 - -### 2.2 核心类设计 - -#### OpenCVCapture类 -```csharp -public class OpenCVCapture : IDisposable -{ - public int Width { get; } - public int Height { get; } - public int FPS { get; } - - public bool Open(string sourceName); - public Mat CaptureFrame(); - public void Release(); - public bool IsConnected { get; } -} -``` - -**主要职责**: -- 管理视频采集设备 -- 提供帧数据获取接口 -- 设备生命周期管理 - -#### ECCapture静态类 -```csharp -public static class ECCapture -{ - public static string[] GetCaptureCamera(); - public static IEnumerable<(string, int)> GetCaptureTypes(); - public static Mat CaptureFrame(int cameraIndex); - public static bool CaptureToFile(int cameraIndex, string filename); -} -``` - -### 2.3 图像识别功能 - -#### 模板匹配 -```csharp -public class TemplateMatcher -{ - public MatchResult FindTemplate(Mat source, Mat template, double threshold = 0.8); - public List FindMultipleTemplates(Mat source, Mat template, double threshold = 0.8); - public MatchResult FindTemplateWithRotation(Mat source, Mat template, double threshold, float maxRotation = 360f); -} - -public struct MatchResult -{ - public bool Found; - public Point Location; - public double Confidence; - public Rect BoundingBox; -} -``` - -#### OCR识别 -```csharp -public class OCREngine -{ - public OCREngine(string tessDataPath, string lang = "eng"); - public string RecognizeText(Mat image); - public List RecognizeTextWithRegions(Mat image); - public void SetWhitelist(string characters); - public void SetBlacklist(string characters); -} - -public class TextRegion -{ - public Rect BoundingBox; - public string Text; - public double Confidence; -} -``` - -#### 图像预处理 -```csharp -public class ImagePreprocessor -{ - public static Mat ConvertToGray(Mat source); - public static Mat ApplyThreshold(Mat source, ThresholdType type, double threshold); - public static Mat ApplyBlur(Mat source, BlurType type, int kernelSize); - public static Mat EnhanceContrast(Mat source, double alpha, double beta); - public static Mat RemoveNoise(Mat source, int kernelSize); -} -``` - -### 2.4 性能优化 - -#### 图像缓存机制 -```csharp -public class ImageCache -{ - private readonly Dictionary _cache = new(); - private readonly int _maxCacheSize; - - public Mat GetOrLoad(string imagePath); - public void ClearCache(); - public void PreloadImages(string[] imagePaths); -} -``` - -#### 异步图像处理 -```csharp -public class AsyncImageProcessor -{ - public Task CaptureFrameAsync(CancellationToken cancellationToken = default); - public Task FindTemplateAsync(Mat source, Mat template, CancellationToken cancellationToken = default); -} -``` - -### 2.5 图像标签系统 - -#### ImgLabel类 -```csharp -public class ImgLabel -{ - public string Name { get; set; } - public string ImagePath { get; set; } - public Mat Template { get; set; } - public Dictionary Metadata { get; set; } - - public void Load(string labelPath); - public void Save(string labelPath); - public bool Validate(); -} -``` - -#### 标签管理器 -```csharp -public class ImgLabelManager -{ - public ImgLabel LoadLabel(string labelPath); - public List LoadLabelsFromDirectory(string directory); - public void CreateLabel(string name, string imagePath, Dictionary metadata); - public void UpdateLabel(string labelPath, ImgLabel updatedLabel); -} -``` - ---- - -## 3. Script模块详细设计 - -### 3.1 模块概述 -Script模块实现ECS脚本的解析和执行,采用编译器设计原理实现完整的脚本语言支持。编译管线为 `SourceText → Lexer → Parser → Binder → SSA → Evaluator`。 - -**执行后端**(VM2,详见 `docs/VM2.md`):SSA 之后新增字节码编译后端——`SsaProgram → BytecodeEncoder → EcxLinker → ECX 镜像 → 纯 C 虚拟机`。`SsaEvaluator` 保留为回归基准;`Assembly/`(VM1 单片机字节码)已废弃,由 VM2 取代。 - -### 3.2 目录结构 - -``` -EasyCon.Script/ -├── Text/ # 源文本处理 -│ ├── SourceText.cs # 源文本表示 -│ ├── TextLine.cs # 文本行 -│ ├── SourceSpan.cs # 文本范围 -│ └── TextLocation.cs # 文本位置 -├── Syntax/ # 词法分析与语法分析 -│ ├── Lexer.cs # 词法分析器(partial) -│ ├── Parser.cs # 语法分析器(partial) -│ ├── Parser.Fn.cs # 函数解析(Parser partial) -│ ├── SyntaxTree.cs # 语法树 -│ ├── SyntaxNode.cs # 语法节点基类 -│ ├── Token.cs # Token与TokenType定义 -│ ├── TokenFacts.cs # 关键字与Token查询 -│ ├── Visitor.cs # 访问者模式 -│ └── Formatter.cs # 代码格式化 -├── Parsing/ # 语法节点定义 -│ ├── Statement.cs # 语句基类 -│ ├── ExprBase.cs # 表达式基类 -│ ├── IfStmt.cs # if语句 -│ ├── ForStmt.cs # for循环 -│ ├── FuncStmt.cs # 函数声明 -│ ├── AssignmentStmt.cs # 赋值语句 -│ ├── ConstantDeclStmt.cs # 常量声明 -│ ├── KeyActionStmt.cs # 按键动作 -│ ├── SerialPrint.cs # 串口打印 -│ └── Wait.cs # 等待指令 -├── Binding/ # 绑定层(语义分析) -│ ├── Binder.cs # 绑定器(类型检查+作用域) -│ ├── BoundProgram.cs # 绑定后的程序 -│ ├── BoundScope.cs # 作用域管理 -│ ├── BoundExpr.cs # 绑定表达式节点 -│ ├── BoundStmt.cs # 绑定语句节点 -│ ├── BoundFactory.cs # 绑定节点工厂 -│ ├── BoundNodeKind.cs # 绑定节点类型枚举 -│ ├── BoundNodePrinter.cs # 绑定树打印 -│ ├── BoundBinaryOperator.cs # 二元运算符 -│ ├── BoundUnaryOperator.cs # 一元运算符 -│ ├── BuildinFuncs.cs # 内建函数 -│ ├── ControlFlowGraph.cs # 控制流图 -│ ├── LinearScanRegisterAllocator.cs # 寄存器分配 -│ └── Lowerer.cs # IR降低/优化 -├── Symbols/ # 符号系统 -│ ├── Symbol.cs # 符号层次(Symbol→VariableSymbol→Global/Local/Param, FunctionSymbol) -│ └── BoundValue.cs # 运行时值类型(Value, ScriptType) -├── Assembly/ # 单片机字节码生成 -│ ├── Assembler.cs # 汇编器 -│ ├── HexWriter.cs # HEX格式写入 -│ ├── Instruction.cs # 指令基类 -│ └── Instructions/ # 各指令实现(AsmKey, AsmStick, AsmFor等) -├── IO/ # IO辅助 -│ ├── CustomSleep.cs # 高精度延时 -│ └── TextWriterExtensions.cs # 文本输出扩展 -├── Compilation.cs # 编译入口 -├── Evaluator.cs # 树解释执行器 -├── Diagnostic.cs # 诊断信息 -├── DiagnosticBag.cs # 诊断集合 -├── IOutputAdapter.cs # 输出适配器接口 -├── ICGamePad.cs # 手柄控制接口+GamePadKey枚举 -└── NSKeys.cs # NS按键定义 -``` - -### 3.3 编译管线 - -#### Compilation类 -```csharp -public sealed class Compilation -{ - public static Compilation Create(SyntaxTree syntaxTrees); - public ImmutableArray Compile(ImmutableHashSet? extVars); - public EvaluationResult Evaluate(IIoAdapter ioAdapter, ICGamePad pad, - ImmutableDictionary> externalGetters, CancellationToken token); -} -``` - -**管线流程**: `SourceText` → `Lexer.Tokenize()` → `Parser` → `SyntaxTree` → `Binder.BindProgram()` → `BoundProgram` → `Evaluator` - -### 3.4 词法分析器 - -#### Lexer类(Syntax/Lexer.cs) -```csharp -internal sealed partial class Lexer(SyntaxTree syntaxTree) -{ - public DiagnosticBag Diagnostics { get; } - public ImmutableArray Tokenize(); -} -``` - -#### TokenType枚举(Syntax/Token.cs) -```csharp -public enum TokenType -{ - BadToken, - // 空白与注释 - NEWLINE, WhitespaceTrivia, COMMENT, - // 标识符与字面量 - IDENT, INT, Number, STRING, - // 特殊变量 - CONST, // _XXX - VAR, // $XXX - EX_VAR, // @XXX - // 算术运算符 - ASSIGN, ADD, SUB, MUL, DIV, SlashI, MOD, - // 位运算符 - BitAnd, BitOr, XOR, SHL, SHR, BitNot, - // 复合赋值 - ADD_ASSIGN, SUB_ASSIGN, MUL_ASSIGN, DIV_ASSIGN, - SlashI_ASSIGN, MOD_ASSIGN, BitAnd_ASSIGN, BitOr_ASSIGN, - XOR_ASSIGN, SHL_ASSIGN, SHR_ASSIGN, - // 比较运算符 - EQL, NEQ, LSS, GTR, LEQ, GEQ, - // 逻辑运算符 - AND, OR, NOT, - // 分隔符 - LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, - COMMA, COLON, SEMICOLON, - // 关键字 - IF, ELSE, FOR, WHILE, FUNC, RETURN, BREAK, CONTINUE, - // 特殊 - EOF -} -``` - -### 3.5 语法分析器 - -#### Parser类(Syntax/Parser.cs) -```csharp -internal sealed partial class Parser -{ - public Parser(SyntaxTree syntaxTree); - public DiagnosticBag Diagnostics { get; } -} -``` - -Parser为partial类,函数解析逻辑在 `Parser.Fn.cs` 中。解析结果为 `SyntaxTree`,其根节点为 `CompicationUnit`。 - -### 3.6 绑定器 - -#### Binder类(Binding/Binder.cs) -```csharp -internal sealed class Binder -{ - public static BoundProgram BindProgram(SyntaxTree syntaxTree, ImmutableHashSet? extVars); - public DiagnosticBag Diagnostics { get; } -} -``` - -Binder将语法树绑定到类型化的Bound树,同时完成: -- **类型检查**: 确保表达式类型一致 -- **作用域管理**: 通过 `BoundScope` 管理变量作用域 -- **符号解析**: 解析变量、函数引用到 `Symbol` 对象 - -#### Lowerer(Binding/Lowerer.cs) -将高级绑定节点降低为更基础的IR,包含优化逻辑。 - -#### ControlFlowGraph(Binding/ControlFlowGraph.cs) -构建控制流图,用于分析和优化。 - -#### LinearScanRegisterAllocator(Binding/LinearScanRegisterAllocator.cs) -线性扫描寄存器分配器,为单片机字节码生成优化寄存器使用。 - -### 3.7 符号系统 - -#### Symbol层次(Symbols/Symbol.cs) -```csharp -abstract class Symbol(string name) -{ - public readonly string Name; -} - -abstract class VariableSymbol(string name, bool isReadOnly, ScriptType type) : Symbol(name) -{ - public readonly ScriptType Type; - public readonly bool IsReadOnly; -} - -sealed class GlobalVariableSymbol : VariableSymbol -class LocalVariableSymbol : VariableSymbol -sealed class ParamSymbol : LocalVariableSymbol -sealed class FunctionSymbol : Symbol -``` - -### 3.8 执行器 - -#### Evaluator类 -```csharp -internal sealed class Evaluator -{ - private readonly BoundProgram _program; - private readonly Dictionary _globals; - private readonly Stack> _locals; - private readonly Dictionary _functions; -} -``` - -Evaluator直接遍历绑定树(BoundProgram)执行脚本,不经过虚拟机/字节码中间层。 - -> **VM2 后端注意**:现行执行路径为 `SsaEvaluator`(SSA 解释器)与实验性 JIT;VM2 字节码管线(`Bytecode/` + 纯 C 虚拟机)为新的官方后端,规格见 `docs/VM2.md`。 - -### 3.9 输出接口 - -#### IIoAdapter接口 -```csharp -public interface IIoAdapter -{ - // 输出方法 - void Print(string message, bool newline); - void Alert(string message); - - // 输入方法 - string ReadLine(); - bool TryReadLine(out string line); -} -``` - -#### ICGamePad接口 -```csharp -public interface ICGamePad -{ - abstract DelayType DelayMethod { get; } - void ClickButtons(GamePadKey key, int duration, CancellationToken token); - void PressButtons(GamePadKey key); - void ReleaseButtons(GamePadKey key); - void ClickStick(GamePadKey key, byte x, byte y, int duration, CancellationToken token); - void SetStick(GamePadKey key, byte x, byte y); - void ChangeAmiibo(uint index); -} -``` - -### 3.10 单片机字节码生成 - -#### Assembler(Assembly/) -**已废弃**:`Assembler.Assemble` 直接抛出"此版本暂不支持编译",VM1 的 2 字节指令集无法承载现代语言特性。取代者为 VM2 字节码管线(`Bytecode/` 目录 + `docs/VM2.md`)。 - ---- - -## 4. VPad模块详细设计 - -### 4.1 模块概述 -VPad模块实现虚拟手柄UI,通过按键绑定将键盘操作映射到伊机控下位机。 - -### 4.2 核心类设计 - -#### IControllerAdapter接口 -```csharp -public interface IControllerAdapter -{ - string Name { get; } - ControllerType Type { get; } - bool IsConnected { get; } - - event Action OnInput; - bool Connect(); - void Disconnect(); - ControllerState GetCurrentState(); -} - -public enum ControllerType -{ - Keyboard, - Gamepad, - VirtualController -} - -public class ControllerEvent -{ - public ControllerInputType InputType { get; } - public int Code { get; } - public float Value { get; } - public DateTime Timestamp { get; } -} -``` - -#### JCPainter类 -```csharp -public class JCPainter -{ - public void DrawJoyCon(DrawingContext context, Point position, JoyConColor color, ControllerState state); - public void DrawProController(DrawingContext context, Point position, ProControllerColor color, ControllerState state); - public void DrawButton(DrawingContext context, ButtonType buttonType, Point position, bool isPressed); - public void DrawStick(DrawingContext context, Point position, Point stickPosition, StickColor color); - - private void DrawControllerBody(DrawingContext context, Rect bounds, IBrush brush, IPen pen); - private void DrawButtonHighlight(DrawingContext context, Rect bounds, bool isPressed); -} - -public enum ButtonType -{ - A, B, X, Y, - L, R, ZL, ZR, - Plus, Minus, Home, Capture, - DPadUp, DPadDown, DPadLeft, DPadRight, - LeftStick, RightStick -} -``` - -### 4.3 按键映射系统 - -#### KeyMappingProfile类 -```csharp -public class KeyMappingProfile -{ - public string Name { get; set; } - public Dictionary Mappings { get; set; } - - public ControllerAction GetAction(KeyCode key); - public void SetMapping(KeyCode key, ControllerAction action); - public void RemoveMapping(KeyCode key); - public void ClearMappings(); - public void Save(string filePath); - public static KeyMappingProfile Load(string filePath); -} - -public class ControllerAction -{ - public ActionType Type { get; } - public NSKeys Button { get; } - public StickType Stick { get; } - public Point StickValue { get; } - public float Duration { get; } -} - -public enum ActionType -{ - ButtonPress, - ButtonRelease, - StickMove, - StickReset, - Combination -} -``` - -#### KeyMappingManager类 -```csharp -public class KeyMappingManager -{ - public KeyMappingManager(); - public List GetProfiles(); - public KeyMappingProfile CreateProfile(string name); - public void DeleteProfile(string name); - public KeyMappingProfile LoadProfile(string name); - public void SaveProfile(KeyMappingProfile profile); - public void SetActiveProfile(KeyMappingProfile profile); - - public ControllerAction? MapKey(KeyCode key); - public void RegisterMapping(KeyCode key, ControllerAction action); - public void UnregisterMapping(KeyCode key); -} -``` - -### 4.4 手柄状态管理 - -#### ControllerState类 -```csharp -public class ControllerState -{ - public bool A { get; set; } - public bool B { get; set; } - public bool X { get; set; } - public bool Y { get; set; } - public bool L { get; set; } - public bool R { get; set; } - public bool ZL { get; set; } - public bool ZR { get; set; } - public bool Plus { get; set; } - public bool Minus { get; set; } - public bool Home { get; set; } - public bool Capture { get; set; } - - public bool DPadUp { get; set; } - public bool DPadDown { get; set; } - public bool DPadLeft { get; set; } - public bool DPadRight { get; set; } - - public Point LeftStick { get; set; } - public Point RightStick { get; set; } - - public bool[] GetButtonStates(); - public void SetButtonState(ButtonType button, bool state); - public bool GetButtonState(ButtonType button); -} -``` - -### 4.5 输入处理 - -#### InputHandler类 -```csharp -public class InputHandler -{ - public event Action? OnAction; - - public void HandleKeyDown(KeyEvent keyEvent); - public void HandleKeyUp(KeyEvent keyEvent); - public void HandleGamepadButton(GamepadButtonEvent buttonEvent); - public void HandleGamepadStick(GamepadStickEvent stickEvent); - - private ControllerAction? MapToAction(KeyEvent keyEvent); - private void ProcessAction(ControllerAction action); -} -``` - -### 4.6 配置文件格式 - -#### JSON配置示例 -```json -{ - "name": "默认配置", - "description": "默认的按键映射配置", - "version": "1.0", - "mappings": { - "KeyA": { - "type": "ButtonPress", - "button": "A", - "duration": 50 - }, - "KeyB": { - "type": "ButtonPress", - "button": "B", - "duration": 50 - }, - "KeyW": { - "type": "StickMove", - "stick": "LeftStick", - "x": 0.0, - "y": -1.0 - } - } -} -``` - ---- - -## 5. 跨平台支持 - -### 5.1 平台抽象层 - -#### PlatformServices类 -```csharp -public static class PlatformServices -{ - public static IPlatformService Current { get; } - - public static void SetPlatform(IPlatformService platform); -} - -public interface IPlatformService -{ - PlatformType Platform { get; } - string[] GetSerialPorts(); - string[] GetBluetoothDevices(); - INativeWindow CreateNativeWindow(); - IGamepadManager GetGamepadManager(); -} - -public enum PlatformType -{ - Windows, - Linux, - MacOS, - Android -} -``` - -### 5.2 SDL2集成 - -#### SDL2GamepadManager类 -```csharp -public class SDL2GamepadManager : IGamepadManager -{ - public event Action OnGamepadEvent; - - public void Initialize(); - public void Shutdown(); - public List GetConnectedGamepads(); - public IGamepad? GetGamepad(int index); -} -``` - ---- - -## 6. 测试与调试 - -### 6.1 单元测试框架 - -#### MockDevice类 -```csharp -public class MockDevice : IDeviceService -{ - public List CallLog { get; } - public Queue ResponseQueue { get; set; } - - public void SetupMockResponses(params byte[][] responses); - public void VerifyCall(string expectedCall); - public void VerifyNoCalls(); -} -``` - -### 6.2 性能监控 - -#### PerformanceMonitor类 -```csharp -public class PerformanceMonitor -{ - public void StartMonitoring(string operationName); - public void StopMonitoring(string operationName); - public PerformanceReport GetReport(); - - public void LogMemoryUsage(); - public void LogCPUUsage(); -} - -public class PerformanceReport -{ - public Dictionary OperationTimes { get; } - public Dictionary MemoryUsage { get; } -} -``` - ---- - -**文档版本**: 1.0 -**最后更新**: 2026年4月16日 -**维护者**: EasyCon2开发团队 \ No newline at end of file diff --git "a/docs/Maa-EasyCon-dev\347\273\264\346\212\244\344\270\216v2\350\256\276\350\256\241\345\244\247\347\272\262-20261020.md" "b/docs/Maa-EasyCon-dev\347\273\264\346\212\244\344\270\216v2\350\256\276\350\256\241\345\244\247\347\272\262-20261020.md" new file mode 100644 index 00000000..afb339dd --- /dev/null +++ "b/docs/Maa-EasyCon-dev\347\273\264\346\212\244\344\270\216v2\350\256\276\350\256\241\345\244\247\347\272\262-20261020.md" @@ -0,0 +1,248 @@ +# EasyCon dev 维护边界与 Maa v2 设计大纲 + +> **状态:部分实施(dev 两项近期修正已实施;Maa v2 未实施)** +> 本文是对 `docs/`、当前源码和既有 Maa 方案的合并核对。它规定 dev 继续维护的边界,并把新的 Maa 自动化设计放入 v2;不会把本文中的 v2 目标误当成当前代码已经完成。第 3 节的两个 dev 修正已在 2026-10-24 落地,下面的验收矩阵仍作为回归约束。 +> +> 基线:EasyCon dev 当前结构,桌面端为 PC,单片机端以 C VM 为实际执行内核。PC 上运行 C VM 只是 MCU 内核的模拟和对拍工具,不算第三个正式执行端。 + +## 1. 结论和优先级 + +开发者的取舍可以归纳成三句话: + +1. **dev 继续维护现在的 ECS、模块编译、能力端口、Flow、GUI/CLI 和 Maa 适配层。** 不做现有 ECS 到 Maa Pipeline 的整体改写,也不以新 Pipeline 取代现有 Flow。 +2. **Maa 的新设计进入 v2。** v2 可以使用 Maa 的 Tasker/Pipeline 骨架,也可以把完整 ECS 暴露为一个 `EasyCon.RunEcs` CustomAction;它们都是编排层能力,不改变已有 ECS 文件。 +3. **本轮 dev 已闭合两个明确缺口:** 编译出现错误时形成硬闸门,阻止模块链接和后续产物;GUI 的“编译固件”复用 Distributable 编译链生成 ECX,再交给现有烧录/固件封装流程。 + +因此,之前方案中“立即重建完整 PC 高层执行器、同步重建手柄和采集模块、把全部 Flow 迁到 Maa Pipeline”的内容,统一降级为 v2 设计和研究项。它们不能再作为 dev 的前置条件,也不能阻塞现有脚本、GUI 和 CLI 的维护。 + +## 2. 当前实现核对 + +### 2.1 已经成立的基础能力 + +| 能力 | 当前落点 | 结论 | +|---|---|---| +| ECS 语言 | `EasyCon.Script`、`docs/Script.md`、`docs/Functions.md` | 已是产品主入口。按键、`WAIT`、`@标签`、FRAME/ROI/OCR、函数返回值、数组、结构体和 FFI 语义继续保留。 | +| 统一编译链 | `Compilation` → `ProjectCompiler` → `ModuleCompilePipeline` → `EcxPipeline.Link` | 当前 ECX1/v3 平铺产物是 PC 与 MCU 共用的字节码格式;不要另起一套 ECS 编译器。 | +| PC/MCU 关系 | `EasyCon.Core.Runner.EcxVm`、`src/EasyCon.Vm/native/ecs-vm` | 正式对拍对象是 PC 桌面执行和 MCU C VM。PC 直接执行高层 IR 属于 v2 的优化方向;MCU 仍以 C VM 执行 ECX。 | +| 手柄输入 | `EasyCon.Device`、`PadSession`、`MaaControllerBinding` | 手柄模块拥有串口会话和端口选择。Maa CustomController 只做适配,不拥有串口生命周期。 | +| 视频采集 | `EasyCon.Capture`、`FrameHub`、`FrameStore` | 采集源拥有来源选择和单生产者。识别、脚本 FRAME/ROI、OCR 取图、监视器和预览应从同一帧源取数据。 | +| 音频采集和标签 | `WasapiAudioCapture`、`AudioMatchMonitor`、`AudioLabelService`、`AUDIO_LOAD/SCORE/UNLOAD` | “未来扩展音频基础能力”已经部分落地。v2 的工作是把音频纳入统一信息节点和来源契约,不应重新规划一遍采集和标签基础功能。 | +| Flow | `FlowNodeCatalog`、`FlowNodeRuntime`、`FlowExecutor`、`FlowServiceState` | 现有 Flow 已有控制流/数据边、看门狗、停止、慢感知和 `script.run`。它是可维护的现状,不是待删除的 v1。 | +| Maa 标签试点 | `MaaLabelConverter`、Maa OCR/视觉适配和资源预检 | 现有 ECS 标签仍是源格式;转换产物是后端资源,不要求用户改标签或改脚本。 | + +### 2.2 当前 Flow 已经覆盖的范围 + +现有节点目录与运行时已经形成信息、决策、执行三层的雏形: + +- 信息:`capture.frame`、`image.load`、`ocr.text`、`vision.changed`。 +- 决策:`compare`、`text.contains`、`state.step`。 +- 执行:`wait`、`pad.key`、`pad.sequence`、`script.run`。 + +`vision.label`、完整的 `ocr.screen`、动态 `switch`、`ai.agent` 和子图仍是挂账项。它们应在 v2 设计接口后逐个补齐,不应在 dev 中通过复制另一套 Pipeline 运行时来解决。 + +### 2.3 手柄和采集源的独立性 + +这两个对象必须在模型、状态和界面上保持平级: + +```text +PadModule + ├─ 枚举/选择串口或其他控制端口 + ├─ 建立 PadSession + └─ 对外提供 IPadInput + +CaptureModule + ├─ 枚举/选择摄像头、采集卡、OBS/OpenCV 来源 + ├─ 建立 CaptureSession / FrameHub + └─ 对外提供 ICaptureSource + +MaaAdapter + ├─ CustomController → IPadInput + └─ 截图/识别回调 → ICaptureSource / FrameHub +``` + +选择手柄端口不应改变采集源,选择采集源也不应重置手柄。连接状态、断开、重连、错误和释放各自归属本模块。Maa 适配器只取得运行期借用的端口,不能把“连接设备”做成一个同时控制两者的总开关。 + +同样的规则适用于音频:音频端点独立选择,不能因为切换视频源而重新选择麦克风或 WASAPI 端点。 + +### 2.4 1080p 是现有标签的几何事实 + +现有标签的内部坐标按 **1920×1080** 解释。历史调研中出现的 720p 表述已被纠正;将这些 ROI 当成 1280×720 会改变标签含义。转换器、`FrameHub` 的规范化和 v2 Pipeline 的 ROI 数据契约都必须以 1920×1080 为基准,再由采集后端统一缩放或拒绝不兼容的来源。 + +## 3. dev 近期只做的两个修正(已实施) + +2026-10-24 已完成以下闭环:编译器在有错误级诊断时不进入链接并清空可运行镜像;链接异常转换为结构化诊断并稳定去重;CLI 与 GUI 共用 `EcxExportService` 生成 Distributable ECX;GUI 编译失败时不返回产物,固件流程继续使用临时文件原子替换。新增的脚本编译、ECX 导出和 GUI 构建测试覆盖这些不变量。 + +### 3.1 编译错误形成硬闸门 + +实施前源码已经有一部分短路逻辑: + +- `ProjectCompiler.CompileCore` 在主树语法错误或模块图错误时返回; +- `ModuleCompilePipeline.CompileAll` 在模块失败时返回 `false`; +- `ProjectCompiler` 的 `EcxPipeline.Link` 位于 `CompileAll` 成功之后。 + +本轮已把“不链接”做成可测试的不变量,并按以下规则处理诊断重复: + +1. **进入链接前只检查错误级诊断。** `ProjectCompiler` 在链接前再次检查 `Diagnostics.Any(d => d.IsError)`;存在错误时保持 `Image = null`、`Success = false`,不调用 `EcxPipeline.Link`,不写 ECX,不替换上一次有效输出。 +2. **链接异常转换成唯一的链接诊断。** 结构体布局不一致、导入索引异常等真正的链接错误不能逃逸成未分类异常;转换为 `phase=link` 的结构化诊断,结果仍为失败。 +3. **区分根错误与级联错误。** 解析、绑定、SSA、编码、缓存错误是根诊断;依赖失败只产生一条“依赖模块失败,已跳过”的摘要,并带 `relatedTo` 根诊断。不能让一个语法错误继续触发多轮未定义符号、入口缺失和链接错误。 +4. **在诊断袋中去重,而不是只在 GUI 端截断。** 去重键至少包括 `phase + code + file + span + message`。CLI、GUI、LSP 和测试读取同一份去重后的诊断。现有 GUI 的 `DistinctBy(...).Take(8)`只能算显示兜底,不能代替编译器的错误模型。 +5. **警告继续允许链接。** `MD_AMBIGUOUS_EXPORT` 等警告不应被误判为错误;有警告但无错误时仍生成正常 ECX。 + +期望结果是:一行语法错误显示一条根错误,必要时附一条依赖跳过摘要;不会因为同一根因再打印四五条“链接失败”。独立文件中的独立错误仍应保留,不能为了安静而只保留第一条。 + +建议的最小测试矩阵: + +| 场景 | 期望 | +|---|---| +| 主脚本一处语法错误 | `Image = null`,链接器未调用,无 ECX 输出;诊断按文件/行/列稳定且无重复。 | +| `lib/a.ecs` 绑定错误,`main.ecs` 依赖它 | 根错误一条,main 只显示一条依赖跳过摘要;不出现入口缺失风暴。 | +| 两个无关模块各有错误 | 两个根错误都保留,各自只出现一次。 | +| 只有警告 | 正常链接并生成 ECX,警告仍可见。 | +| 链接阶段真实错误 | 只生成一条链接错误,不产生部分 ECX,不污染上一次有效文件。 | +| 修复后再次编译 | 新的成功结果完全替换失败状态,缓存错误侧车不被当成成功产物。 | + +### 3.2 GUI 的“编译固件”连接到生成 ECX + +实施前调用链已经指出缺口: + +```text +FlashFirmwareViewModel + ├─ CompileFlashAsync / GenerateFirmwareAsync + ├─ IScriptService.CompileAsync(editorText, scriptPath) + └─ IScriptService.BuildAsync(...) + └─ 当前抛出 NotImplementedException +``` + +这不是要恢复一个旧的 v1 `Assemble`;正确做法是复用当前 dev 的统一编译链: + +1. `CompileAsync` 保存本次编辑器源码快照、入口路径和标签外部变量集合。未保存编辑器内容也要按当前文本编译;入口路径仅用于 `IMPORT`、`lib/` 和标签目录解析。 +2. 新增 Core 侧的 `EcxExportService`(名称可以调整),唯一职责是以 `ScriptCompileProfiles.Distributable` 编译项目并调用 `EcsContainer.WriteImage`。CLI `compile` 和 GUI 都调用它,不让 GUI 复制模块编译逻辑。 +3. `BuildAsync` 改为调用该导出服务,删除“v1 Assemble 已移除”的用户可见错误。已有 `CompileFlashAsync` 和 `GenerateFirmwareAsync` 可以继续保留,只替换其字节码来源。 +4. 生成失败时不生成、不覆盖 ECX,也不进入 HEX 封装或串口烧录。错误显示编译诊断或导出诊断,不再显示已经删除的架构名。 +5. 成功时先写临时文件再原子替换;生成的 ECX 字节必须与 CLI `compile` 对同一源码、同一标签集合生成的字节一致。`stripDebug` 应成为明确选项,不再用难以理解的 `autoRun` 布尔值承载两个语义。 +6. “生成固件”仍可把同一份 ECX 作为脚本段写入现有固件模板;“编译并烧录”仍调用现有 `Flash` 协议。该修正不等于本次就实现 MCU 能力查询、容量预检或新的烧录协议,那些仍属于 `McuBytecodeDelivery.md` 的后续阶段。 + +GUI 验收应至少检查: + +- 编辑器内容有未保存修改时,生成内容来自编辑器而不是磁盘旧文件; +- 同一文件由 CLI 和 GUI 生成的 ECX 容器可逐字节比较(若调试段策略不同,先固定同一 `stripDebug` 选项); +- 编译错误时不会留下新 ECX、HEX 或烧录动作; +- 已连接/未连接手柄只影响烧录动作,不影响离线生成 ECX; +- 日志使用“编译失败:文件:行:列: 原因”或“ECX 导出失败:原因”,不再出现“v1 已删除”。 + +## 4. Maa v2 的设计边界 + +### 4.1 v2 目标 + +v2 提供 EasyCon 特化的自动化工作流:用通用节点组合“信息采集 → 决策 → 执行”,同时允许现有 ECS 作为一个完整动作节点。Maa 负责成熟的任务编排、资源和控制器扩展点;EasyCon 负责 ECS 语义、设备协议、来源选择、标签兼容和脚本运行上下文。 + +v2 不要求用户把现有脚本改写成 Pipeline。推荐的三种入口是: + +```text +现有 ECS 文件 ───────────────► EasyCon ECS 宿主 +Maa Pipeline + EasyCon.RunEcs ► Maa Tasker / Pipeline 执行 +EasyCon Flow ────────────────► 现有 FlowExecutor(兼容入口) +``` + +三条入口共用能力端口、FrameHub、音频源、标签转换和停止/报告模型;不复制三套设备连接逻辑。 + +### 4.2 Maa 适配层 + +| v2 组件 | 责任 | 不负责的事情 | +|---|---|---| +| `EasyConCustomController` | 把 Maa 的按键、摇杆、截图请求转为 `IPadInput` 和 `ICaptureSource` 调用 | 不枚举端口、不打开采集卡、不拥有连接生命周期 | +| `EasyConCustomRecognition` | 对需要保持旧 OpenCV 语义的标签执行适配算法,并把结果换算为旧分值 | 不改变 ECS 阈值,不另建隐藏的图像源 | +| `EasyConMaaOcr` | 把标准 Maa OCR 资源、ROI 和结果映射为 EasyCon OCR 返回值 | 不把模板图像误当成 OCR,不改 `OCR_CONF` 语义 | +| `EasyConRunEcs` | 在当前运行会话里执行一段 ECS 或一个 ECS 文件,借用既有能力租约 | 不启动第二个设备连接,不另建独立 GUI/CLI 进程 | +| `EasyConResourceGeneration` | 把 `.IL`/标签、ROI、OCR 配置转换为版本化 Maa 资源并预检 | 不修改源标签,不把生成目录当成用户源文件 | + +Maa 版本和 binding 版本必须固定。不能因为某个预览版本存在接口,就把它写成 EasyCon 的稳定依赖;缺 API 时先记录适配缺口,再决定升级或在 Maa 目录添加薄封装。 + +### 4.3 标签与图像 + +旧标签格式继续是源格式,转换结果是派生资源: + +```text +Legacy .IL / 标签 + ├─ 模板图像 + ROI + 算法参数 → Maa TemplateMatch / CustomRecognition + └─ 文字标签 + ROI + 语言/模型 → Maa OCR 资源 +``` + +转换器需要保存源摘要、转换器版本、资源版本和 1920×1080 坐标。运行时只加载与当前资源代次一致的索引;转换失败要在运行前报告,不能悄悄回退到另一套图像后端。 + +模板识别、OCR、`FRAME`、`ROI`、监视器全部从同一个 `FrameHub` 读取最新帧或租约帧。Maa 的截图回调只是这个帧源的适配出口,不允许 Maa 自己再打开一个采集设备。 + +### 4.4 工作流节点 + +v2 节点目录按信息、决策、执行分组,但数据边和执行边继续分开: + +| 层 | 首批节点 | 说明 | +|---|---|---| +| 信息 | `capture.frame`、`roi.crop`、`ocr.text`、`vision.label`、`audio.match`、`script.value` | 输出图像、文字、分数、音频匹配分数或 ECS 返回值。 | +| 决策 | `compare`、`text.contains`、`script.predicate`、`agent.analyze`、`jev.analyze` | `agent`/`jev` 必须有超时、取消、输入输出 schema 和只读/可执行权限;“jev”在实现前必须补齐其算法或 provider 定义,不能擅自当作某个既有标准。 | +| 执行 | `pad.key`、`pad.sequence`、鼠标键盘、模拟器、自定义执行器、`run.ecs`、`wait` | 执行节点接受显式能力租约和停止令牌。`run.ecs` 是 Maa CustomAction 的首个闭环。 | + +`run.ecs` 的输入至少包括脚本来源、参数、入口目录、超时和错误策略;输出包括 `ok`、返回值、日志和结构化错误。它必须复用 `ScriptHostAssembler`/未来的高层执行桥,不通过 CLI 子进程运行。 + +### 4.5 PC 与 MCU 的执行策略 + +v2 允许 PC 直接从 AST/SSA/高层 IR 执行,以支持桌面专属的 OCR、OpenCV、音频、LLM 和调试能力。MCU 路径仍然是: + +```text +ECS → typed IR/SSA → MCU lowering → ECX1/v3 → C VM +``` + +PC 和 MCU 的正式对拍只比较两端能表达的公共子集:值语义、控制流、按键/等待、返回值、错误和取消。桌面专属能力在 MCU 上通过特征位和预检拒绝,不能编译成功后再让设备无响应。PC 上的 C VM 模拟只用于验证 MCU 内核。 + +### 4.6 cleanroom 模块重建 + +“cleanroom”在本方案中指独立按公开协议和行为契约重建,不指法律认证: + +- 手柄模块只依赖串口协议、报文格式、握手、节流、重连和输入行为测试向量; +- 采集模块只依赖来源枚举、OpenCV/OBS 后端选择、帧尺寸、时间戳、断开和重连契约; +- 两者都不引用对方的私有类型,也不把 Maa 原生对象泄漏到模块公共接口; +- 通过录制报文、固定图像帧和故障注入做差分测试;通过后再挂入 `MaaControllerBinding`。 + +重建是可选的内部替换手段,不是 v2 的用户可见功能,也不是 dev 继续维护的前置条件。 + +## 5. v2 阶段大纲 + +| 阶段 | 交付 | 进入条件 | +|---|---|---| +| V2-0 契约冻结 | Pad/Capture/Audio 独立端口、FrameHub、资源代次、停止/错误模型、1920×1080 坐标 | dev 两项近期修正完成 | +| V2-1 适配器 | CustomController、Maa OCR、模板 CustomRecognition、标签转换器 | 旧 ECS 回放基线固定 | +| V2-2 `run.ecs` | Maa CustomAction 在同一 RunScope 执行 ECS,输出结构化结果 | 能力租约和取消可对拍 | +| V2-3 Pipeline/Flow 互操作 | Maa Pipeline 可调用 `run.ecs`;现有 Flow 继续运行并可调用同一节点语义 | 不改变已有 `.flow.json` | +| V2-4 决策节点 | script predicate、agent provider、jev provider、权限和超时 | provider 契约明确,默认只读 | +| V2-5 MCU 公共子集 | 高层 IR 到 ECX、C VM 执行、PC/MCU 对拍、容量和能力预检 | 先有有效 ECX 导出,再接硬件下发 | + +任何阶段都必须保留一个可以运行的 ECS 直达入口;v2 不能以“全部节点迁移完成”作为可用条件。 + +## 6. 不纳入本轮的内容 + +- 不把现有 ECS 全量转换为 Pipeline。 +- 不删除或改名现有 Flow,不把 `script.run` 宣布为“v1 已删除”。 +- 不因为接 Maa 而合并手柄和采集源;端口与来源仍可分别选择。 +- 不以 720p 重解释现有标签。 +- 不把 PC 高层执行、MCU C VM 模拟和旧 C# 解释器描述成三个需要同时长期维护的正式产品端。 +- 不在本轮实现 LLM/jev 的具体算法;先冻结 provider、schema、权限、超时和取消契约。 +- 不把 MCU 烧录能力查询、容量预检和固件协议升级混入 GUI ECX 导出修正。 + +## 7. 验收定义 + +dev 维护完成的最低验收是: + +1. 编译错误时无链接、无新 ECX、无固件封装、无烧录;重复诊断按根因去重。 +2. 无错误(允许警告)时,CLI 与 GUI 对相同编辑器源码生成相同 ECX。 +3. GUI 生成使用当前编辑器文本,并保留入口路径对 `IMPORT`、`lib/`、标签和音频目录的解析语义。 +4. 手柄端口和采集来源可独立选择、独立断开,互不触发重连。 +5. 识别、FRAME/ROI、OCR、监视器取同源帧;音频端点独立于视频和手柄。 +6. Maa 适配层只作为宿主能力和动作/识别适配,不改变 ECS 源文件。 + +v2 验收再增加:Maa `run.ecs` 的执行/停止/返回值、标签转换回放、PC/MCU 公共子集对拍,以及节点目录和运行时的一致性。 + +## 8. 与旧方案的关系 + +`Maa接入详细方案-20261014.md`、`Maa接入实施任务书-20261015.md` 和 `PC高层执行与设备模块重建方案-20261018.md` 中关于协议兼容、独立端口/来源、统一帧源、旧分值、PC/MCU 两端对拍和 Maa 适配器的内容继续有效;其中要求立即改造整个执行架构的部分,按本文移入 v2。 + +本文不覆盖 `0000-cvm.patch` 的应用细节。该补丁先按已授权范围完成 C VM 内存布局/构建预算的处理;它与 GUI ECX 导出属于不同层,补丁通过后仍须单独完成上面的 ECX 编译闸门和 GUI 调用链验收。 diff --git a/docs/McuBytecodeDelivery.md b/docs/McuBytecodeDelivery.md new file mode 100644 index 00000000..c8a41bc8 --- /dev/null +++ b/docs/McuBytecodeDelivery.md @@ -0,0 +1,69 @@ +# MCU 字节码下发通道 —— 方案 + +> **状态:方案(未实施)**。这是唯一保留的未实施方案:接回「桌面编译 → 单片机执行」链路。 +> 前置条件(2026-10 单流化落地后已变化):原「断点 1:桌面档产物不可序列化」**已消失**—— +> 全部产物同为 ECX1 平铺形态(`EcsContainer.WriteImage`),GUI 会话镜像可直接序列化。 +> 仍在的断点:**GUI 产物入口未接**(`ScriptService.BuildAsync` 抛 NotImplemented)、 +> **无自动传输通道**(`NintendoSwitch.Flash(byte[])` 协议已就绪、0x82/0x83 命令可用,但无人调用)。 + +## 1. 目标 + +编辑器里有脚本 + 单片机已连接 → 点一次「编译并烧录」→ 脚本在真机运行;任何失败给出**可操作的单一原因** +(在烧之前说清,而不是让用户拿到"设备没反应")。 + +## 2. 核心设计(六个决定) + +| # | 决定 | 要点 | +|---|---|---| +| D1 | **单一产物入口 `McuImageBuilder`** | 与 `ScriptHostAssembler` 同属组合根;产出 `McuImage`(序列化字节 stripDebug + Features + 静态极值),保证可分发镜像只有一处产出 | +| D2 | **预检 `McuPreflight`(纯函数)** | 输入镜像元数据 + `McuTargetProfile`,输出结构化判定(§3 四条件),不碰设备 | +| D3 | **能力查询命令 0x8A**(需固件配合) | 设备上报 `feats:u32 / arena_bytes / frame_seg_bytes / obj_block_bytes / obj_block_count / image_capacity / format_ver / abi_rev`(小端)。**上报池容量而非 max_slots**——零分配下固件 RAM 是编译期常数,主机侧拿路径帧和来比,不猜乘积。老固件无应答 → fail-closed 保守档(能力全无、容量 0=未知),容量只能来自 `config.json` 固件档案;都没有 → 拒烧并说明,**不许凭空猜**(产品事实:MCU 可用烧录容量约 512–1024 B) | +| D4 | **传输层加固** | 复用 `Flash(byte[])` 但:①入口加 32767 上限守卫(现 7+8 位偏移编码超限会**静默回绕**);②`FlashAsync(bytes, IProgress, ct)`(进度/取消,同步 API 保留薄封装);③失败返回 `FlashResult`(Success/RetriesExhausted/ControlLost/TooLarge)而非 bool——可区分"没响应"与"太大" | +| D5 | **烧录后启动** | 编排既有 `RemoteStart()/RemoteStop()` | +| D6 | **能力判定两段式** | 烧录前(C# 预检)给可操作诊断;加载期(C VM)critical 段/特征位 fail-closed 兜底——镜像是可手工拷贝分发的文件,"烧录工具检查过"不是设备能信任的前提 | + +## 3. 可执行性判定:四个条件的合取("装得下"≠"能执行") + +flash 容量与 RAM 是两件事。RAM 需求 = **活跃调用路径的帧和**(`Σ nslots×16`,非 `max_depth×max_slots` +乘积——光速过帧实测 336 B vs 乘积式 480 B;且链接期 `MaxDepth` 对递归回边按 1 计会**低估**, +不能直接当预算)。零分配池落地后,C4 退化为常数比对: + +| # | 条件 | 判定 | 失败表现 | +|---|---|---|---| +| C1 | 容器/格式匹配 | ECX1 magic/format、格式修订号(单值,无分级梯) | 加载期拒跑(IMAGE/UNSUPPORTED) | +| C2 | 能力满足 | critical 段集 ⊆ 加载器支持集;镜像 feats ⊆ 设备 feats | 预检诊断 + 加载期 fail-closed | +| C3 | flash 装得下 | 镜像字节 ≤ image_capacity | 预检拒绝 | +| C4 | RAM 池够 | ①活跃路径帧和 ≤ frame_seg_bytes(非递归可精确算)②单帧 ≤ 帧段 ③递归脚本须声明深度否则拒(fail-closed)④静态极值(平铺头 u16×3 × 元素定宽)≤ obj_block_bytes | 帧段:预检;对象段峰值不可静态判定 → 运行期 `ECS_ERR_POOL` 干净失败(区分帧段满/对象段满) | + +可以承诺:不满足容量不烧;满足了不会因 RAM 崩溃、只会干净报错。不能承诺:"放下就一定跑得完"。 + +## 4. 产品硬门:带图像识别的脚本不脱机运行 + +产物只存标签**名字**(`@标签` → ExtVars → `Img/LoadK`),图像数据在 PC 侧; +镜像带 VISION/IL 特征位 → MCU 加载期拒跑(`ECS_ERR_FEAT/IL`)。预检在烧录前给出 +"该脚本引用图像标签,不支持脱机运行"的可操作诊断。这条不可绕过。 + +## 5. 分阶段 + +| 阶段 | 内容 | 依赖 | +|---|---|---| +| P0 | GUI/CLI 接回:`McuImageBuilder` + `McuPreflight` + 编排 `Flash/RemoteStart`(结构化 FlashResult) | 无固件改动 | +| P1 | 能力查询 0x8A + 传输健壮性(FlashAsync/上限守卫/进度取消) | 固件配合 | +| P2 | 一键闭环与体验(进度 UI、失败原因定位) | P0/P1 | +| P3 | 产物格式演进(信任面:C VM 加载期不做 CRC/指令流校验,完整性由传输层负责) | 双端协商 | + +测试锚点(实施时建):`PortableProfile_AllExamplesSerializable`(examples+corpus 全量编译→序列化→往返)、 +预检单测(四条件各一拒绝路径)、`FlashResult` 归因测试。 + +## 5.5 设备侧字节码来源(`ecs_io`,已实现) + +C VM 的加载入口抽象为随机访问读取器(uLua zIO 思路),覆盖三类典型存放位置: + +| 来源 | 接入方式 | 拷贝 | +|---|---|---| +| RAM 缓冲 | `ecs_vm_load(buf, len)` | 零拷贝(可 XIP) | +| 内部 flash(内存映射) | `ecs_io_mem` 直载 | 零拷贝(XIP 原地取指) | +| EEPROM / NVS | `ecs_io` read 回调 → `ecs_vm_load_io` | 拉入 VM 内部副本(free 时释放) | + +信任面:加载期仅校验 magic/format/修订号与解析期结构边界(宿主内存安全),字节码内容全信 +(Lua 式最小验证面)。参考实现:`ecs_main.c --io`(stdio seek 读取器演示)。 diff --git a/docs/ModuleImportRules.md b/docs/ModuleImportRules.md new file mode 100644 index 00000000..3b33e909 --- /dev/null +++ b/docs/ModuleImportRules.md @@ -0,0 +1,95 @@ +# ECS 模块导入规则 + +> **状态:现状**。本文是 `ModuleGraphBuilder` 的规则说明与编译序论证,供 +> `docs/Pipeline.md`、`docs/ModuleSystem.md` 与 `docs/Script.md` 第十一部分引用。 +> 规则编号沿用代码注释:R1–R5、R7(R6 无现行规则)。 +> 实现落点:`src/EasyCon.Script/Modules/ModuleGraphBuilder.cs`; +> 语义测试:`test/EasyCon.Tests/LibTests.cs`。 + +## 1. 术语 + +- **主脚本 / 入口**:传给 `ProjectCompiler.CompileProject` 的文件,模块名固定 `
`。 +- **项目根**:主脚本所在目录(`scriptDir`);所有模块名相对它计算。 +- **模块**:一个 `.ecs` 文件(编译单元),模块名见 R5。 +- **显式边**:`IMPORT` 产生;**自动边**:目录扫描产生(R1/R3)。 + +## 2. 编译序 + +``` +std → vision → 用户模块(显式边层级升序,同层按模块名 OrdinalIgnoreCase)→ main +``` + +- 层级只由**显式边**约束:`LevelOf(module) = max(LevelOf(显式依赖)) + 1`; + 显式环已在建图期报错,层级计算对残留环按 0 层截断(防御分支)。 +- **自动边不约束层级**:同目录互见与 `lib/` 自动导入的模块按同层名序排列, + 依赖接口由 `.eci` 预提取先行就绪(见 `docs/ModuleSystem.md §1`)。 +- 每个节点的依赖列表按 `std, vision, 自动依赖(名序,且去掉已显式依赖), 显式依赖(发现序)` + 合成;显式别名优先于自动边。 +- main 恒为最后一个编译单元。 + +## 3. 规则 + +| 编号 | 规则 | 语义 | +|---|---|---| +| R1 | 同目录互见 | 同一目录(入口目录除外)下的所有 `*.ecs` 互相可见,无需 `IMPORT`。扫描只覆盖该目录的**直接子文件**,不递归 | +| R2 | 导入基准 | `IMPORT "x.ecs"` 相对**导入文件所在目录**解析;基准随模块移动,不是进程 CWD | +| R3 | main 隐式导入 `lib/` | 主脚本自动导入 `<项目根>/lib/*.ecs`(直接子文件),**不传递**:`lib/` 的子目录不会因 R3 自动进入 | +| R4 | 跨目录显式导入 | 不在同一目录、也不在 `lib/` 顶层自动范围的模块,必须写 `IMPORT` | +| R5 | 模块名 | 相对项目根的规范化路径:POSIX `/`、去掉 `.ecs`。如 `lib/net/http.ecs` → `lib/net/http`。内存单文件无项目根时退化为文件名 | +| R7 | 入口目录豁免 | 项目根目录不触发 R1:并列主脚本互不打扰;根目录被显式导入的模块也不与根目录其他脚本互见 | + +补充约束: + +- 模块名查重**大小写不敏感**(节点表 `StringComparer.OrdinalIgnoreCase`);同名或仅大小写不同 → + `ECX0114 模块名冲突`。 +- `IMPORT` 文件不存在 → `ECX0113`;显式依赖成环 → `ECX0112`。 +- 自动边(R1/R3)不参与环检测:同目录互见天然双向,由接口先行预提取(`.eci`)保证循环可见可编译。 +- `lib/` 自动导入只针对 main;lib 内文件之间的互见是 R1 的自然结果,不是 R3 的传递。 +- `lib` 是**固定目录名**(`<项目根>/lib`),只扫描顶层 `*.ecs`。 + +## 4. 建图与收敛 + +1. 注册内嵌 `std` / `vision` 与 main 节点。 +2. 从 main 开始 BFS: + - `CollectExplicit`:扫描当前节点的 `IMPORT`,记录显式依赖并将新节点入队; + - main 额外扫描 `<项目根>/lib`(R3); + - 非入口目录的节点扫描自身目录(R1/R7)。 +3. 新注册节点入队继续扫描,直到队列为空(显式边与自动边相互触发、定点收敛)。 +4. 已注册文件按完整路径去重(`byPath`,大小写不敏感);自动边对已注册文件补齐反向边。 +5. 闭包收敛后,对全部节点的显式依赖执行 DFS 环检测(`ECX0112`),再合并自动依赖并生成编译序。 + +## 5. 轻量导入扫描 + +建图阶段**不 parse 依赖模块**: + +- 已有语法树(main 或缓存未命中后)→ 直接读 `ImportStmt`; +- 否则用 Lexer 令牌流匹配 `IMPORT` + `STRING`,跳过 `NEWLINE` / 空白 / 注释 trivia; +- 禁止裸正则扫描(避免把注释或字符串里的 `IMPORT` 误判为依赖)。 + +全量 parse 推迟到接口预提取(`.eci`)或缓存未命中时的模块编译。 + +## 6. 与项目骨架的对齐 + +`ezcon init` 生成的骨架(见 `docs/Script.md §11.4`)即本文规则的最小示例: + +``` +my-project/ +├── main.ecs
+├── lib/mod1.ecs lib/mod1 ← R3 自动可见,main 无需 IMPORT +├── lib/net/http.ecs lib/net/http ← 不在 lib 顶层,需显式 IMPORT +└── other.ecs 根目录脚本 ← R7:不与 main 自动互见 +``` + +- `main.ecs` 直接调用 `lib/mod1.ecs` 的 `ADD`:R3 + R1; +- `lib/net/http.ecs` 与 `lib/net/json.ecs` 同目录:R1 互见; +- `lib/mod1.ecs` **看不到** `lib/net/http.ecs`:不同目录且无显式 IMPORT(R4)。 + +## 7. 常见误区 + +| 现象 | 原因 | +|---|---| +| 根目录另一个 `.ecs` 的函数不可见 | R7 入口目录豁免;主脚本并列互不打扰 | +| `lib/sub/x.ecs` 没有自动可见 | R3 只扫 `lib/*.ecs` 顶层;子目录走 R1/R4 | +| 子目录模块互相调用失败 | R1 只覆盖同一目录;跨目录必须 `IMPORT` | +| `IMPORT` 在子目录模块内写相对主脚本的路径失败 | R2 基准是**导入文件**所在目录 | +| 两个仅大小写不同的模块名报冲突 | 模块名查重大小写不敏感(`ECX0114`) | diff --git a/docs/ModuleSystem.md b/docs/ModuleSystem.md index d69187f0..d33024ac 100644 --- a/docs/ModuleSystem.md +++ b/docs/ModuleSystem.md @@ -3,24 +3,31 @@ > **版本 2.0**。本文档描述模块系统现状:每个 `.ecs` 文件是一个模块(编译单元), > 产物为 `.ecm`(接口区 + 代码区);消费者只读依赖的接口区,接口内容寻址缓存,源码或依赖接口不变则永不重编。 > 理论对应物:C++20 Named Modules(BMI/IFC)、Rust rmeta、Go build cache、OCaml `.cmi`。 -> 关联:`docs/VM2.md`、`docs/EcmEcxFormat.md`(.ecm 位级布局)、`docs/Pipeline.md`(统一链路)。 +> 关联:`docs/VM2.md`、`docs/Pipeline.md`(统一链路;.ecm 位级布局见 `EcsContainer`/链接器测试)。 ## 1. 编译模型 ``` -main.ecs ─► ModuleGraphBuilder(IMPORT 递归展开 + 环检测 DFS + lib/ 自动加载) +main.ecs ─► ModuleGraphBuilder(显式 IMPORT 闭包 + 同目录互见/lib/ 自动导入 + 显式环检测 DFS) │ 依赖模块只做 Lexer 令牌级导入扫描(建图不 parse;全量 parse 推迟到缓存未命中) - ▼ 拓扑序(std → vision → 依赖序 → main;DAG 强制,环 = 编译错误) -ModuleCompilePipeline(逐模块):cacheKey 三路查找(disk .ecm / 进程缓存 / .err 重放) + ▼ 编译序:std → vision → 显式边层级 + 同层名序 → main(docs/ModuleImportRules.md §2) +ModuleCompilePipeline(逐模块):接口先行预提取(.eci 缓存)→ cacheKey 三路查找 + │ (disk .ecm / 进程缓存 / .err 重放) │ 未命中 → parse → InterfaceScopeSynthesizer(依赖以接口区提供) │ → Binder 急切绑定 → SSA → 优化(导出为根)→ EcxModuleEncoder → 原子写回 obj/ ▼ EcxPipeline.Link ─► EcxImage(桌面 EcxInterpreter 与 MCU C VM 共用同一产物) ``` +- **导入规则**:R1 模块同目录互见(目录即包)、R2 导入基准 = 导入文件所在目录、R3 main 隐式导入 + lib/、R4 跨目录显式导入、R5 模块名 = 相对主脚本目录的规范化路径、R7 主脚本并列互不打扰 + (入口所在目录豁免同目录互见)——完整规则与可见环的编译序论证见 `docs/ModuleImportRules.md`; + `ezcon init` 生成的骨架是该规则的参考布局。 - **无源码级合并**:任何编译只 parse 自己那份源码;stdlib(std/vision)内嵌源码随编译器发布, 首次编译任一脚本时与用户模块同走 obj/ 缓存自然产出 `.ecm`,此后零解析。 -- `lib/` 自动加载:main 同目录 `lib/*.ecs` 注册为隐式模块(显式 import 之后、main 之前编译,全局可见无 alias,按文件名序,跳过显式 import)。 +- **接口先行预提取**:同目录互见产生接口级依赖环,编译序不再保证依赖接口就绪——管线先经 + `.eci` 接口缓存(按源码内容寻址,含路径上下文)为全部模块备好接口;缓存命中零 parse, + 未命中 parse 提取并回写。接口提取失败(源码语义错误)留空接口,真实诊断由绑定阶段报告。 - 关键接缝:接口合成的函数符号 `Declaration = null`,Binder 的 `EnsureFunctionBodyBound` 天然跳过绑体、调用点直接生成 Call(体由链接期提供)——绑定器零改动消费接口。 @@ -67,8 +74,10 @@ interface_hash = SHA-256(CompilerVersion ## 5. 缓存系统(`Modules/ModuleCache.cs`) -- **cacheKey** = SHA256(源码 ⊕ Σ直接依赖接口哈希 ⊕ 编译器版本 ⊕ `CompileOptions.ProductFingerprint()`)。 +- **cacheKey** = SHA256(源码 ⊕ 文件模块的规范化源路径上下文 ⊕ Σ直接依赖接口哈希 ⊕ 编译器版本 ⊕ `CompileOptions.ProductFingerprint()`)。 影响产物的选项集中在 ProductFingerprint(Optimize / LegacySyntax / ExtVars);其余选项不进键。 + 源路径上下文用于保证 `EXTERN ... FROM "相对路径"` 始终按声明 `.ecs` 所在目录解析;内嵌 `std/vision` 与内存伪文件名使用空上下文,仍可跨项目共享。 + `.eci` 预声明接口缓存也包含相同路径上下文,避免复用其他项目目录解析出的原生库绝对路径。 文件名 = `<模块名>-.ecm`,不同版本并存免锁竞争。 - **读**:全量校验 interface_hash(短码碰撞防御)+ `ModuleArtifact.IntegrityCheck()`(disk 与进程缓存两条 TryLoad 共享);损坏按未命中重编。 - **写**:`temp + rename` 原子替换;成功后删除同键 `.err`。 @@ -77,7 +86,13 @@ interface_hash = SHA-256(CompilerVersion - **进程缓存**:仅 `UseDiskCache=false` 现编路径,存 EcmFormat 字节、命中反序列化出新实例(杜绝别名共享)。 - **GC**:运行末扫描 obj/,删除引用闭包之外且 mtime 超龄(默认 30 天,可配)的 .ecm/.err。 -## 6. 语义保持要点 +## 6. FFI 原生库路径 + +`EXTERN FUNC ... FROM "..."` 中含目录分隔符的相对路径,以声明所在 `.ecs` 文件的目录为基准解析为绝对路径;主脚本中的声明以主脚本目录为基准。绝对路径保持原样,`FrlgFfi.dll` 这类不含目录分隔符的库名继续交由操作系统按默认规则搜索。格式化只重写语法树中的原始声明,不会把本机绝对路径写回脚本。 + +运行时加载失败会包含最终尝试的库路径,并区分声明文件缺失、文件格式/位数不兼容及目标库存在但其本体或原生依赖无法加载;底层异常保留为内部异常信息。 + +## 7. 语义保持要点 | 语义 | 行为 | |------|------| @@ -89,9 +104,9 @@ interface_hash = SHA-256(CompilerVersion | lib 顶层语句先于 main | 链接器在 `
` 头部按拓扑序前插 `` 调用(无合成壳) | | LSP/编辑器语义 | 源码级实时分析,不经模块管线(明确出界) | -## 7. 测试锚点 +## 8. 测试锚点 `ModuleProjectTests`(管线/诊断/警告/GC,M7 区段)、`Cache_MerkleInvalidation` 等缓存组 (`ModuleCacheOptimizationTests`)、`ModuleInterfaceTests`(round-trip)、 `InterfaceCompletenessSnapshotTests`(§3 清单快照)、`NestedLibImport_ConstAndCrossCalls`、 -`LibConstant_Parity`、`Cache_StdVisionSharedAcrossProjects`。 +`LibConstant_Parity`、`Cache_StdVisionSharedAcrossProjects`。 \ No newline at end of file diff --git a/docs/Pipeline.md b/docs/Pipeline.md index df2aa7c1..22561264 100644 --- a/docs/Pipeline.md +++ b/docs/Pipeline.md @@ -2,8 +2,8 @@ > C# 侧单一编译链路:**源码 → Parser → ProjectCompiler(模块图 + lib/ 自动加载)→ 每模块独立编译 → > EcxImage**;桌面由 **EcxInterpreter** 执行 ECX 镜像,单片机由 **C VM** 执行同一二进制。 -> 关联:`docs/ModuleSystem.md`(模块/缓存)、`docs/VmSemanticContract.md`(双端语义契约)、 -> `docs/EcmEcxFormat.md`(二进制格式)、`docs/VM2.md`(指令集规格)。 +> 关联:`docs/ModuleSystem.md`(模块/缓存)、`docs/VM2.md`(双端语义,对拍测试锁定)、 +> 产物容器规格见 `EcsContainer`/链接器测试(`test/EasyCon.Tests/Bytecode/`)。 ## 1. 架构 @@ -27,16 +27,17 @@ ## 2. 关键语义事实 -- **lib/ 自动加载**:main 同目录 `lib/*.ecs` 注册为隐式模块(显式 import 之后、main 之前编译, - 全局可见无 alias;按文件名序,跳过显式 import)。 +- **lib/ 自动加载**:main 同目录 `lib/*.ecs` 注册为隐式模块(全局可见无 alias;按文件名序, + 跳过显式 import)。编译序由显式边层级 + 同层名序决定,自动边不约束层级;完整导入规则 + (R1–R5/R7)见 `docs/ModuleImportRules.md`;`ezcon init` 生成的项目骨架即该规则的参考布局。 - **同签名跨模块导出**:链接期首匹配遮蔽 + `MD_AMBIGUOUS_EXPORT` 警告(同名同签名函数可共存)。 - **NeedIL 判定**:链接后在完整镜像上做入口可达性 BFS(采集洞 CallN)∪ 全局 Img 标签, 据此设置镜像 NeedIL 标志。 - **Link 纯函数化**:在深拷贝副本上重写——不污染调用方产物,`CompileResult.Artifacts` 可安全 .ecm roundtrip。 -- **FWRITE 句柄 0**:写入 no-op 返回写入长度(S-14,见 VmSemanticContract)。 +- **FWRITE 句柄 0**:写入 no-op 返回写入长度。 - **EXT 扫描铁律**:EXT 后随字是「数据」,数值可能恰好等于某个操作码——一切线性扫描必须按 - `EcsFormat.ExtWords` 步进跳过(C 侧 `ecs_op_has_ext`),否则把数据误读为指令。 + `EcsFormat.WordCount` 步进跳过(C 侧 `ecs_op_words`),否则把数据误读为指令。 ## 3. 链接期行为 @@ -56,22 +57,25 @@ | 知识 | 落点 | |------|------| -| 指令格式/EXT 语义/结果槽 | `Bytecode/EcsFormat.cs`(漏登自检)+ C 侧 `ecs_op_has_ext` | +| 指令格式/EXT 语义/结果槽 | `Bytecode/EcsFormat.cs`(漏登自检)+ C 侧 `ecs_op_words` | | 内置函数能力清单(路由 + 特征位 + MCU 桩可用标记) | `Binding/BuiltinFunctions.Manifest`(单一登记;`Routes`/`IsIntrinsic` 派生;链接器特征扫描消费 FeatureBit;总表见 VM2.md §7.0) | | syscall 编号/镜像特征位 | `Bytecode/EcsOpcode.cs`(`EcsSyscall`/`EcsImageFeatures`)+ C 侧 `ECS_SYSCALL_*`/`ECS_FEAT_*` | | 代码线性扫描 | `Bytecode/InstructionScanner.cs`(回调式,链接器各 pass 共享) | -| RC 槽写 | C# `MoveToSlot/StoreFresh/OverwriteItem`;C `move_to/store_fresh`(规则见 VmSemanticContract §二) | +| RC 槽写 | C# `MoveToSlot/StoreFresh/OverwriteItem`(EcxInterpreter.cs);C `move_to/store_fresh`(ecs_vm.c) | | 缓存键选项 | `CompileOptions.ProductFingerprint()`;产物自检 `ModuleArtifact.IntegrityCheck()` | | 模块诊断构造 | `DiagnosticBag.FromMessage` | | 测试基建 | `test/EasyCon.Tests/Support/`(CvmRunner/EcsTestHost/CorpusAssert) | ## 5. 后续项(未实施) -- GUI 一键产 `.ecx`:经 `IScriptSession.Info.Image` → `EcxWriter`(CLI compile 已可用)。 -- 桌面路径缓存化(当前桌面现编)。 -- 运行错误位置映射:产物无行号表,需 pc→源行映射才能恢复源级定位。 -- EcxInterpreter 性能:常量串驻留(对齐 VM2.md §2 字符串驻留规格)/ 帧池复用 / 取消检查三分点 +- **GUI 一键产 `.ecx` 并烧录**:方案见 `docs/McuBytecodeDelivery.md`(唯一入口 + `McuImageBuilder` + 预检 + 复用 `NintendoSwitch.Flash`;单流化后产物同形态, + GUI 会话镜像可直接序列化,CLI `compile` 已可用)。 +- ~~桌面路径缓存化~~(✅ 单流化档位塌缩后桌面路径已可用进程/磁盘缓存;GUI 默认仍现编)。 +- ~~运行错误位置映射~~(✅ ECM v4 起产物带行号表,桌面路径 `EcxVm` 已实现 pc→源行; + 行号调试块保留,MCU 发布镜像省略)。 +- EcxInterpreter 性能:常量串 pinned(✅ S-20 已落地)/ 帧池复用(C VM ✅ ZeroAlloc P2)/ 取消检查三分点 (入口 + 预算边界 + 宿主调用后)已落地;剩余方向:C VM 同型收益(加载期驻留 + 帧池)、 运行错误位置映射(需 pc→源行表)。 - 格式 v2 窗口四项(调用约定寄存器化/镜像 CRC/Link 三遍合一/模块并行编译)——设计备注见 - `docs/EcmEcxFormat.md` §5.1,实施须双端协商升版本。 + 需双端协商升版本(容器写入/校验落点 `EcsContainer`)。 diff --git a/docs/Pokeldn.md b/docs/Pokeldn.md new file mode 100644 index 00000000..2fe1dfa3 --- /dev/null +++ b/docs/Pokeldn.md @@ -0,0 +1,172 @@ +# pokeldn ESP32 手柄固件适配 + +**状态:已实施;协议及模拟设备测试通过。用户已确认早期适配构建的 ESP32E 连接和控制可用; +本次对齐上游后的构建及 S3 实机验证待完成。** + +本次连接层整理基于 EasyConNS/EasyCon `dev` 提交 `26538600fce7fa3269744a17e23bb59ce3192ca7`, +在已合并的适配上分开注册通用串口与桥接协议。核对的 pokeldn +提交为 `30cbb0a47541a4293ca92152838b03cd7c8b9615`,控制器固件版本 **1.3.0**(2026-10-30)。 +适配发生在 EasyCon 电脑端,使用上游原版固件,无需重编译 ESP32 固件。 + +## 固件与硬件选择 + +pokeldn 有两个独立的固件项目: + +- `firmware/esp32` / `pokeldn-radio*.bin`:承载 LDN 无线通信,用于交换、配信等功能,不能当作手柄使用。 +- `firmware/pad` / `pokeldn-pad*.bin`:模拟 Switch 手柄,本适配使用这一组固件。 + +| 板子 | 应刷固件 | 电脑 → 板子 | 板子 → NS | EasyCon 入口 | +|---|---|---|---|---| +| 普通 ESP32 / WROOM-32 | `pokeldn-pad.bin` | USB 转串口,921600、8N1 | Bluetooth Classic Pro Controller | 对应 COM/tty 端口 | +| ESP32-S3 | `pokeldn-pad-s3.bin` | 电脑 BLE,经 pokeldn 本地手柄服务 | 原生 USB,HORI HID | 服务的 `tcp://主机:端口` 地址 | +| ESP32-C3 / C6 | 不支持手柄固件 | — | 缺少上述手柄所需的硬件组合 | — | + +S3 本次通过**官方 pokeldn 服务**桥接 BLE,并非 EasyCon 直接调用操作系统蓝牙 API。 +使用 S3 时需运行 pokeldn 的手柄服务;普通 ESP32 串口直连不需要该服务。 + +## 普通 ESP32 使用步骤 + +1. 从 [pokeldn Releases](https://github.com/Decryptu/pokeldn/releases) 下载普通 ESP32 的 + `pokeldn-pad.bin`,或在 pokeldn Board 页安装 **Controller** 固件。勿刷 Wireless/Radio。 +2. 通过 USB 数据线将 ESP32 接电脑,安装开发板所需的 CP210x/CH340 驱动。 +3. 在 NS 上进入「手柄 → 更改握法/顺序」完成配对。固件会在重启后尝试连接已配对主机。 +4. 退出会占用该串口的 pokeldn 控制连接/串口监视器。EasyCon 中选择对应端口并连接。 +5. 确认日志出现 `pokeldn-pad (串口) 1.3.0`,然后使用虚拟手柄、键盘映射或电脑端脚本。 + +普通端口按原有 `115200 → 9600` 探测 EasyCon 固件,失败后再探测 `921600` 的 pokeldn-pad。 +串口地址直接使用 `COM3` 或 `/dev/ttyUSB0`;协议由设备层的注册项探测,不使用固件专用前缀。 +开口可能触发开发板复位,握手会在约 3 秒内重试状态查询。连接后保持串口打开,不按每个按键重开。 + +```powershell +ezcon run script.ecs --port COM3 +``` + +## ESP32-S3 使用步骤 + +1. 安装 `pokeldn-pad-s3.bin` / Board 页的 **Controller** 固件。 +2. 将 S3 的**原生 USB/OTG 接口**接 NS;NS 供电并将它枚举为 `0f0d:0092` 的 HORI 手柄。 + 控制固件占用此接口作为 HID,不能将其当作电脑串口使用。 +3. 开启电脑蓝牙,在 pokeldn **Control** 页面连接 `POKELDN-PAD`,保持 pokeldn 运行。 + 若同时插着普通 ESP32,先在 pokeldn 中确认连接的是目标 S3;官方服务会优先尝试串口板。 +4. 在 EasyCon 的 `controller_profile` 配置中填写桥接地址 `tcp://127.0.0.1:47800`、 + 传输方式 `TcpBridge`,重启后从设备下拉框选择该地址并连接。完整配置见 + [通用设备连接说明](DeviceConnections.md#配置桥接地址)。 +5. 使用 EasyCon 控制。连接时会停止板上已有宏并释放按键;不要同时在 pokeldn Control 页面发按键或启动宏。 + +也可在已配置依赖的 pokeldn 源码环境启动官方服务: + +```powershell +python -m pokeldn.pad.service +``` + +然后在 EasyCon CLI 中使用: + +```powershell +ezcon run script.ecs --port tcp://127.0.0.1:47800 +``` + +上游服务默认监听本机 `127.0.0.1:47800`,该地址仅是配置示例,EasyCon 不固定服务端口。 +设备扫描只枚举物理串口,桥接地址由用户显式配置;不会为未安装的服务添加固定列表项。 +EasyCon 不会自动启动、下载或退出 pokeldn 进程。EasyCon 断开时发送释放和 `disconnect`, +服务进程继续运行。 +macOS 的 BLE 权限和服务启动方式沿用 pokeldn 官方应用的设置。 + +从早期适配迁移:`pokeldn:COM3` / `pokeldn:/dev/ttyUSB0` 改为 `COM3` / `/dev/ttyUSB0`; +`pokeldn://service` 和 `pokeldn (BLE 服务)` 改为上述 TCP 地址,并将配置的 `Transport` 改为 `TcpBridge`。 +旧的固件专用地址不再解析。 + +## 已适配的能力与边界 + +- A/B/X/Y、L/R/ZL/ZR、加减、左右摇杆按压、HOME、截屏、八向十字键和双摇杆全范围。 +- 虚拟手柄、SDL 键盘/手柄映射、电脑端 ECS 脚本以及共用 `NintendoSwitch` 的 Flow/CLI 控制。 +- 停止脚本/Reset 会清空待发送输入并发送固件 STOP,正常断开时再次释放全部按键。 +- 每秒查询板子状态,发送逐条确认;串口错误、拒绝的 ACK 或失联会退出连接并上报错误。 +- 不支持 EasyCon 离线字节码烧录、远程启动/停止离线脚本、Amiibo、颜色、控制器模式和软件解除配对。 + 这些操作会明确失败,不能向 pokeldn 发送 EasyCon 私有命令。普通「运行脚本/停止脚本」仍可用。 +- `FirmwareVersion` 单独保存 `1.3.0` 文本,旧 `GetVersion()` 的 EasyCon 协议版本不能伪造。 +- 「已连接」表示电脑已连接控制板。`ConsoleConnected` 对应固件 `mounted`;为 false 时, + 日志会提示检查 USB/配对,但保留电脑连接以便之后配对。 +- BLE 写入使用上游服务的可靠写入路径,速度受系统蓝牙影响;原有 30 ms 节流不构成实机端延迟保证。 + 极短点按和严格帧计时需要实测。 +- 普通 ESP32 的 UART 固件没有电脑失联看门狗;若电脑进程崩溃或串口线意外断开, + 主动释放可能无法送达,应重连或重启板子。此限制不能仅靠电脑端适配消除。 +- 用户已确认早期适配构建中 ESP32E 使用正确的 Controller 固件后可连接并控制 NS。 + 本次对齐上游的构建尚未进行实机复测;ESP32-S3 和 Switch 2 尚未实测。 +- 当前固件不提供从 NS 休眠状态主动唤醒的保证。普通 HOME 报告仅在蓝牙链路已建立时发送; + `bt_esp32.c` 在链路关闭后重新进入可发现状态,没有收到 HOME 输入就主动重连/唤醒的路径。 + 用户反馈 ESP32E 无法唤醒,与该实现一致。请先用 NS 电源键或原装手柄唤醒主机。 + +## 协议分析与实现 + +共享 HID 报告为 8 字节:按钮 `u16 little-endian`、HAT、LX、LY、RX、RY、vendor(主机置 0)。 +按钮位与 EasyCon `SwitchButton` 相同,HAT 的 0–7 为顺时针方向、8 居中,摇杆 128 居中、0 左/上。 +旧 EasyCon 采用 7 位打包,不能把 `SwitchReport.GetBytes()` 直接交给 pokeldn。 +新增 `IConnection.WriteReport` 让连接实现选择编码;旧连接继续使用原有序列化。 +保留 `PokeldnSerialClient` 的公开入口、翻译方法和 `CreatePokeldnConnection` 测试接缝。 +`BytesSent` 在收到固件/服务 ACK 后回传原来的 7 位打包 HID,保持 Maa 会话确认中立报告的事件契约; +这不代表 NS 或游戏已经处理输入。端点规范化和来源占用锁由通用 `ConnectionEndpoint` 负责, +覆盖串口路径别名、TCP 主机名大小写、结尾斜杠和 IPv6 的等价写法,不依赖固件名称。 + +串口与桥接分别通过 `SerialConnectionProvider` / `BridgeConnectionProvider` 注册。 +`NintendoSwitchConnections.cs` 组合默认协议,Core、GUI、CLI 仅处理传输地址和连接能力。 +桥接目前默认注册的是官方服务的 JSON 行协议适配器;其他蓝牙桥接服务需要实现对应协议, +不能仅凭使用 TCP 就自动兼容。扩展方式见 [通用设备连接说明](DeviceConnections.md)。 + +普通 ESP32 的 UART 帧: + +```text +A5 5A | length u16 LE | type | payload | sum(type + payload) mod 256 +length = 1 + payload 长度,最大 600 +01 report(8 字节) → 81 result(有符号错误码,0 成功) +02 status → 82 status record +03 command(13 STOP)→ 81 result + +查询状态:A5 5A 01 00 02 02 +停止宏: A5 5A 02 00 03 13 16 +按下 A: A5 5A 09 00 01 04 00 08 80 80 80 80 00 0D +``` + +S3 的 BLE 服务 UUID 为 `7a1e0001-5d2c-4c3e-9f4b-504f4b454c44`,末段 `0002/0003/0004` +分别对应报告、状态、命令。当前实现通过 `pokeldn.pad.service` 的 JSON 行协议调用: + +```json +{"op":"connect"} +{"op":"status"} +{"op":"stop"} +{"op":"send","report":"0400088080808000"} +{"op":"disconnect"} +``` + +每条请求等待一条 `ok` 响应,拒绝/超时不当作成功;扫描可等待最多 45 秒,普通 I/O 有独立超时。 +串口、服务共用 `PokeldnConnection` 的队列、停止释放和状态轮询,测试通过传输接缝注入模拟固件。 + +核对来源(固定提交): + +- [Controller board 文档](https://github.com/Decryptu/pokeldn/blob/30cbb0a47541a4293ca92152838b03cd7c8b9615/docs/hardware_pad.md) +- [UART 固件实现](https://github.com/Decryptu/pokeldn/blob/30cbb0a47541a4293ca92152838b03cd7c8b9615/firmware/pad/main/uart_link.c) +- [蓝牙发送与连接生命周期](https://github.com/Decryptu/pokeldn/blob/30cbb0a47541a4293ca92152838b03cd7c8b9615/firmware/pad/main/bt_esp32.c) +- [报告状态与宏命令](https://github.com/Decryptu/pokeldn/blob/30cbb0a47541a4293ca92152838b03cd7c8b9615/firmware/pad/main/player.c) +- [官方串口客户端](https://github.com/Decryptu/pokeldn/blob/30cbb0a47541a4293ca92152838b03cd7c8b9615/pokeldn/pad/serial_link.py) +- [官方 BLE 服务协议](https://github.com/Decryptu/pokeldn/blob/30cbb0a47541a4293ca92152838b03cd7c8b9615/pokeldn/pad/service.py) + +## 验证 + +2026-11-01 本机验证:设备协议与连接注册测试 **65/65**,Maa 适配集成测试 **4/4**; +Release 全项目测试 **1,904 通过、182 跳过、0 失败**,另有 29 项显式测试未选择运行。 +Release 构建及 `dotnet format --verify-no-changes` 通过。 +跳过项主要依赖本机未安装的 C99 编译器、OCR 模型/资料,以及非 Windows 平台功能。 +这些结果不替代 NS 实机验证。 + +```powershell +dotnet test test/EasyCon.Device.Tests/EasyCon.Device.Tests.csproj +dotnet test EasyCon2.slnx -c Release +dotnet format --verify-no-changes +``` + +设备测试覆盖固定报文、14 个按钮位、9 个 HAT 值、摇杆边界、逐字节分片、粘包、噪声/坏校验、 +启动重试、负 ACK/超时/取消、真实设备队列输出、Reset/断开释放、空闲掉线、旧协议回归、 +能力拒绝,以及真实本机 TCP 上的官方服务请求/响应契约。另覆盖自定义协议注册、串口与桥接隔离、 +地址校验和别名独占、GUI/CLI 配置读写及桥接选择刷新。 + +实机验收:分别检查两种板的 A 点按、组合键、十字键、摇杆边界、持续按下后停止脚本、主动断开、 +拔插重连,以及 S3 关闭 pokeldn 服务后能否在 EasyCon 观察到断线。首次配对和每种 NS 型号单独记录。 diff --git a/docs/QQ_BOT_SETUP.md b/docs/QQ_BOT_SETUP.md new file mode 100644 index 00000000..c18d833e --- /dev/null +++ b/docs/QQ_BOT_SETUP.md @@ -0,0 +1,60 @@ +# qq bot 使用教程 + +在「用户配置 → 推送设置」展开或添加 `qq bot`,点击顶部的「使用教程」。软件内教程包含创建机器人、接入 EasyCon、脚本提醒三个页面;12 步注册截图随软件打包,可以离线查看,并支持放大完整原图。关闭教程会回到原来的编辑项,保留当前输入。 + +配置页和教程右上角的 [QQ 开放平台](https://q.qq.com/#/apps) 链接会在默认浏览器中打开机器人管理入口。截图依据 2026 年 9 月的平台界面,实际页面文案可能调整。 + +## 创建机器人并取得凭据 + +已有 AppID 和可用 AppSecret 时,在教程里点击「已有凭据,直接接入」。已有机器人时,可以从第 9 步继续。 + +1. 登录 [QQ 开放平台](https://q.qq.com/#/apps)。首次登录时阅读服务协议,勾选同意后完成注册。 +2. 点击「完善主体信息」,按账号页面的要求提交资料;已完善可跳过。 +3. 在首页选择「机器人」。 +4. 点击「去创建或管理我的 QQ 机器人」。 +5. 点击右上角「+ 创建机器人」。 +6. 设置头像、名称和简介,点击「创建机器人」。 +7. 在「设置 AI 服务」页选择「稍后连接」,EasyCon 会直接连接机器人。 +8. 从机器人卡片进入管理页面。此时显示离线不影响继续准备凭据。 +9. 打开左侧的「开发设置」。 +10. 复制 AppID,获取 AppSecret。确认事件接收方式为 **WebSocket**,绑定接收方需要它。 +11. 按需重置 AppSecret。已有可用密钥时无需重置;重置会使旧密钥失效,其他使用它的程序也需要更新。 +12. 复制密钥,返回 EasyCon 的 `qq bot` 配置。 + +AppSecret 是机器人鉴权密钥,接收通知的人无需获得它。软件内截图和点击框用于定位平台操作,实际注册、主体信息和密钥操作仍在开放平台完成。 + +## 接入 EasyCon + +所有接入操作都在当前 `qq bot` 推送项中完成。 + +1. 填入 AppID、AppSecret,点击「验证凭据」,等待显示「凭据已验证」。Windows 上可勾选「记住 AppSecret」,使用当前 Windows 用户的 DPAPI 加密保存。 +2. 绑定接收方:私聊点击「绑定私聊」,等六位绑定码显示后,在 QQ 中私聊机器人发送当前绑定码;群聊先把机器人加入目标群,再点击「绑定群聊」,在群内 **@机器人** 发送当前绑定码。 +3. 勾选「发送到私聊」或「发送到群聊」,也可以同时选择已绑定的两种接收方。OpenID 由绑定自动取得,不是 QQ 号或群号。绑定码每 60 秒更新,旧码立即失效。 +4. 点击「发送图文测试」,到 QQ 中确认文字和 EasyCon Logo 图片都收到。测试始终包含图片。 +5. 保留「通知默认附带当前视频画面」勾选,脚本提醒会附带触发时的最新画面。 +6. **打开当前 qq bot 项右侧的开关,再点击推送设置窗口底部的「保存」。** 测试成功不代表配置已应用到脚本通知。 + +验证、绑定和测试仅操作当前编辑项,点击「取消」会放弃本次修改。开发阶段需检查开放平台的「服务范围」「开发体验号码设置」以及对应的私聊/群聊消息权限,确保目标用户或群可以使用机器人。 + +## 脚本提醒 + +在需要提醒的位置加入: + +```ecs +ALERT "任务完成" +``` + +在 EasyCon 桌面软件中运行脚本。默认发送提醒文字和执行 `ALERT` 时的最新视频帧;无可用画面时使用 EasyCon Logo。关闭附图选项后,脚本提醒只发送文字。 + +发送在后台排队,运行日志先显示 `[ALERT]`,发送结果随后出现在 `[qq bot]` 日志中。失败不会中断脚本,也不会自动重发;接口提交成功后仍应到 QQ 中核对。单片机独立运行时不能发送 QQ 通知。更多发送与队列行为见 [ALERT 函数说明](Functions.md#alertmessage)。 + +## 常见问题 + +- 测试成功,但 ALERT 没发:检查该项已启用、选择了已绑定的接收方,并点击过「保存」;确认脚本执行到了 `ALERT`。 +- 绑定没有反应:发送当前六位绑定码,群聊需 @机器人;检查 WebSocket 接收方式和平台允许的测试范围。 +- 重启后需要填密钥:未勾选「记住 AppSecret」时,密钥只保留在当前会话。重新填写、验证、启用并保存。 +- 只有文字或发送失败:查看 `[qq bot]` 运行日志中的平台错误,再到 QQ 中核对实际提交结果。 +- 旧 QQ 配置读取失败:旧文件被占用、无权读取或内容损坏时,迁移和保存会暂停,已有推送仍保留。排除占用或修复旧文件后,重新打开推送设置会重试迁移。 +- 推送配置解析失败:日志和设置页会显示备份路径,原文件不会立即被覆盖。无法创建备份时会暂停保存,修复文件或权限后重新打开设置。 + +推送配置统一保存在 `%APPDATA%\easycon\alert.json`,qq bot 是其中的一个推送项。 \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index ad023c37..02f09cc6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,80 +1,58 @@ # 伊机控 EasyCon 文档中心 -欢迎使用伊机控文档系统!这里包含项目的完整技术文档。 +文档分四类。改代码前先读对应类;所有文档以**现状**为准——描述与代码冲突时,改文档或改代码, +不留"两说"。 -## 📚 文档导航 +## 📖 使用(面向脚本作者/用户) -### 核心文档 -- **[快速开始](GETTING_STARTED.md)** - 安装配置和使用教程 -- **[系统架构](Framework.md)** - 系统架构和设计理念 -- **[模块系统](models.md)** - 模块化设计和FFI接口 -- **[脚本语法](Script.md)** - ECS脚本语言完整语法说明 +| 文档 | 内容 | +|---|---| +| [GETTING_STARTED.md](GETTING_STARTED.md) | 安装、`ezcon init` 项目初始化、GUI/CLI 运行脚本、采集画面来源(含 OBS 虚拟摄像头) | +| [DeviceConnections.md](DeviceConnections.md) | 通用串口协议注册、TCP / 蓝牙桥接配置、协议扩展与端点占用规则 | +| [Pokeldn.md](Pokeldn.md) | pokeldn-pad 固件分析、ESP32 串口 / ESP32-S3 BLE 服务连接、能力范围与验证 | +| [Script.md](Script.md) | ECS 脚本语言完整语法:变量/常量/类型/流程控制/函数/结构体/数组/按键语句/识图标签/IMPORT | +| [Functions.md](Functions.md) | 内置函数手册(按功能域;速查表含桌面/单片机可用性) | +| [AudioSource.md](AudioSource.md) | 连接 Windows 游戏音频输入、查看电平及连接排查 | +| [AudioLabels.md](AudioLabels.md) | 声音标签制作、ECS 目录、LOAD/SCORE/UNLOAD 后台识别示例、0~100 整数分数校准与排错 | +| [DrawingBoard.md](DrawingBoard.md) | 喷射战士画板:图片处理(适配/明暗/抖动/去杂点/轮廓)、绘制设置、橡皮擦模式、框选区域、耗时估算与常见问题 | -### 虚拟机文档 -- **[V1指令集](VM1.md)** - 单片机端虚拟机指令集文档 -- **[V2指令集](VM2.md)** - 新一代虚拟机指令集设计 -- **[双端语义契约](VmSemanticContract.md)** - ECS 字节码双端语义/RC 协议权威表(S-01..S-19)与验证体系 -- **[二进制格式](EcmEcxFormat.md)** - ECM/ECX 位级格式规范 -- **[模块系统设计](ModuleSystem.md)** - 接口式独立编译与内容寻址缓存 -- **[统一编译链路](Pipeline.md)** - 编译链路现状与单一事实源落点 +## 🏗️ 架构与编译管线(改编译器/宿主前必读) -### 功能文档 -- **[EasyCon Assist](EasyCon%20Assist.md)** - 远程助手功能说明 -- **[OBS虚拟摄像头](obs-virtual-cam.md)** - OBS虚拟摄像头支持 +| 文档 | 内容 | +|---|---| +| [Framework.md](Framework.md) | 分层依赖图、组合根规则(能力装配/编译档位唯一落点)、三条主链路、已知短板 | +| [Pipeline.md](Pipeline.md) | 源码 → EcxImage 统一编译链路、链接期行为、单一事实源落点表 | +| [ModuleImportRules.md](ModuleImportRules.md) | 模块导入规则 R1–R5/R7、自动边收敛、编译序与常见误区 | +| [ModuleSystem.md](ModuleSystem.md) | 接口式独立编译、Merkle 缓存键、接口完备性清单、缓存 GC | +| [Flow.md](Flow.md) | 节点编排(`*.flow.json`):图格式、节点目录与语义、执行模型(看门狗/计时/停止/慢感知)、HTTP + MCP 接口、Python 画布(`frontend/`)、已知挂账 | -### 模块实现文档 -- **[EasyCon.Core](../src/EasyCon.Core/README.md)** - 核心功能模块 -- **[EasyCon.Device](../src/EasyCon.Device/README.md)** - 设备通信模块 -- **[EasyCon.Capture](../src/EasyCon.Capture/README.md)** - 图像处理模块 -- **[EasyCon.Script](../src/EasyCon.Script/README.md)** - 脚本解析模块 -- **[EasyCon.VPad](../src/EasyCon.VPad/README.md)** - 虚拟手柄模块 +## ⚙️ 虚拟机与字节码(改 VM/格式/双端前必读) -### UI设计文档 -- **[UI文档索引](../src/EasyCon2.Avalonia/DOCUMENTATION_INDEX.md)** - UI模块完整文档 -- **[系统设计文档](../src/EasyCon2.Avalonia/DESIGN_DOCUMENT.md)** - 详细架构设计 -- **[UI设计规范](../src/EasyCon2.Avalonia/UI_DESIGN.md)** - 界面设计文档 -- **[模块详细设计](../src/EasyCon2.Avalonia/MODULE_DESIGN.md)** - 功能模块设计 +| 文档 | 内容 | +|---|---| +| [VM2.md](VM2.md) | 现役 VM 规格:L0-L3 分层、值模型、指令表、宿主 ABI、调用约定、能力降级(§6.3)。双端语义一致性由 corpus/FullChain/ABI 对拍测试锁定(`test/EasyCon.Tests/Bytecode/`),产物容器位级规格见 `EcsContainer`/链接器测试 | -## 🎯 文档分类 +## 📐 设计思路与方案 -### 开发者文档 -- 系统架构文档 -- 模块系统文档 -- 虚拟机指令集文档 +| 文档 | 状态 | 内容 | +|---|---|---| +| [McuBytecodeDelivery.md](McuBytecodeDelivery.md) | 📋 方案(未实施) | MCU 下发链路:McuImageBuilder/预检/能力查询 0x8A/四条件判定/标签不脱机硬门 | +| [Maa-EasyCon-dev维护与v2设计大纲-20261020.md](Maa-EasyCon-dev维护与v2设计大纲-20261020.md) | ✅ 部分实施·含实施记录 | dev 维护边界、已落地的编译错误闸门/GUI ECX 导出,以及尚未实施的 Maa v2 信息/决策/执行工作流大纲 | -### 用户文档 -- 脚本语法文档 -- 功能使用说明 +## 📎 其他 -### 维护状态 -- ✅ **已实现**: 系统架构和模块系统 -- ✅ **已完成**: 脚本系统和虚拟机 -- 🔄 **持续更新**: 文档与代码同步 +- `架构图drawio.drawio` — 分层架构图源文件 +- `../baselines/` — 产物尺寸与解释器性能基线(尺寸门 N1②、性能回退判据的对照物) -## 📖 阅读建议 +## 阅读路径 -### 新手入门路径 -1. 阅读 [系统架构](Framework.md) 了解整体设计 -2. 学习 [脚本语法](Script.md) 掌握脚本编写 -3. 参考 [模块系统](models.md) 理解扩展机制 +- **写脚本**:GETTING_STARTED → Script → Functions +- **改编译器/VM**:Pipeline → ModuleSystem → VM2(双端语义以对拍测试为准绳,见 `test/EasyCon.Tests/Bytecode/`) +- **改宿主/UI**:Framework → `AGENTS.md` 分层规则 → `src/EasyCon.Core/README.md` +- **做编排/画布**:Flow(+ `frontend/README.md`;改节点必须同时改 `FlowNodeCatalog` 与 `FlowNodeRuntime`) -### 开发者路径 -1. 系统架构 → 了解设计原则 -2. 模块系统 → 理解接口设计 -3. 虚拟机文档 → 深入底层实现 +## 维护规则 -## 🔗 相关资源 - -- [主项目README](../README.md) -- [源代码目录](../src/) -- [详细设计文档](../src/EasyCon2.Avalonia/DOCUMENTATION_INDEX.md) - -## 📝 文档信息 - -**状态**: ✅ 已更新至最新架构 -**最后更新**: 2026年4月16日 -**版本**: 2.0 - ---- - -如有问题或建议,请通过 [GitHub Issues](https://github.com/EasyConNS/EasyCon/issues) 反馈。 +- 每份文档头部标**状态**(现状 / 已实施·含实施记录 / 方案·未实施);改代码同步改文档。 +- 格式/指令/契约改动遵循各文档内的 checklist(VM2 新增指令流程;ABI/语义以对拍测试期望为准)。 +- 历史文档不保留在 docs/(v1 设计、旧格式规格、已并入的调研均在 git 历史)。 diff --git a/docs/Script.md b/docs/Script.md index 4499e752..7e7c5e5e 100644 --- a/docs/Script.md +++ b/docs/Script.md @@ -498,7 +498,27 @@ $攻击力 = 50 $名字 = "勇者" ``` -### 5.2 数据类型 +### 5.2 常量与弃赋值 + +以 `_` 开头(不带 `$`)的标识符是**常量**:声明时必须赋值,且右侧须为编译期可求值的表达式(字面量、其他常量及其算术组合),求值结果折叠进使用点;同名常量重复定义报错。常量在编译期固定,不占运行时存储。同一常量在函数外声明即为模块级常量,库文件中的常量对本模块可见(跨模块不导出)。 + +```ecs +_MAX_HP = 100 +_NAME = "勇者" +_RATIO = 1.5 +_ENABLED = true + +PRINT _MAX_HP # 100 +_MAX_HP = 200 # 错误:常量重复定义 +``` + +`_ = 表达式` 是**弃赋值**:求值右侧表达式但丢弃结果(用于显式消费有副作用的调用)。 + +```ecs +_ = RAND(10) # 消耗一个随机数,不保存 +``` + +### 5.3 数据类型 #### 整数 (INT) @@ -512,13 +532,15 @@ $zero = 0 #### 布尔值 (BOOL) -布尔值通过比较表达式产生,不支持 `true`/`false` 字面量。 +布尔字面量仅支持全小写的 `true`/`false`(`TRUE`/`True` 等写法报错);布尔值也可由比较与逻辑表达式产生。 ```ecs +$ready = true +$busy = false $is_ready = 1 > 0 # true $isValid = 1 > 2 # false -$result = 1 > 2 # false $flag = 5 == 5 # true +$ok = true and not $busy # 逻辑运算 ``` #### 字符串 (STRING) @@ -539,12 +561,15 @@ $backslash = "路径\\到\\文件" #### 数组 -支持同类型数组,使用方括号定义。 +支持同类型数组,使用方括号定义。**初始化元素仅允许编译期常量**(数字、字符串、`true`/`false`、`_` 常量及其一元负号);变量、函数调用与识图标签在语法阶段即被拒绝。需要运行时构造的数组用 `APPEND` 逐个添加。 ```ecs $numbers = [1, 2, 3, 4, 5] $empty = [] $names = ["Alice", "Bob", "Charlie"] +$table = [1, -2, _MAX_HP] +$bad = [$x] # 错误:数组初始化只允许常量元素 +$good = APPEND([], $x) # 运行时构造用 APPEND ``` --- @@ -654,6 +679,10 @@ $a >>= 1 # 右移赋值 ### 7.1 函数定义 +函数参数不再限制为 7 个。参数数受字节码上限(最多 254 个)和**单个函数的 255 槽寄存器预算** +共同约束:参数、调用暂存、存活值和表达式临时值均占槽位,预算不足时会报寄存器溢出。普通函数与 +`EXTERN FUNC` 使用相同的调用规则;GUI 与 CLI 均可调用超过 7 个参数的函数。 + #### 无参数函数 ```ecs @@ -972,26 +1001,39 @@ AMIIBO $slot # 使用变量 ## 第十一部分:模块与导入 -### 11.1 导入库脚本 +### 11.1 导入规则 + +模块可见性由目录结构决定(目录即包,类 Go): + +- **主脚本并列互不打扰**:项目根可并列任意多个主脚本,各自独立编译运行; + 主脚本只默认导入 std/vision/lib,不与同目录其他脚本互见。 +- **模块同目录互相可见**:同一目录内的模块互见函数/结构体/常量,无需 IMPORT;扫描不递归, + lib/ 的各级子目录各自适用该规则。 +- **lib/ 目录自动导入**:主脚本同目录 `lib/*.ecs` 自动对主脚本可见(lib 内文件互见同上)。 +- **跨目录必须显式导入**:导入路径相对**当前脚本所在目录**解析。 + +完整规则与编译序见 [ModuleImportRules.md](ModuleImportRules.md);脚手架可通过 +`ezcon init` 生成(见 §11.4)。 ```ecs -# 导入库文件(必须放在脚本开头) -IMPORT "utils.ecs" -IMPORT "math.ecs" +# 显式导入(跨目录时必须;须放在脚本开头) +IMPORT "net/http.ecs" # → 当前目录 net/http.ecs,模块名 "net/http" +IMPORT "lib/tools.ecs" AS t # AS 别名:只能 t.func() 限定访问 -# 库文件放在脚本同目录的 lib/ 文件夹中 +# 同目录 / lib/ 内的函数直接调用,无需 IMPORT +$r = twice(21) ``` ### 11.2 库脚本规范 -库脚本只能包含: +库脚本(被他人调用的模块)只能包含: - 函数定义(FUNC/ENDFUNC) - 结构体定义(STRUCT/END) - 外部函数声明(EXTERN) -- 全局变量或常量定义 +- 全局变量或常量定义(模块私有,调用方不可见;顶层语句编译为模块初始化,先于 main 执行) ```ecs -# lib/utils.ecs +# lib/math.ecs FUNC max($a, $b):INT IF $a > $b RETURN $a @@ -1011,12 +1053,36 @@ ENDFUNC ### 11.3 标准库 -以下函数自动可用,无需导入: +std 与 vision 标准库自动可用,无需导入: ```ecs $t = TIME() # 获取脚本运行时间(毫秒) ``` +### 11.4 项目初始化(ezcon init) + +CLI 提供脚手架命令,默认在当前目录生成基础项目框架: + +```bash +ezcon init [path] [--force] +``` + +生成结构: + +``` +<项目根>/ +├── main.ecs 主脚本(调用 lib/ 中的 ADD 并打印欢迎信息) +├── lib/mod1.ecs 库模块(FUNC ADD) +├── models/ 模型/字典资产(空) +└── ImgLabel/ 识图标签(空;运行时只识别顶层 *.IL) +``` + +- `path` 缺省为当前目录,目录不存在时自动创建 +- 已存在 `main.ecs` / `lib/mod1.ecs` 时默认拒绝且不做任何写入;`--force` 只覆盖这两个模板文件 +- `lib/*.ecs` 自动导入(规则 R3),`main.ecs` 无需 `IMPORT` +- `models/` 只是脚本相对路径约定(如 `NET_LOAD("models/x.onnx")`),运行前需在项目根目录执行 + `ezcon run main.ecs` + --- ## 第十二部分:FFI 外部函数 @@ -1255,18 +1321,118 @@ ENDFUNC --- -## ONNX 推理实验函数(NET_*) +## ONNX 句柄族(NET_IMAGE / NET_RUNH / NET_SCALE / NET_FREE / NET_UNLOAD) + +> 与 NET_* 同族(L3 名表 + VISION 特征位,单片机拒跑)。大数组驻留宿主侧, +> 脚本只见句柄——原生边界纯标量协议不变。适合卷积类视觉模型(det/rec/分类器)。 + +```ecs +# 图像 → 张量句柄(抓当前画面;负坐标 = 全帧,正坐标 = ROI) +$img = NET_IMAGE(-1, -1, -1, -1, 640, 640, "rgb") # RGB [1,3,640,640],像素 0-255 +$img = NET_IMAGE(100, 50, 200, 80, 320, 32) # 灰度 [1,1,32,320](缺省 mode) + +# 逐元素变换 t2 = t·scale + offset(归一化在此组合,如 1/255) +$t = NET_SCALE($img, 0.003921568627, 0.0) + +# 以句柄张量为输入推理:返回输出元素个数(0 = 失败);NET_OUT(i) 逐元素读取 +$n = NET_RUNH($net, $t) +$o = NET_OUT(0) + +# 释放资源(长脚本建议显式释放张量;会话结束宿主统一回收) +NET_FREE($img) +NET_UNLOAD($net) +``` + +约定: +- `NET_IMAGE` 模式串:`"gray"`([1,1,H,W])| `"rgb"`([1,3,H,W],CHW 平铺)| + `"letter"`(YOLO letterbox:等比缩放至目标内、居中、四周垫 114 灰、RGB 通道); + `"rgb"`/`"gray"` 可加 `'c'` 后缀(如 `"rgbc"`)改为 cover 缩放 + 居中裁剪 + (不变形不垫边,分类模型常规)。`NET_IMAGE_FILE(path, w, h [, mode])` 同管线吃本地图片文件。 +- 像素为 0-255 原始值,模型专属归一化用 `NET_SCALE`(全局仿射)与 + `NET_NORM`(逐通道 (x-mean[c])/std[c],通道号按张量形状自动推断)组合;宿主保持模型中立。 +- 张量形状由宿主按模式自动声明,`NET_RUNH` 校验会话与句柄有效性。 + +--- + +## 分类链路配方(NET_NORM + NET_ARGMAX + NET_LABEL) + +> ResNet 类图像分类模型全链路——预处理策略全部留在脚本可见的 ECS 层 +> (`NET_SCALE` ⊕ `NET_NORM` 任意组合),解码用已修二维的 `NET_ARGMAX`,类名走标签表。 + +```ecs +$net = NET_LOAD("resnet18.onnx") + +# 预处理:cover 裁剪 → /255 → ImageNet 逐通道归一化(每步都是通用原语,可自由替换) +$t = NET_SCALE(NET_IMAGE_FILE("test.png", 224, 224, "rgbc"), 1.0 / 255, 0) +$t = NET_NORM($t, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) + +# 推理 + 解码:[1,1000] 向量输出按 1 行 1000 列解码 +NET_RUNH($net, $t) +$cls = NET_ARGMAX(0) + +# 类名(可选辅助层:不加载标签表就零依赖) +NET_LABELS("imagenet_classes.txt") +PRINT NET_LABEL($cls) +``` + +约定: +- `NET_NORM(tensor, mean, std): PTR` 返回新句柄;mean/std 长度须为 1 或等于通道数,否则 -1。 +- `NET_ARGMAX` **末维 = 类别维**:`[1,T,C]` 逐行(PP-OCR rec/序列解码)、 + `[1,N]` 向量输出按 1 行 N 列(分类 logits)。`NET_ROWS()` 同口径。 +- `NET_LABELS(path): INT`(**行号即索引**:逐行原样占位,空行/空白字符行也是条目, + 仅去行尾 CR——保 PP-OCR 类字典的行号语义;重复加载整体替换,失败 -1)、 + `NET_LABEL(i): STRING`(越界/未加载 → "")。纯字符串处理,不依赖推理能力。 + CTC 字典配合:class id c → `NET_LABEL(c - 1)`(blank 占位)。 +- top-k / 概率序列等自定义解码:`NET_OUT(i)` 循环自写(预处理与解码均可替换的出口)。 + +--- + +## YOLO26 检测配方(YOLO_DETECT + YOLO_* 访问器) + +> std 配方 + 宿主加速洞(与 PP-OCR 的 CTC_FOLD 同一模式)。面向 **YOLO26 端到端导出** +> (Ultralytics 默认导出即端到端,免 NMS):输出 `[1,300,6]` = +> `[x1, y1, x2, y2, conf, class_id]`,坐标在 640×640 letterbox 空间,按分数降序。 + +```ecs +$net = NET_LOAD("models/yolo26n.onnx") +$c = YOLO_DETECT($net, 100, 80, 400, 300, 0.5) # 采集 ROI → letterbox → 1/255 → 推理 → 解码 +$i = 0 +WHILE $i < YOLO_COUNT() + $cls = YOLO_CLS($i) + $s = YOLO_SCORE($i) + $x = YOLO_X1($i) + PRINT $cls + $i = $i + 1 +END +NET_UNLOAD($net) +``` + +约定: +- `YOLO_DETECT(net, x, y, w, h, thresh): INT` 返回命中数;访问器按**命中列表**下标读取, + 坐标已逆映射回屏幕并**裁剪回 ROI 内容区**(Ultralytics predict 同约定,可直接用于点击/绘制; + letterbox 几何由配方计算,与宿主 `"letter"` 模式同式零漂移)。 +- 防呆在配方层:输出元素数 / `NET_ROWS()` ≠ 6(如 `end2end=False` 导出的 `[1,8400,·]`)→ 返回 -1 + (宿主不做形状假设,协议知识归配方)。 +- 越界语义:`YOLO_CLS`/坐标访问器返回 -1,`YOLO_SCORE` 返回 -1.0(与 NET_ARGMAX 同约定)。 +- 类别名映射(class_id → 名称)属用户脚本策略(COCO 或自训模型的类别表脚本自理)。 +- 想自实现解码策略:`NET_ROWS` + `NET_OUT(i*6+j)` 扁平寻址原始输出。 +- 真模型 smoke(OpenCV DNN × YOLO26n 端到端图已验证兼容,见 test/assets/README.md 资产约定)。 + +--- + +## ONNX 推理函数(NET_*) -> 实验面:走 L3 名表,宿主须装配 `IInference` 能力(OpenCvSharp.Dnn 后端); +> 走 L3 名表,宿主须装配 `IInference` 能力(OpenCvSharp.Dnn 后端); > 镜像带 VISION 特征位——单片机参考桩不支持,加载期拒跑(ECS_ERR_FEAT)。 ```ecs # 加载 ONNX 模型,返回会话句柄(-1 = 失败) $net = NET_LOAD("models/demo.onnx") -# 执行推理:输入为数值数组(展平),返回输出元素个数(0 = 失败) -$in = [1, 2, 3] -$n = NET_RUN($net, $in) +# 句柄族:图像/张量大数组驻留宿主,脚本只见句柄 +$img = NET_IMAGE(0, 0, 0, 0, 64, 32, "rgb") # 采集 → 张量句柄 +$t = NET_SCALE($img, 1.0 / 255, -0.25) # 仿射变换(如 PP-OCR 归一化) +$n = NET_RUNH($net, $t) # 推理,返回输出元素个数(0 = 失败) # 逐元素读取输出(越界返回 0) $o0 = NET_OUT(0) @@ -1274,8 +1440,8 @@ PRINT $o0 ``` 约束: -- 纯标量协议——原生调用边界不携带数组,输出只能经 `NET_OUT(i)` 逐个读取。 -- 未装配推理能力时:`NET_LOAD` 返回 -1、`NET_RUN` 返回 0、`NET_OUT` 返回 0(不报错)。 +- 纯标量协议——原生调用边界不携带数组,大数组驻留宿主(张量/输出经句柄引用),输出经 `NET_OUT(i)` 逐个读取。 +- 未装配推理能力时按 S-21 缺省值表降级(不报错):`NET_LOAD` 返回 -1、`NET_ARGMAX` 返回 -1、句柄族(`__NET_IMAGE__`/`__NET_IMAGE_FILE__`/`NET_RUNH`/`NET_SCALE`/`NET_NORM`/`NET_ROWS`/`NET_FREE`/`NET_UNLOAD`)返回 0、`NET_OUT` 返回 0;标签表 `NET_LABELS` 返回 -1、`NET_LABEL` 返回 "";YOLO 侧 `__YOLO_DECODE__`/`YOLO_COUNT` 返回 0、`YOLO_*` 访问器返回 -1/-1.0。 - 模型文件为外部资产,不入库。 --- @@ -1338,4 +1504,4 @@ PRINT $o0 | 比较 | == != < > <= >= | | 逻辑 | and or not | | 字符串 | & + in | -| 赋值 | = += -= *= /= \= %= &= \|= ^= <<= >>= | +| 赋值 | = += -= *= /= \= %= &= \|= ^= <<= >>= | \ No newline at end of file diff --git a/docs/VM1.md b/docs/VM1.md index 7bcdb60a..43a758bc 100644 --- a/docs/VM1.md +++ b/docs/VM1.md @@ -1,5 +1,8 @@ ## V1虚拟机指令集文档记录 +> ⚠️ **历史文档**:单片机端旧指令集(2 字节指令),代码侧仅剩死代码 +> (`src/EasyCon.Script/Assembly/`、`src/EasyCon.Device/V1.cs`)。现役虚拟机见 [VM2.md](VM2.md)。 + - [文档链接](https://docs.qq.com/sheet/DZm1ydlZadkpncUNo?c=A88A0AZ0&tab=BB08J2) 此文档链接记录伊机控脚本在单片机端的虚拟机指令集,作者铃落,向前辈致敬。 diff --git a/docs/VM2.md b/docs/VM2.md index 9887e553..cbff215c 100644 --- a/docs/VM2.md +++ b/docs/VM2.md @@ -3,7 +3,7 @@ > **定位**:ECS 脚本的编译目标与双端执行规范——C# `EcxInterpreter`(桌面)与 C `ecs_vm`(单片机) > 消费同一 ECX 镜像。本文是**现状规范**:指令集、二进制格式、宿主 ABI、分层模型。 > 权威源:`EcsOpcode.cs` / `EcsFormat.cs`(C# 侧)与 `ecs_vm.h`(C 侧)逐项对齐; -> 双端语义契约与测试矩阵见 `docs/VmSemanticContract.md`;编译链路见 `docs/Pipeline.md`; +> 双端语义一致性由 corpus/FullChain/ABI 对拍测试锁定(`test/EasyCon.Tests/Bytecode/`);编译链路见 `docs/Pipeline.md`; > 模块系统见 `docs/ModuleSystem.md`。冲突时以代码为准并修本文。 ## 1. 分层架构 @@ -14,8 +14,10 @@ |----|------|------|------------------| | **L0 机器** | 算术/比较/控制流/容器/结构体/调用 | VM 核指令 | 天然支持 | | **L1 基础能力** | `WAIT`、按键、摇杆、`RAND` | 专用指令(`WaitI/KeyI/Stick*/Rand`),直连宿主回调 | 全支持 | -| **L2 平台 syscall** | `FWRITE/FREAD/文件族/ALERT/ARG/ENV/APP/TIME/BEEP/AMIIBO/OCR_CONF` | `CallN` EXT 旗标 + **编号**(§8.2),语义在宿主参考实现 | 参考桩:静默/默认值(TIME=0、FWRITE 返 len、串返空) | -| **L3 动态原生** | 采集洞 `__CAPTURE__` 系、EXTERN FFI、`ENCODE/JQ`、推理实验 `NET_*` | `CallN` 名表**按名**分发 | 不在功能集:镜像特征位(CAPTURE/FFI/VISION)缺位 → **加载期拒跑** | +| **L2 平台 syscall** | `FWRITE/FREAD/文件族/ALERT/ARG/ENV/APP/TIME/BEEP/AMIIBO/OCR_CONF` | `CallN` EXT 旗标 + **编号**(§7.3),语义在宿主参考实现 | 参考桩:静默/默认值(TIME=0、FWRITE 返 len、串返空、AMIIBO 槽 0–19 越界静默) | +| **L3 动态原生** | 采集洞 `__CAPTURE__` 系、EXTERN FFI、std 加速洞 `__ENCODE__/__JQ__/__CTC_FOLD__`、推理 `NET_*`(含 `NET_NORM`/标签表)、YOLO 解码 `__YOLO_DECODE__` + `YOLO_*` 访问器、det 找框 `__DET_BOXES__` + `DET_*` 访问器 | `CallN` 名表**按名**分发 | 桌面缺能力按 C# 缺省值表降级(OCR/ROI/CTC_FOLD/ENCODE/JQ/NET_LABEL→""、NET_LOAD→-1、NET 句柄族→0、NET_ARGMAX/NET_LABELS→-1、NET_OUT→0.0、YOLO/DET 洞/COUNT→0、访问器→-1/-1.0、FFI→0);**C/MCU 无名表降级与 FFI——native 缺失一律 NOSUCHNATIVE**;strict_caps 双端恢复响亮 | + +> **L3 名表面准入规则**:只收两类——(a) 能力洞(语义由 CapabilitySet 定义:采集/OCR/视觉/推理);(b) std 加速洞(std 定 API 与语义,宿主仅作性能加速器,如 `__CTC_FOLD__`/`__JQ__`/`__ENCODE__`)。实用工具的 API 与语义一律归 std,不得直接占用本面。 组件清单(现状): @@ -32,20 +34,20 @@ | `src/EasyCon.Script/Modules/*` | 模块图/独立编译/接口缓存(`docs/ModuleSystem.md`) | | `src/EasyCon.Vm/native/ecs_vm.h|.c` | C 虚拟机公共 API + 实现(加载器/堆/解释器,纯调度) | | `src/EasyCon.Vm/native/ecs_main.c` | CLI harness = MCU 参考宿主(L2 参考桩 + L1 回调) | -| `ci/build-vm.sh` | CI 构建入口(无 C 工具链则跳过) | +| `ci/build-vm.sh` | 普通 / budget 构建入口;budget 与 CI 强制模式不允许缺编译器时跳过 | --- ## 2. 值模型 -### 2.1 值表示:16 字节 tagged union +### 2.1 值表示:C# 16 字节,C 8 字节 -C# `TaggedValue` 与 C `ecs_value` 同构: +C# `TaggedValue` 保持 16 字节内联载荷。C `ecs_value` 改为 8 字节: ```c typedef struct ecs_value { - uint8_t tag; uint8_t _pad[7]; - union { int32_t i32; int64_t i64; double f64; }; + uint8_t tag; uint8_t _pad[3]; + union { int32_t i32; uint32_t u32; }; } ecs_value; ``` @@ -53,24 +55,32 @@ typedef struct ecs_value { |-----|----|------|------| | VOID=0 | — | — | 句柄 0 同时表示 null | | BOOL=1 / BYTE=2 / INT=3 / UINT=4 | i32 | BOOL 规范化 0/1;UINT 无符号解释 | -| UINT64=5 | i64 | | -| DOUBLE=6 | f64 | | -| STRING=7 | i64 低 32 位 = 句柄 | UTF-16LE code unit 存储;LEN 按 code unit | -| ARRAY=9 | 句柄 | 元素为定长 `ecs_value` 数组 | -| PTR=10 | i64 | FFI 指针 | +| UINT64=5 | C# i64 / C 对象池箱句柄 | 数组内为裸存 8B 位模式 | +| DOUBLE=6 | C# f64 / C 对象池箱句柄 | 数组内为裸存 8B IEEE754 | +| STRING=7 | 32 位句柄 | UTF-16LE code unit 存储;LEN 按 code unit | +| ARRAY=9 | 句柄 | C 按元素 tag 定宽打包;宽元素 8B,内联标量和引用 4B | +| PTR=10 | C# i64 / C 对象池箱句柄 | 原始指针位模式经宿主辅助 API 读写 | | STRUCT=12 | 句柄 | 字段按布局展开的槽区 | **标签可观察性**:运行时只有 STRING/ARRAY/STRUCT 的标签被解引用检查;BOOL/BYTE/INT/UINT 的标签差异不可观察(读取方式由编译期类型特化的指令决定)。编码器可用 `LoadI`(INT 标签)装 BYTE/UINT 常量,仅 BOOL 用 `LoadBool` 保持 0/1 规范化。 +双端共享标签编号、指令和 ECX1 格式,不共享原生值槽布局。C 原生宿主必须重新编译, +宽值使用 `ecs_vm_arg_wide` / `ecs_vm_ret_wide`,DOUBLE 使用 +`ecs_vm_arg_double` / `ecs_vm_ret_double`;不得读写已删除的 `i64/f64` 字段。 +返回辅助函数创建出生引用,由 CallN 接管。池耗尽返回 `ECS_ERR_POOL`,非法宽返回 tag 返回 +`ECS_ERR_TYPE`;宿主忽略返回码时 VM 仍传播错误。内存档案与迁移说明见 [VmBudget.md](VmBudget.md)。 + ### 2.2 堆:句柄表 + 引用计数 - 句柄从 1 起,0 = null;句柄表 + 空闲链复用,恒不移除/移动元素。 -- **字符串驻留(intern)**:常量池字符串**首次执行时驻留**(缓存持常驻引用,句柄永不归零;对齐 - C# `EcxInterpreter.InternedString` 与 C 加载期驻留);运行期字符串(CAT/切片/转换)新建句柄。 +- **常量字符串 pinned**:双端使用带 bit31 标记的常量池索引,不占运行对象池,不参与引用计数; + C 字符数据直接借用镜像,因此镜像必须在 VM 生命周期内有效。运行期字符串(CAT/切片/转换)新建句柄。 字符串不可变,复制一律共享。 - 数组:`{elem_type, items[]}`;结构体:`{layout*, slots[]}`,槽区按 StructDef 布局展开。 - **引用计数不回收环**:ECS 无闭包,实践值为树形;停机随 VM 销毁全量释放。 - 自洽不变量:`alloc − release = live = 非空槽位数`(`HeapCountsConsistent` 锁定)。 +- C 宽标量箱不可变,参与引用计数。数组读取宽元素产生出生箱,复制/拼接时直接复制 8B 位模式; + membership / TOSTR 的临时箱必须释放。对象池耗尽是可诊断错误,不能静默返回 0。 ### 2.3 拷贝语义(S-01,对拍权威) @@ -109,21 +119,32 @@ ToStr ToInt`。 ## 3. 指令集 -### 3.1 编码格式(小端) +### 3.1 编码格式(小端;定长指令,ECX1 平铺容器) -| 格式 | 布局 | 用于 | +| 格式 | 布局(v3 定长,即线格式) | 用于 | |------|------|------| -| iABC | `op:8 \| A:8 \| B:8 \| C:8` | 三地址运算、域操作 | -| AsBx | `op:8 \| A:8 \| sBx:16` | LoadI、Jpt/Jpf(s16) | -| ABx | `op:8 \| A:8 \| Bx:16` | LoadK / NewArrE / NewSt / Img / WaitI / KeyI | -| IsJ | `op:8 \| s24:24` | Jmp | -| EXT | iABC + 后随 `ext:32`(8 字节) | Call/CallN 目标、Slice end 槽、GetFI/PutFI 索引槽、StickP/StickPv、NewArrV 元素类型码 | - -**EXT 铁律**:EXT 后随字是数据,数值可能恰等于操作码——一切线性扫描按 `EcsFormat.ExtWords` / -`ecs_op_has_ext` 步进跳过。权威表:C# `EcsFormat.BuildTable`(漏登自检抛出)↔ C `ecs_op_has_ext`。 - -容量上限(编码期强制):槽位 ≤ 255、Bx ≤ 65535、单函数 ≤ 2²⁴ 字;Jpt/Jpf 偏移越界由编码器 -跳转反转模板消化。 +| iABC | `op:8 \| A:8 \| B:8 \| C:8`(4B) | 三地址运算、域操作 | +| AsBx | `op:8 \| A:8 \| sBx:16`(4B) | LoadI(s16 立即数;超界编译期归一 LoadK)、Jpt/Jpf(s16 **字节**偏移) | +| ABx | `op:8 \| A:8 \| Bx:16`(4B) | LoadK / NewArrE / NewSt / Img | +| IsJ | `op:8 \| sJ:24`(4B,**字节**偏移) | Jmp | +| Ext | iABC + 后随 `data:32`(8B) | Call/CallN 目标、Slice end 槽、GetFI/PutFI 索引槽、StickP/StickPv、NewArrV 元素类型码、**WaitI/KeyI 时长**(v3 起) | +| IabcJ | iABC + 后随 `jump:32`(8B) | **ForStep/CmpJ** 第 4 操作数(跳转**字节**偏移;v3 起) | + +> **单流化 + v3 定长(已实施)**:内存唯一表示为解码形态 `EcsInstruction`(完整 int32 操作数, +> Jump = 相对指令下标);文件的 **v3 定长指令流**(ECX1 `.text`)与内存按上表一一对应, +> 投影/抬升由 `InstructionCodec` 单处换算(线格式跳转 = 相对**字节**偏移:目标 = 指令起始 + +> 本指令字节数 + delta)。操作数位宽由格式锁定:槽位 u8 ≤254(寄存器 ≤255 帧长上限,无哨兵值—— +> Call/CallN 的 C 恒为接收寄存器号);AsBx=s16;ABx=Bx16(≤65535,常量池 16 位索引是 pinned 句柄编码前提); +> IsJ=s24。越界一律**编译期响亮失败**。 +> 权威表:C# `EcsFormat.BuildTable`(漏登自检抛出)↔ C `ecs_op_words`(字数 1=4B/2=8B)。 + +**数据字**:12 条带字指令(Ext 8 条 + ForStep/CmpJ/WaitI/KeyI)的数据字是「数据」——数值可能 +恰好等于某个操作码,一切线性扫描必须按 `EcsFormat.WordCount` 步进跳过(历史 F4 缺陷铁律, +v3 随定长布局回归)。 + +容量上限(R-3):槽位上限 254 已前移到**编译期**(v3 槽位 u8,与宿主档案 `ECS_MAX_SLOTS=255` +对齐);加载期校验保留(nslots ≤ 宿主档案)。Jpt/Jpf 偏移越界由编码器跳转反转模板消化, +超 s16 由 `Project` 响亮失败。 ### 3.2 指令表 @@ -172,7 +193,7 @@ KeyI a, Bx / KeyV a, b 点击按键 a(GamePadKey 码)持 KeySt a, b b=1 按住 / b=0 松开 StickSet a, x, y 设摇杆 a(0=L,1=R) 坐标(回中 128) StickP a, x, y, ext:32 点击摇杆,ext=毫秒 -StickPv a, dur, ext:32 ext 高16 位 x | 低16 位 y,持续 R[dur] 毫秒 +StickPv a, dur, ext:32 ext 低16 位 x | 高16 位 y(x=ext&0xFF, y=(ext>>16)&0xFF,与编码器 x|(y<<16) 一致),持续 R[dur] 毫秒 Img a, Bx R[a] = 图像标签匹配置信度(标签名=常量池[Bx]) Rand a, b R[a] = rand(R[b])(S-12) ``` @@ -182,32 +203,57 @@ LCLICK=11 RCLICK=12 HOME=13 CAPTURE=14`;HAT `TOP=16..TOP_LEFT=23`;摇杆 `LS (StickSet/StickP 的 side 参数:0=L, 1=R)。摇杆坐标 ∈ [0,255],回中 (128,128);方向→坐标换算 在 C# 编码期完成,VM 不理解角度。 -### 3.3 槽位模型 +### 3.3 寄存器与槽位模型(每函数栈窗口) + +**帧模型**(Dalvik/Lua 式):帧 = 函数自身的寄存器窗口,帧长 = 函数寄存器高水位(`EcsFunction.NSlots` +≤ 255,指令 A/B/C 域 u8 是唯一上限——项目代码量增长不牵动寄存器堆)。调用时在共享值栈上按被调方 +帧长开窗、返回时弹出。帧内布局: ```text -[0 .. nParams) 参数符号槽(恒等映射:Call 实参窗 + TRE 回边 store) -[nParams .. nLocals) 存活局部符号槽(编码期压缩重编号;优化删除的局部不占槽) -[nLocals ..) 专用槽:phi 结果(死 φ 免分配)与跨块值 -(..) 保留区:Neq 中间槽、并行副本 scratch、实参 staging、返回值 receive -(.. nSlots) 块内槽池:块内值与常量,定义/物化时取用,末次读取归还 -nSlots ≤ 255 +R0 RET:返回值固定落点(Call 的 C=0,无显式接收时) +R1 .. NParams ARG 参数窗:仅占用函数声明的参数数(S-17 深拷贝) +NParams+1 起 STAGING:按本函数最大出站调用/数组分块需求保留 +STAGING 之后 分配区:独占槽(φ/ForStep/循环携带值/编码器临时)→ 池区 +frameLen ≤ 255 帧长 = NSlots = 发射结束后池区高水位 ``` -- **编码期槽位回收**:非参数 `StoreLocal` 不发射——局部读值全走 SSA(构建期 mem2reg), - 帧槽无读取者,对无人读取槽的写入不可观察;参数 store 保留(Call ABI 播种 + TRE 回边是 - 真实读取者)。仍被引用的局部符号槽压缩重编号到参数窗之后(`AssignSlots` 步骤 0)。 -- **池化判据**:phi 恒专用(**全函数零读取的死 φ 不分配槽、前驱边不产生副本**);其余值全部读取 - 都在定义块内 → 池化;跨块 → 专用槽。phi 臂读取按臂↔前驱对齐计入前驱块。 -- **常量块首惰性物化**:常量在每个使用块的块首物化(`LoadI/LoadBool/LoadK`),末次读取归还; - 零使用不物化。立即数操作数(`KeyI/WaitI/StickP` 常量时长)不占槽。 -- **链接期死存储清扫**(`DeadStoreSweep`):反向活跃性删除落槽/边副本残留的死 - `Move/SetVar/LoadI/LoadBool/LoadK/LoadG`(防线 3),pc 重映射保持跳转合法。 +- **生命周期分配**(`AssignSlots`,参考线性扫描寄存器分配):非常量值统一池化——定义发射 + (或首次读取触碰,先到先得)时取槽,**末次静态读取结算后归还**(`ReleaseDying` 全函数读取 + 计数归零);跨迭代安全:循环内的读取点不会在归还后再执行。常量按使用块块首物化(每块独立 + 槽 + 块内计数归还)。 +- **独占槽**(不入池):φ 结果与 ForStep 目标(跨迭代被边副本/分支原地写)、**循环携带值** + (`CollectLoopCarriedValues`:定义块不在其读取所在自然循环体内——线性扫描对回边活性的盲区, + 素数筛语料实证)、编码器临时(Neq/scratch/模板守卫组)。φ 臂合并(下 P1b)后臂须退出池化 + 结算,否则会把 φ 独占槽错放回空闲池。 +- **参数数没有独立的 7 项上限**:声明上限为 `MaxRegisters - ArgBase = 254`,实际编译还须让 + 参数、出站窗口、存活值和临时值一起满足 255 槽帧预算;预算不足报寄存器溢出。无出站调用或 + 数组构建的函数不保留 STAGING。零参数调用的 A 取 R0,不读取实参。 +- **STAGING 与参数/分配区物理隔离**:出站实参 Move 不会覆写存活值,嵌套调用的结果先放在 + 分配区再搬入出站窗口。链接期内联将壳体的整个窗口搬到调用者未使用的区间,先从调用点 A + 复制参数,再统一重映射壳体寄存器;各调用点复用这段窗口。C VM 原生实参栈快照按 B 的实际 + 参数数保留,不使用固定 8 项数组,也不增加运行期堆分配。指令/容器 ABI 不变;编译缓存 M2 + 使旧布局的产物和旧参数溢出错误缓存失效。 +- **编码期槽位回收**:非参数 `StoreLocal` 不发射——局部读值全走 SSA(构建期 mem2reg);参数 + store 保留(Call ABI 播种 + TRE 回边是真实读取者)。 +- **常量块首惰性物化 + 循环不变量外提**:常量在每个使用块的块首物化(`LoadI/LoadBool/LoadK`), + 末次读取归还;零使用不物化。`HoistLoopInvariantConsts`(AssignSlots 步骤 1.5)把「全部使用块 + 都被某循环 preheader 支配、且循环体内使用块在脊柱上」的常量改挂 preheader 块首物化一次; + 外提常量按共享组定槽(双向 BFS 守卫防覆写读陈值)。立即数操作数(`KeyI/WaitI/StickP` 常量 + 时长)不占槽。 +- **链接期死存储清扫**(`DeadStoreSweep`):反向活跃性删除死 `Move/SetVar/LoadI/LoadBool/LoadK/ + LoadG`(防线 3)。活跃性 CFG 的终结符集含 `ForStep` 与 `CmpJ`(素数筛回归实证);无终结符的 + 直落块必须补 fall-through 边(corpus arith 实证)。 +- **发射优化**:块尾跳转消除(P1a)、phi 臂二地址合并(P1b,合并臂 ScrubPooled 退出池化)、 + CmpJ 比较跳转融合(P2′)、常量模板降级(全常量 ≥8 元素数组 → 守卫式模板全局)。 + 数组/字符串字面量自语言层即限常量(语法阶段白名单),可在编译期完整固定。 ### 3.4 调用约定 -实参连续暂存调用者 `R[base..base+n)`,`Call` 深拷贝进被调帧槽 `0..n-1`;返回值经接收槽 C 深拷贝 -交接(255 = 无)。递归天然支持;深度上限 `ECS_MAX_CALL_DEPTH`(512)→ `ECS_ERR_DEPTH`;尾递归 -由 SSA 优化器(TRE)变循环回边,VM 无 tail-call 指令。 +实参连续暂存调用者出站窗口 `R[a..a+n)`(A 随调用点编码,位置见 §3.3),`Call` 深拷贝播种进 +被调方参数窗 `R[1..1+n)`(S-17);接收槽 C 恒为寄存器号(无哨兵值),返回值深拷贝交接 +(C=0 即 RET 落点)。实参无独立固定上限:编码上限 254,实际与出站窗口、存活值共享 255 槽帧 +预算(§3.3;绑定层声明校验 + 编码器预算校验)。递归天然支持;深度上限 `ECS_MAX_CALL_DEPTH`(64,双端对齐) +→ `ECS_ERR_DEPTH`;尾递归由 SSA 优化器(TRE)变循环回边,VM 无 tail-call 指令。 --- @@ -253,7 +299,7 @@ Link 在深拷贝副本上进行,不污染调用方产物(`.ecm` 可安全 r 末 entry:u32 ``` -原生名表只承载 L3(采集洞/FFI/ENCODE/JQ);L2 syscall 编号直传 EXT,不占名表 +原生名表只承载 L3(采集洞/FFI/std 加速洞 `__ENCODE__/__JQ__/__CTC_FOLD__`);L2 syscall 编号直传 EXT,不占名表 (纯内建脚本名表为空)。 --- @@ -266,34 +312,56 @@ Link 在深拷贝副本上进行,不污染调用方产物(`.ecm` 可安全 r src/EasyCon.Vm/native/ ecs_vm.h(唯一头文件)/ ecs_vm.c(加载器+堆+解释器)/ ecs_main.c(CLI harness) 构建:cc -O2 -std=c99 -Wall -Wextra -o ecs-vm ecs_vm.c ecs_main.c -lm(单翻译单元可合并) 嵌入式:两文件加入工程;malloc/free 经宏重定向到池分配器即可 -CLI:ecs-vm run image.ecx [--trace] [--print] [-- arg0...] +CLI:ecs-vm run image.ecx [--io] [--trace] [--print] [--strict-caps] [-- arg0...] + --io = 经 ecs_io 读取器加载(模拟 EEPROM/NVS 等位置的字节码来源);缺省 = RAM 缓冲直载(可 XIP) stdout = PRINT 输出;stderr = --trace 域事件 TSV(KEY/KEYST/STICK/STICKC/WAIT/AMIIBO/BEEP, 与 C# EcxHost.EnableRecording 同格式);--print 为 PC 验证通道(缺省 = MCU 精确语义) ``` ### 6.2 结构与主循环 -- 加载期:解析表指针直指 image 缓冲(零拷贝可 XIP)+ 全量校验 + 特征校验(§8.3)+ 结构体布局 - 展开(slot_offset 计算,嵌套递归 + 环检测)。 -- 堆:句柄表(`{kind, rc, payload}`)+ 空闲链;帧 = `malloc(sizeof(frame) + nslots*sizeof(ecs_value))`, - 弹帧全槽释放。 -- 主循环:非递归调用(压帧/改 PC/弹帧);步数预算 `ECS_DEFAULT_BUDGET`(100 万步)耗尽 → - `ECS_YIELD`(宿主轮询/心跳后重入续跑);取消 → `ECS_CANCELLED`;错误带 `error_func/error_pc` 现场。 +- 加载期(信任面):解析表指针直指 image 缓冲(零拷贝可 XIP)+ 结构边界防护(r_* 越界即拒, + 宿主内存安全)+ strict_caps 特征校验(§6.3)+ 结构体布局展开(slot_offset 计算,嵌套递归 + + 环检测)。**相信字节码**(Lua 式最小验证面):不做 CRC、不做指令流内容校验(validate_stream + 已随信任面收缩删除)——执行期从镜像定宽直取(4B/8B,格式类查表),XIP 原地执行。 +- 字节码来源(`ecs_io`,uLua zIO 式随机访问读取器):RAM 缓冲(`ecs_io_mem` 直载,可 XIP)、 + 内部 flash(内存映射 → 直载零拷贝)、EEPROM/NVS(read 回调分块拉入 VM 内部副本, + `ecs_vm_load_io`,副本由 VM 持有并在 `ecs_vm_free` 释放)——**读取这些位置的字节码解释执行**。 +- 堆:固定对象池(单块 = 一对象,空闲链);帧 = arena bump + 整帧清零, + 弹帧全槽释放后水位回退。 +- 主循环(定长直取):fr/code/end/R/pc/idx/steps 循环局部化,格式类查表 + switch 取指 + (WJ 跳转字延迟读),ret_pc(字节)/ret_idx(指令下标)仅在调用边界与让出点写回;非递归调用 + (压帧/改 PC/弹帧);步数预算 `ECS_DEFAULT_BUDGET`(100 万步)耗尽 → `ECS_YIELD`(宿主轮询/ + 心跳后重入续跑);取消 → `ECS_CANCELLED`;错误带 `error_func/error_pc` 现场(pc = 指令下标)。 - CallN:`bit31=1` → `host.syscall(id, args, nargs, &ret)`;否则 `host.native(name, args, ...)`; 回调缺失/非 0 返回 → `ECS_ERR_NOSUCHNATIVE`。 -### 6.3 特征校验与错误码 +### 6.3 特征元信息与能力降级(S-21) + +声音识别使用 `AUDIO = 0x20` 特征位,`AUDIO_LOAD` / `AUDIO_SCORE` / `AUDIO_UNLOAD` 通过 L3 名表路由。LOAD/UNLOAD 返回 VOID,SCORE 返回 0~100 的 INT 累计峰值(百分制向下取整);缺少能力不能降级为 0 分。桌面执行桥在执行任何脚本操作前要求 `IAudioService` 并建立独立运行会话,只有 LOAD 才启动对应标签的后台识别;会话负责正常结束、出错和取消时的清理。C VM 即使没有启用 `strict_caps`,缺少 `ECS_FEAT_AUDIO` 仍在加载期返回 `ECS_ERR_FEAT`(14)。没有原生实现的直接解释器调用保持 `ERR_NOSUCHNATIVE`,不进入缺省值表。具体函数语义见 `docs/Functions.md`。 ```text -加载期:feats = 头部掩码 | (flags.I ? FEAT_IL : 0) - feats & FEAT_IL → ECS_ERR_IL (13,既有码) - feats & ~host->feats != 0 → ECS_ERR_FEAT (14) +feats = 头部掩码 | (flags.I ? FEAT_IL : 0) ← 元信息:缺省不拒载 +运行期 miss(宿主回调缺失/未实现): + L2 syscall:双端锁步降级(C# EcsCapabilityDefaults.GetSyscallDefault ≡ C ecs_cap_syscall_default,对拍锁定): + FWRITE→len FREAD/READFILE/ARG/ENV/APP→"" FOPEN→-1 FEof/FILE_EXISTS/TIME/OCR_CONF→0 + FClose/WriteFile/AppendFile/Alert/Beep/Amiibo→no-op(Void) + L3 名表:降级是桌面专属(C# GetNativeDefault:OCR/ROI/CTC_FOLD/ENCODE/JQ/NET_LABEL→""、 + NET_LOAD→-1、NET 句柄族→0、NET_ARGMAX/NET_LABELS→-1、NET_OUT→0.0、YOLO/DET 洞→0、访问器→-1/-1.0)。 + C/MCU 侧无名表判断、无 FFI——native 缺失一律 ECS_ERR_NOSUCHNATIVE 响亮失败(MCU 不支持桌面原生)。 + EXTERN FFI:桌面 C# 缺能力 → int 0;C/MCU 无 FFI → NOSUCHNATIVE。 + 表外(未知编号/未知名)双端都不降级 → ECS_ERR_NOSUCHNATIVE + 图像标签(Img)→ 目标槽 ← -1 +strict_caps 宿主(ecs-vm --strict-caps / C# EcxHost.StrictCaps)恢复响亮: + 加载期 feats & FEAT_IL → ECS_ERR_IL(13,先判);feats & ~host->feats → ECS_ERR_FEAT(14); + 运行期 miss → ECS_ERR_NOSUCHNATIVE 错误码全集:OK/YIELD/CANCELLED/IMAGE/OPCODE/SLOT/TYPE/INDEX/DIVZERO/DEPTH/ - NOSUCHNATIVE/HOST/OOM/IL/FEAT;错误现场 error_func/error_pc + NOSUCHNATIVE/HOST/OOM/IL/FEAT/CRC/POOL;错误现场 error_func/error_pc + (pc = 函数内指令下标,与 C# ErrorPc 同单位) ``` -宿主通过 `host->feats` 声明提供的能力:MCU 参考宿主 = `FEAT_FILE`(L2 桩覆盖文件族静默行为); -采集洞/FFI/图像标签缺位即加载期拒跑——比运行期错误更强的保证。 +宿主通过 `host->feats` 声明提供的能力(元信息 + strict 档校验依据);烧录前预检 +(McuBytecodeDelivery §4)仍消费该掩码。L1 域操作(wait/key/stick/rand)不降级。 ### 6.4 引用计数协议(S-19) @@ -321,8 +389,13 @@ CLI:ecs-vm run image.ecx [--trace] [--print] [-- arg0...] | WAIT RAND LEN APPEND STRING INT `__CAPTURE__` 系 | Intrinsic | SSA 内联 → 域指令/纯指令,无宿主调用 | | PRINT TIME | StdlibSource | stdlib ECS 源码(PRINT 体调 FWRITE syscall;TIME 体引用 `__TIME__` RuntimeValue → TIME syscall) | | ALERT ARG ENV APP BEEP AMIIBO OCR_CONF + 文件族 FOPEN..FILE_EXISTS | Syscall | CallN 编号(§7.3)→ `EcxHost.ReferenceSyscall` / C harness 桩 | -| ENCODE JQ | NativeName | CallN 名表 → 宿主 `Native`(EcxVm BuiltinMap) | -| NET_LOAD NET_RUN NET_OUT | NativeName | CallN 名表 → 宿主 `Native`(`IInference` 能力;VISION 特征位;纯标量协议——原生边界不携带数组) | +| `__ENCODE__` `__JQ__` `__CTC_FOLD__` | NativeName | CallN 名表 → 宿主 `Native`(EcxVm BuiltinMap;std JQ/ENCODE/CTC_FOLD 的加速洞) | +| `__YOLO_DECODE__` | NativeName | CallN 名表 → 宿主 `Native`(std YOLO_DETECT 的加速洞:形状守卫 + 置信度过滤 + letterbox 逆映射;lib-only) | +| JQ ENCODE READFILE WRITEFILE APPENDFILE | StdlibSource | stdlib ECS 源码实现(JQ/ENCODE 体调各自加速洞;READFILE/WRITEFILE/APPENDFILE 组合 FOPEN/FREAD/FWRITE/FCLOSE) | +| NET_LOAD NET_RUNH NET_SCALE NET_ARGMAX NET_ROWS NET_FREE NET_UNLOAD NET_OUT | NativeName | CallN 名表 → 宿主 `Native`(`IInference` 能力;VISION 特征位;纯标量协议——原生边界不携带数组) | +| NET_NORM `__NET_IMAGE_FILE__` | NativeName | CallN 名表 → 宿主 `Native`(同上 VISION 特征位;NORM 逐通道归一化、FILE 洞本地图片走同管线;lib-only 洞不进 root scope) | +| NET_LABELS NET_LABEL | NativeName | CallN 名表 → 宿主 `Native`(标签表;纯字符串处理,不挂 VISION 特征位) | +| YOLO_COUNT YOLO_CLS YOLO_SCORE YOLO_X1 YOLO_Y1 YOLO_X2 YOLO_Y2 | NativeName | CallN 名表 → 宿主 `Native`(读 `__YOLO_DECODE__` 命中缓存;VISION 特征位;越界 -1/-1.0) | | EXTERN "库" FUNC | L3 FFI | CallN 名表 `"库!导出名"` → EcxVm externMap → NativeLoader | ### 7.1 分层接口(`ecs_host`,全部回调可 NULL) @@ -330,7 +403,8 @@ CLI:ecs-vm run image.ecx [--trace] [--print] [-- arg0...] ```c typedef struct ecs_host { void *ud; - uint32_t feats; /* 提供的特征位(ECS_FEAT_*),加载期校验 */ + uint32_t feats; /* 提供的特征位(ECS_FEAT_*);strict_caps 时加载期校验 */ + int strict_caps; /* S-21:0 = 缺省值降级(缺省);1 = 恢复响亮 */ const char *const *args; int32_t nargs; /* ARG */ const uint16_t *app_dir; /* APP */ /* L1 域操作 */ @@ -369,7 +443,7 @@ void ecs_vm_ret_str(ecs_vm *vm, ecs_value *ret, const uint16_t *units, int32_ | 13 | APP | 应用目录 | | 14 | TIME | 运行毫秒(MCU 参考桩恒 0) | | 15 | BEEP | 蜂鸣(MCU 静默) | -| 16 | AMIIBO | 切换槽位;n>9 静默忽略(S-13) | +| 16 | AMIIBO | v2.3 Amiibo 槽位选择:槽 0–19 宿主判越界静默(S-13);无接收槽——任何宿主不写寄存器 | | 17 | OCR_CONF | OCR 置信度查询(桌面默认 0) | ### 7.4 参考宿主 @@ -377,11 +451,11 @@ void ecs_vm_ret_str(ecs_vm *vm, ecs_value *ret, const uint16_t *units, int32_ | 宿主 | 文件 | 覆盖 | |------|------|------| | 桌面参考 | C# `EcxHost.Syscall`(默认装配)+ `EcxVm`(CapabilitySet 能力装配:BuiltinMap/FFI/采集洞) | 全 L2 语义(行断 + Caps 门控 + 文件转发) | -| MCU 参考 | C `ecs_main.c` 桩(`h_syscall` + `feats = FEAT_FILE`) | 文件族静默/默认值;TIME=0;ALERT/BEEP/AMIIBO no-op;ENV=getenv;ARG=harness args | +| MCU 参考 | C `ecs_main.c` 桩(`h_syscall` + `feats = FEAT_FILE`) | 文件族静默/默认值;TIME=0;ALERT/BEEP no-op;AMIIBO 槽 0–19 trace(越界静默);ENV=getenv;ARG=harness args | | 桌面全量 | C `EcxInterpreter` ↔ `EcxVm`;`ecs-vm --print` 通道 | corpus / FullChain 双端对拍锁定 | 一致性责任面:仓库内两端由 corpus/FullChain 逐字锁定;第三方宿主对照 §7.3 表 + 参考桩 + -`VmSemanticContract` 自行验证。 +`EcxInterpreter.cs`(C# 语义参考实现)自行验证。 --- @@ -390,10 +464,11 @@ void ecs_vm_ret_str(ecs_vm *vm, ecs_value *ret, const uint16_t *units, int32_ | 项 | 预算 | |----|------| | ECX 镜像 | flash/XIP(光速过帧 540 B,含调试区;stripDebug 更小) | +| 指令流 | **零 RAM**:v3 定长 4B/8B,镜像 XIP 原地取指(无解码缓存、无解码段) | | 帧区 | `max_depth × max_slots × 16B`(光速过帧 608 B) | | 堆 | 句柄表 + 对象,池大小可配(4–64 KB) | -| VM 本体 | ecs_vm.c ~1800 行 C99 单翻译单元,`cc -O2` 一条命令构建 | -| 调用深度 | `ECS_MAX_CALL_DEPTH` 512;帧按需 malloc,用户递归不耗宿主栈 | +| VM 本体 | ecs_vm.c ~2200 行 C99 单翻译单元,`cc -O2` 一条命令构建 | +| 调用深度 | `ECS_MAX_CALL_DEPTH` 64(与 C# 对齐);帧 = 每函数栈窗口 arena bump,帧长按需(参数窗 + 出站窗 + 分配区,无全局地板——无出站调用的叶子函数可小至数槽),深度预算按活跃路径帧和估算(McuBytecodeDelivery.md §3 C4);用户递归不耗宿主栈;深递归由 TRE 兜底 | --- @@ -406,10 +481,10 @@ void ecs_vm_ret_str(ecs_vm *vm, ecs_value *ret, const uint16_t *units, int32_ | ABI 契约 | `AbiContractTests`(编号稳定、L2 编号化名表为空、特征掩码、C VM 加载期拒跑) | | 语义协议 | `InterpreterProtocolTests`(YIELD/取消/错误现场)、`InterpreterHeapRefcountTests`(RC 自洽)、`CowSemanticsTests`(COW 双态) | | 链接优化 | `LinkOptimizationTests`(死函数/死存储/死元素)、`BytecodeSizeAnalysisTests`(体积剖析) | -| 门禁 | 每次语义/结构改动:全量 735 双态 + LSP 66 + `dotnet format --verify-no-changes` + corpus 双端对拍 | +| 门禁 | 每次语义/结构改动:全量测试双态 + `dotnet format --verify-no-changes` + corpus 双端对拍 | -双端不一致时先判定哪端错:修实现而非改期望(corpus 期望即规格)。语义条目 S-01..S-19 与 -RC 协议的权威清单见 `docs/VmSemanticContract.md`。 +双端不一致时先判定哪端错:修实现而非改期望(corpus 期望即规格)。RC 槽写协议权威实现见 +`EcxInterpreter.cs`(MoveToSlot/StoreFresh/OverwriteItem)与 `ecs_vm.c`(move_to/store_fresh)。 --- @@ -422,4 +497,4 @@ RC 协议的权威清单见 `docs/VmSemanticContract.md`。 → ECX
:Call → ... Call PRINT → PRINT 体内 CallN 0x80000001 执行 桌面:EcxHost.Syscall(1) → 行断协议 → Print 委托 → 控制台 MCU :h_syscall(1) → --print 通道开则输出;恒返回 len -``` +``` \ No newline at end of file diff --git a/docs/VmBudget.md b/docs/VmBudget.md new file mode 100644 index 00000000..f681ef7e --- /dev/null +++ b/docs/VmBudget.md @@ -0,0 +1,76 @@ +# C VM 值槽、宿主 ABI 与容量预算 + +## 1. 来源与兼容边界 + +P0 基于上游 dev `78d294b61fcc0d7e4059c2a6f76d20c6d4e62db5`。 +引入 `0000-cvm.patch`,来源提交 `8cded4a0282cd1d8e0f4bdb639a7705c37f5570c`, +作者 jinbo,日期 2026-10-13;补丁 SHA-256: +`F4405009AD39260AE5F0F8993F72630291B7BDA46B51408EA210EC8F08F02647`。 + +这次改变 C 运行时内存表示与宿主 C ABI,不改变 ECS 源码、标签编号、操作码、ECX1 文件格式 +或镜像 `ECS_ABI_REV=1`。C# `TaggedValue` 仍为 16B。所有链接 C VM 的原生宿主必须重新编译。 + +## 2. 值模型与所有权 + +- C `ecs_value` 为 8B,BOOL/BYTE/INT/UINT 内联于 32 位载荷。 +- DOUBLE/UINT64/PTR 的值槽保存对象池箱句柄;箱中保存 8B 原始位模式。PTR=-1 与 64 位边界不可截断。 +- 容器引用和宽值箱参与引用计数;pinned 常量字符串直接借用镜像,不占对象池。 +- 数组内宽元素裸存 8B;读到值槽时创建箱;数组内部复制不创建临时箱。 +- 宿主读取宽值用 `ecs_vm_arg_wide`;读取 DOUBLE 用 `ecs_vm_arg_double`。 +- 返回宽值用 `ecs_vm_ret_wide`,tag 只允许 PTR/UINT64;DOUBLE 用 `ecs_vm_ret_double`。 + 字符串用 `ecs_vm_ret_str`。这些辅助函数创建出生引用,CallN 接管;借用的 args 句柄不得直接赋给 ret。 +- 宽返回函数返回 ECS_OK/TYPE/POOL;失败时 ret=VOID。VM 同时记录错误,宿主忽略返回码也不能伪造成功。 + 缺少能力的合法 -1 哨兵与对象池耗尽必须区分。 + +## 3. 容量档案 + +| 项目 | 普通参考宿主 | budget 档 | +|---|---:|---:| +| arena | 256KiB | 768B | +| 帧段 | 128KiB | 384B | +| 对象段 | 128KiB | 384B | +| 对象块 | 128B | 128B | +| 对象块数 | 1024 | 3 | +| 每值槽 | 8B | 8B | +| 派发 | GCC/Clang computed-goto;其他编译器 switch | C99 switch(ECS_NO_DIRECT_THREADING=1) | + +对象头为 24B,单块剩余载荷 104B;字符串、数组、结构体和宽值箱共享同一池。 +预算档只有 3 个同时存活的对象块,不能承诺任意桌面脚本都能运行。 +帧表存 arena 偏移;帧头尺寸仍与目标指针宽度有关。配置必须满足 8B 对齐和块容量约束,编译期检查。 + +255 个槽是字节码格式上限。384B 帧段无法同时容纳 255 个 8B 值槽;设备适配仍需容量预检。 +`ECS_ERR_POOL` 是正常的资源性拒绝,可与无池的 C# 参考解释器发生资源性差异;错误现场必须可定位。 + +## 4. WAIT 续跑 + +缺省 `ecs_vm_set_wait_yield(vm, 0)`:WAIT 回调保持桌面阻塞约定。 +开启后,WaitI/WaitV 通知宿主时长并返回 `ECS_WAITING=19`,续跑点在 WAIT 后继。 +宿主保存截止时间,在 tick 到期后才再次调用 run;VM 不代替硬件时钟安排等待,不能立即循环吸收 WAITING。 +取消可立即调用 run,返回 CANCELLED;等待回调中取消优先于 WAITING。结束或错误后释放 VM。 + +## 5. 构建与门禁 + +```sh +sh ci/build-vm.sh +sh ci/build-vm.sh budget +ECSVM_REQUIRE_CC=1 dotnet test test/EasyCon.Tests -c Release --filter 'FullyQualifiedName~Bytecode' +``` + +`ECSVM_CC` 可指定编译器或包装脚本;Linux 32 位 CI 用 `ci/cc32.sh`(需要 gcc-multilib)。 +普通/budget 都使用 C99、-O2、-Wall、-Wextra、-Werror。budget 或 ECSVM_REQUIRE_CC=1 缺工具链时失败。 +budget 选择现有 switch 派发以控制 ROM;32 位 MinGW GCC 8.1 的 computed-goto 对象文件 text +为 85,472B,同样 -O2 的 switch 为 50,064B。两种派发使用同一 handler,均由 P0 测试执行; +这项选择不承诺运行速度提升,也不能用代理平台 text 数字替代真实 MCU 链接 map。 +`ECSVM_SIZE` 可指定 size 工具,默认探测 size/llvm-size。 + +补丁定义的容量门禁为:`sizeof(ecs_vm)-sizeof(ecs_host) <= 1024B`, +以及 budget 编译出的 `ecs_vm.o` text(size 首列,含该工具归入的只读段)<=65536B。 +编译器/架构会影响数值,报告必须带版本和目标架构。 + +这不是固件总占用:门禁不含宿主回调、解析元数据、镜像副本、线程栈、通信缓冲及外设状态。 +`ecs_vm_load` 借用有效镜像,可供 XIP;`ecs_vm_load_io` 加载期分配整镜像副本。 +真实 MCU 的链接 map、栈和缓冲占用必须在对应固件工程中测量,本仓库的 PC 代理结果不能替代它。 + +P0 验收由既有 Corpus/Fuzz/FullChain/ABI/Pool 差分,以及 CvmP0Tests 的公开 ABI 探针共同承担。 +新增探针覆盖两种派发、宽值位模式、源/目标重合、3 块池的重复返回与数组操作、资源错误位置、 +嵌套视图/深拷贝和 WAIT/取消。CI 显式执行普通与 budget 构建,并执行 32/64 位差分;关键验证不允许无 cc 时跳过。 diff --git a/docs/VmSemanticContract.md b/docs/VmSemanticContract.md deleted file mode 100644 index 04d0b9a0..00000000 --- a/docs/VmSemanticContract.md +++ /dev/null @@ -1,109 +0,0 @@ -# VM 双端语义契约(VmSemanticContract) - -> **定位**:ECS 字节码双端(C# `EcxInterpreter` ↔ C `ecs_vm.c`)语义与引用计数协议的**强制 checklist** -> 与权威定义(S-01..S-19)。任何指令/语义/RC 改动:改前逐条核对双端锚点,改后跑「锁定测试」列 -> 全部 + corpus 对拍 + `ECS_COW=0` 双态重跑。锚点格式 `文件:符号`;C 锚点均在 -> `src/EasyCon.Vm/native/ecs_vm.c`(单文件)。不变量由全量测试矩阵事实锁定(EasyCon.Tests 731 / -> LSP 66 / corpus 对拍),本表使其可 grep、可审计。 - -## 一、核心语义条目(S-01..S-19) - -| 编号 | 语义定义 | C# 锚点 | C 锚点 | 锁定测试 | -|---|---|---|---|---| -| S-01 | 深浅拷贝边界:`Move`/phi/实参暂存=浅共享;`SetVar`/传参/返回=数组/结构体一层深拷贝、字符串共享 | `EcxInterpreter.DeepCopy` / `DeepCopyCopyOnWrite` | `deep_copy` / `deep_copy_cow` | CowSemanticsTests、CvmCrossValidationTests | -| S-02 | 除零:所有 Div/Mod → `ECS_ERR_DIVZERO` 停机 | `EcxInterpreter.RequireDivisor` | `exec_data_op` DIVZERO 分支 | BytecodeSimulationTests、corpus | -| S-03 | 移位掩码:32 位 `&31`、64 位 `&63` | `EcxInterpreter` 位运算 case | `exec_data_op` OP_ShlI/ShrI | corpus(nqueens_bitwise) | -| S-04 | D2I 饱和:≥INT_MAX→INT_MAX,≤INT_MIN→INT_MIN,NaN→0 | `EcxInterpreter.Conv`(DoubleToInt) | OP_Conv CV_DOUBLE_TO_INT | corpus | -| S-05 | RDivI 四舍五入整除 `(a+b/2)/b`,b=0→S-02 | `EcxInterpreter` case RDivI | OP_RDivI | corpus | -| S-06 | 真值 = `i32 != 0`(句柄非 0) | case Jpt/Jpf | OP_Jpt/Jpf | corpus | -| S-07/08 | TOSTR 两层格式:顶层 PTR=十进制;嵌套 PTR=`0x%X`、STRUCT=`struct:名`、ARRAY=`[a, b]`、VOID=`void`;DOUBLE=ToString() | `ToPublicValue` / C# ToString 链 | `tostring_top` / `tostring_nested` | corpus(print 逐字对拍) | -| S-09 | Cont:字符串子串(Ordinal);数组元素 tag 不匹配→false 非错误;非法容器→`ECS_ERR_TYPE` | case Cont | OP_Cont | corpus | -| S-10 | GetI 按元素 tag 归一读(BYTE `&0xFF`、BOOL 归一 0/1);字符串索引返回单字符 STRING | case GetI | OP_GetI | corpus | -| S-11 | Cat:任一侧 STRING→双侧 TOSTR(顶层) 拼接;两侧数组→元素 tag 一致才拼;否则 `ECS_ERR_TYPE` | case Cat | OP_Cat | corpus | -| S-12 | Rand:max=0→0;max<0→错误;[0,max) 宿主注入 RNG | case Rand | OP_Rand | corpus | -| S-13 | Amiibo:n>9 静默忽略 | case Amiibo | OP_Amiibo | corpus | -| S-14 | FWRITE 行断协议:`\` 结尾剥掉并挂起下一行换行(pending_break);句柄 0 写=no-op 返 len;2=print(newline);>2→f_write | 宿主 `IIoAdapter` + `EcxNativeContext` | FWRITE native + `pending_break` | CvmCrossValidationTests(事件 TSV) | -| S-15 | Slice/索引越界不 clamp → `ECS_ERR_INDEX` | case Slice | OP_Slice | corpus | -| S-16 | 结构体字段三种类(Scalar/FixedArray/Nested);Boxed 访问→`ECS_ERR_TYPE`;PutF 写 BYTE 截断 `&0xFF`;嵌套 GetF 视图写穿透 | `WriteStructSlot` / GetF case | OP_GetF/PutF + `sync_view` | CowSemanticsTests、corpus | -| S-17 | 调用约定:实参深拷贝进新帧槽 0..n-1;返回值深拷贝到接收槽 C≠255;深度上限 512→`ECS_ERR_DEPTH` | case Call/CallN/Ret | OP_Call/CallN/Ret + `push_frame` | InterpreterHeapRefcountTests、CvmCrossValidationTests | -| S-18 | 停机协议:入口返回→OK;取消→CANCELLED;预算→YIELD 可续跑 | `EcxInterpreter.Step` | `ecs_vm_run` 返回码 | InterpreterProtocolTests | -| S-19 | 引用计数协议(见第二节) | 见下 | 见下 | InterpreterHeapRefcountTests(HeapCountsConsistent) | - -## 二、RC 协议(S-19 展开,双端逐点锁步) - -**槽写原语**(任何帧槽/容器元素/结构体槽写入必须经以下原语,双端同名同义;豁免:新帧实参槽 -`nf.Slots[i]` 为全新分配槽区,直写 ≡ StoreFresh): - -| 原语 | 语义 | C# 锚点 | C 锚点 | -|---|---|---|---| -| move_to | 覆写:释放旧值 + retain 新值(借用转移) | `EcxInterpreter.MoveToSlot` | `move_to` | -| store_fresh | 覆写:仅释放旧值(新值为出生引用/标量) | `EcxInterpreter.StoreFresh` | `store_fresh` | -| 容器元素覆写 | 旧元素释放 + 新值 retain | `OverwriteItem` | OP_SetI/PutF/PutFI 内联 + `sync_view` | -| 弹帧 | 被弹帧全槽 release | `PopFrame` | OP_Ret/Ret0 全槽释放 | - -**COW(move-on-unique)**:四个深拷贝站点(SetVar 局部 / StoreG 全局 / CALL 实参 / RET 返回)在源句柄 -rc==1 时 `retain` 共享移交、跳过容器拷贝,rc>1 维持深拷贝。安全性依据:rc==1 ⟹ 对象仅被出生临时槽 -持有(临时槽对程序不可见),移交不产生变量间别名,SetI/PutF 原地写安全不变;可观察值语义逐字一致。 -开关 `ECS_COW=0` 关闭(双态测试矩阵锁定开/关一致)。锚点:`DeepCopyCopyOnWrite` ↔ `deep_copy_cow`; -站点与锁定测试见上 S-01/S-17 行与 CowSemanticsTests。 - -## 三、EXT 指令集(编码/扫描/执行三方契约) - -- **权威表**:C# `Bytecode/EcsFormat.cs`(每操作码一行的格式/EXT 语义/结果槽登记,漏登在 BuildTable - 完整性自检抛出);C 侧 `ecs_vm.h` 的 `ecs_op_has_ext`(与 C# 表逐项对齐)。 -- **EXT 集合(8 条)**:`Call`(目标 fid/导入标记)、`CallN`(原生 nid)、`NewArrV`(元素类型码)、 - `Slice`(end 槽,0xFFFFFFFF=省略端)、`GetFI`/`PutFI`(元素索引槽)、`StickP`(时长立即数)、 - `StickPv`(packed xy)。 -- **铁律**:EXT 后随字是「数据」,数值可能恰好等于某个操作码——一切线性扫描必须按 - `EcsFormat.ExtWords` 步进跳过,否则把数据误读为指令。 -- **消费点**:发射自检 `BytecodeEncoder.EmitExt`;扫描 `InstructionScanner`(回调式,链接器各 pass - 共享)与 `EcxLinker`;执行预取 `EcxInterpreter.Step`;反汇编 `EcxDisassembler`;体积分析 - `BytecodeSizeAnalysisTests`。 -- **新增 EXT 指令 checklist**(diff 面应为 2 处表 + 各自语义点): - 1. `EcsOpcode.cs` 加值 + `EcsFormat.BuildTable` 加一行(漏登 → 自检抛出); - 2. `ecs_vm.h` 的 `ecs_op_has_ext` 加 case + C 执行 case; - 3. 发射(`EmitExt`)/执行(解释器 case 消费预取 `ext`)语义; - 4. `InstructionFormatTests` 与 VM2.md §4 同步。 - -## 四、C VM 结构与平台契约 - -- **形态**:单翻译单元 C99(`ecs_vm.h` API 契约 + `ecs_vm.c` + `ecs_main.c` harness),仅依赖 libc; - `sh ci/build-vm.sh`(cc/gcc/clang 探测,全无则跳过)。VM 核对 L2/L3 **纯调度**(取号/取名 → - 宿主回调),无内建语义;行断协议、caps 门控、getenv、ARG 均在宿主参考实现 - (C# `EcxHost.Syscall` / C `ecs_main.c` 桩)。 -- **五区段**:镜像解析(表指针直指 image 缓冲,零拷贝可 XIP,加载即全量校验 + 特征校验 → 堆 - (句柄表 + rc + 空闲链 + §二原语)→ 解释器主循环(压帧/弹帧非递归,YIELD 预算步进可重入)→ - 结构体布局展开(加载期算 slot_offset,嵌套递归 + 环检测)→ 宿主回调分发(L2 编号 syscall / - L3 名表原生,未注册 → `ECS_ERR_NOSUCHNATIVE`)。 -- **L2/L3 分流 ABI**(VM2.md §9.1):`CallN` EXT 字 `bit31=1` → 低 31 位为 syscall 编号 - (FWRITE=1..OCR_CONF=17 全 L2 封闭集,C# `EcsSyscall` ↔ C `ECS_SYSCALL_*`,**不进原生名表**); - `bit31=0` → 原生名表索引(仅 L3:采集洞 `__xxx__` / EXTERN FFI "库!导出名" / ENCODE / JQ)。 -- **特征需求掩码**(镜像头保留位 u16 @0x0A;IL 由 flags.I 投影):加载规则 - `feats & FEAT_IL → ECS_ERR_IL(13)`;`feats & ~host->feats → ECS_ERR_FEAT(14)`——FFI/采集洞 - 镜像加载期拒跑(不再等到运行期);文件族由 MCU 参考桩覆盖(`feats = FEAT_FILE`),行为 = - 静默/默认值(FWRITE 返 len、FREAD/串返空、TIME=0、BEEP/ALERT/AMIIBO no-op,S-13)。 -- **harness**:`ecs-vm run image.ecx [--trace] [--print]`——stdout=输出;stderr=事件 TSV - (KEY/KEYST/STICK/STICKC/WAIT/AMIIBO/BEEP,与 `EcxHost.EnableRecording` 同格式);`--print` 为 - PC 侧验证通道,缺省即 MCU 精确语义。 -- **错误码**:OK/YIELD/CANCELLED/IMAGE/OPCODE/SLOT/TYPE/INDEX/DIVZERO/DEPTH/NOSUCHNATIVE/HOST/ - OOM/IL/FEAT(全集见 `ecs_vm.h`);错误现场 `error_func/error_pc` ↔ `EcxInterpreter` ErrorFunc/ErrorPc。 - -## 五、验证体系 - -| 层 | 载体 | 内容 | -|---|------|------| -| 语料对拍 | `CorpusCrossValidationTests` + `Bytecode/corpus/` | 每用例 `.ecs`+`.expected`+`.events` 数据驱动:编译 → 解释器断言 → 同镜像交 C VM(无 cc 自动跳过)逐行/逐事件对拍 | -| 结构断言 | `CvmCrossValidationTests` | 模块管线镜像、错误码传播(除零=8)、深度上限(9)、NeedIL 拒绝(13) | -| 全链路 | `FullChainVerificationTests` | 真实例程(光速过帧/nqueens)+ 嵌套模块项目,解释器 ↔ C VM 输出与事件全量对拍(TIME 墙钟行白名单) | -| 堆语义 | `InterpreterHeapRefcountTests`、`CowSemanticsTests` | HeapCountsConsistent、深拷贝隔离、COW 双态一致 | -| 门禁 | 每次语义/结构改动 | 731 双态 + LSP 66 + format + nqueens 双端逐字一致 | - -双端不一致时先判定哪端错:修实现而非改期望(corpus 期望即规格)。 - -## 维护规则 - -1. 改动任何 S-条目语义 → 先改本表 → 双端实现同日落地(禁止单端先行合入)→ 跑「锁定测试」列 - 全部 + corpus 对拍 + `ECS_COW=0` 重跑; -2. 新增 RC 站点 → 第二节表加行,双端锚点同步; -3. 新增指令 → 第三节 checklist; -4. 锚点失效(重构改名/移动)→ 当次 PR 内同步更新(grep 校验锚点存在)。 diff --git a/docs/maa/P1-acceptance-20261016.md b/docs/maa/P1-acceptance-20261016.md new file mode 100644 index 00000000..88f7dde8 --- /dev/null +++ b/docs/maa/P1-acceptance-20261016.md @@ -0,0 +1,46 @@ +# Maa P1 底座:Windows x64 软件验收(2026-10-16) + +基线:上游 dev `d00b9284b8d835f91166c96a393031e3d7ff06c1`。范围为实施任务书 2026-10-16 更新后的 P1-01~04 与 P2 接口预留;架构和复验命令见 [P1.md](P1.md)。 + +## 已完成 + +- 手柄与采集两个平级模块分别持有物理连接、代次和使用租约。Maa 绑定只借用固定端口;独立只读绑定的停止不释放输入。 +- GUI、CLI、FlowService、SDL、AI 能力装配与虚拟手柄入口统一接到公开门面。纯计算不连接硬件,单侧使用不要求另一侧连接。 +- 保留原串口协议,取消先停止旧运行,再确认中性 HID 写出、排空回调并归还 epoch。迟到的旧连接事件、KeyUp/Inactive 和采集帧不能跨代次作用。 +- 来源枚举记录后端和稳定身份;同名项可区分,刷新/消失不改选首项。输入与采集配置独立保存,CLI 覆盖一侧不修改另一侧。 +- 帧统一 BGR8、1920×1080;像素与元数据在同一槽发布,PNG 按槽缓存。停止超时保留阻塞 Read 的原生所有者。 +- 空 bootstrap 显式选择 CPU,避免每个短任务重复触发 Auto 推理设备初始化。设备写循环与采集 Read 使用专用线程,避免长期阻塞线程池;异步用例等待实际 HID 写出事件。 +- OCR/推理资源逐项拥有或借用。旧 `ImgLabel.Search`、GUI 截断和 CLI 向上取整保留;P1 未要求修改脚本阈值。 + +## 本地检查 + +| 检查 | 结果 | +|---|---| +| `.NET 10.0.400` Debug 全方案构建 | 0 错误;有 nullable/Avalonia 等警告 | +| `dotnet format --verify-no-changes --no-restore` / `git diff --check` | 通过 | +| Core | 1321 通过,3 跳过;包含真实 Maa、模块独立和 1000 次生命周期回归 | +| LSP / SDL | 67 / 18 通过 | +| GUI 服务 / Headless 界面 | 168 / 47 通过 | +| 两种编辑器 Ctrl+S 单独运行 | 2 通过 | +| 额外 MinGW x86 C VM / bytecode 差分 | 378 通过,1 跳过 | +| C VM x64 / x86 budget 门禁 | RAM 944 / 892 B;ROM 39124 / 50064 B,均在预算内 | +| Windows x64 GUI + CLI 发布 | 通过;沿用仓库的 framework-dependent 单文件发布方式 | +| 发布目录 `maa doctor` | native v5.14.3 / binding 5.10.0;实际库从发布目录加载;bootstrap、无控制器 Tasker、DirectHit 和 PNG 像素往返通过 | +| 发布程序无硬件 ECS / 中文路径 Flow | 原算术 corpus 和纯计算 Flow 正常完成;Flow 指定无效端口仍可运行纯计算 | +| 原文件摘要 | 仓库跟踪的 28 份 ECS/IL/Flow 文件与基线 blob 相同;未覆盖改写用户外部资源 | + +普通回归共 **1621 项通过**,加上单独 Ctrl+S 的 2 项为 **1623 项通过**。x86 的 378 项是额外复跑,不重复算入该数字。显式人工截图/真实窗口用例和缺模型/平台的用例分别记录在 TRX 中,不作为 Maa 关键用例通过的替代。 + +1000 次输入启动/停止分全方案与隔离进程记录:每次结束 native 绑定归零,采样时活动帧租约为 0,预热后私有内存没有逐轮增长。P50/P95/P99/最大值及逐次内存采样见验收目录。这里的时长含输入投递、停止、原生排空及中性写出;它不是游戏反馈或模板/OCR 时延。 + +首轮 Windows CI 出现采集故障注入前首次输入写出的等待超时。修复长期阻塞采集占用线程池,并把异步测试的同步轮询改为订阅实际 `BytesSent`;新增专用采集线程回归,保持原来的 3 秒写出等待上限。 + +## 证据与范围 + +验收收集脚本在本地生成 `acceptance//windows-x64/`:依赖与发布文件摘要、build/test 日志、TRX、原生加载路径、实际 fake IConnection 写出事件、帧/来源/epoch 快照、M01~M17 软件证据映射、性能和生命周期采样、原文件摘要、入口旁路清单及待验收项。未观察到的字段不推断补齐。CI Develop 上传 TRX 和 Maa 自检输出,并继续执行独立的 C VM 32/64 位门禁。 + +M01~M17 的 **P1 软件断言**通过:真实 Maa native + fake IConnection / 可控帧源,或对应来源目录、配置、ViewModel 契约。M02/M03 的识别部分随 P2;物理驱动热插拔及 GUI/CLI 对真实端点的 replay 仍待实机验收。 + +以下不属于本批已完成结论:P2 标签转换与 Maa 旧评分/OCR 迁移,P3 RunEcs,P4 Maa Pipeline 推进 Flow,P5 完整任务面板与跨平台发行验收。真实串口/采集卡、游戏反馈、30 分钟业务任务、macOS/Linux Maa 发布包仍 pending。Windows GUI 的可核实来源枚举使用 DirectShow;其他后端缺身份映射时明确失败。 + +回退应同时恢复原程序及配套 runtime/资源。P1 没有覆盖旧 ECS/IL/Flow 或原标签文件;当前包不是已完成旧识图迁移的发行版。 diff --git a/docs/maa/P1.md b/docs/maa/P1.md new file mode 100644 index 00000000..1db1aed5 --- /dev/null +++ b/docs/maa/P1.md @@ -0,0 +1,79 @@ +# Maa P1 宿主底座 + +实施基线:上游 dev `d00b9284b8d835f91166c96a393031e3d7ff06c1`。按 2026-10-16 更新后的实施任务书落地 P1,运行依赖固定为 Maa native **5.14.3** / C# binding **5.10.0**。 + +## 范围 + +P1 接通手柄、帧源、设备门面、运行取消及资源所有权。ECS、IL、Flow 文件格式和编译器/VM 保持原样。2026-10-18 已补充来源配置校验与实时采集需求判断,并继续接入标签转换、Maa 识别和旧评分适配;当前代码与 OCR 试点的范围见 [P2](P2.md)。 + +## 模块与停止域 + +```mermaid +flowchart LR + UI[GUI / SDL / AI / CLI / Flow] --> Port[手柄公开门面] + Port --> Binding[每个使用者独立的 MaaControllerBinding] + Binding --> Pad[PadSession 内部协议服务] + Pad --> Device[既有串口协议] + Camera[采集来源] --> Hub[FrameHub / 单个 FrameProducer] + Hub --> Monitor[监视器 / FRAME / ROI] + Hub --> Binding +``` + +| 对象 | 所有权与边界 | +|---|---| +| `MaaHostSession` | 应用组合根;并列持有输入、采集和运行服务 | +| `PadSession` | 独占物理协议连接、连接代次及输入所有者;不引用采集或 Maa native | +| `FrameHub` | 独立采集连接、生产者和帧存储;不引用手柄模块 | +| `MaaControllerBinding` | 固定输入所有者/epoch 和可选帧源代次;只拥有自己的 native 对象 | +| `HostRunScope` | 按需登记输入/实时采集依赖;已有图片的计算不登记采集依赖 | +| `CapabilityLease` | 逐项管理 OCR/推理资源;新建默认资源始终由创建方释放 | + +手动输入的接受队列有 256 项上限,后台串行执行 Maa Shell。满队列会清掉尚未执行的手动事件并执行中性收尾,避免留下丢失的释放事件。脚本/Flow 的长按使用可直接取消的 pulse。 + +停止先取消业务入口,再停止当前绑定的 Tasker、排空回调、经 Inactive 释放输入并确认中性报告写出,最后转交 epoch。写出确认来自实际连接的 `BytesSent`,不表示游戏已执行。掉线记录 `ReleaseUnconfirmed`,重连成功并确认中性报告后才恢复输入。 + +独立预览使用只截图绑定,Stop/Inactive/Dispose 不写手柄。手动连接保持与运行借用分开:结束任务归还自己的租约,保留用户连接。切换/断开模块会取消并等待其依赖者;另一个模块独立运行。阻塞采集 Read 超时后保留原生产者身份和句柄,迟到帧不发布,不启动第二个生产者。 + +## 帧与配置 + +帧槽一起发布像素、frameId、sourceEpoch、宿主到达时间、原始/规范尺寸和 normalizationId。统一输出 BGR8、1920×1080;非 16:9 需显式裁剪或旋转。PNG 桥接不传递跨库 Mat 指针,整帧 PNG 只在帧槽缓存一份。 + +Windows GUI 来源按 DirectShow 枚举并用 DevicePath/moniker 身份重新解析。macOS 使用 AVFoundation,将 Video 与 Muxed 设备合并并按 `uniqueID` 升序排列,与锁定的 OpenCvSharp5 5.0.0.20261011(OpenCV `40738fb`)索引规则一致;显示 `localizedName`,保存稳定 `uniqueID`,枚举不打开设备。Linux V4L2 优先使用 `/dev/v4l/by-id`;缺少稳定身份的选择标为临时,不能跨进程启动当作稳定 ID 使用。同名显示项不覆盖;未知/消失的来源不回退 0 号。未提供可核实枚举映射的 GUI 后端明确失败。CLI 显式数字序号按指定后端的当前地址解释,并记录临时来源及实际后端。 + +macOS 的 GUI 后端选项为 `ANY` / `AVFOUNDATION`,Linux 为 `ANY` / `V4L2`;选择与配置恢复共用平台规则。macOS 首次连接会触发摄像头授权,允许后重新连接;拒绝或受限制时,日志会提示系统设置中的摄像头权限入口。`.app` 已包含 `NSCameraUsageDescription`,请使用完整应用包启动 GUI。 + +两侧选择独立存入 GUI 配置;CLI `run ... --profiles ` 读取同样的字段。以下是手写临时索引配置的最小例子: + +```json +{ + "controller_profile": { "endpoint": "COM3", "transport": "Serial" }, + "capture_profile": { + "index": 0, + "backend": "DSHOW", + "normalization": { "crop": null, "rotation": 0, "interpolation": 0 } + } +} +``` + +`--port/-p` 只覆盖输入;`--device/-d` 仍是采集序号;`--videotype/-vt` 是采集后端。改变后端不能沿用另一后端的枚举地址,需显式重新选序号。运行保存实际端点/后端/代次快照,后续修改下拉框不改活动绑定。 + +## 原生诊断与复验 + +```powershell +dotnet build EasyCon2.slnx -c Debug +dotnet test EasyCon2.slnx -c Debug --no-build +dotnet format --verify-no-changes --no-restore +dotnet run --no-build -c Debug --project src/EasyCon2.CLI -- maa doctor +``` + +`maa doctor` 不连接硬件;它检查真实 native 版本、有效 bootstrap、无控制器 Tasker、显式图片识别和 PNG 像素往返,并在 Windows 输出已加载库的位置。发行目录也可直接运行 `ezcon.exe maa doctor`(仓库构建产物名为 `EasyCon2.CLI.exe`),用于排除开发目录依赖。 + +bootstrap 不含模型,显式指定 CPU。对照探针发现重复加载默认 Auto 资源会触发推理设备初始化并持续增加私有内存;CPU bootstrap 避开此初始化。它不规定 P2 模型资源的推理后端。实现依据是 [ResourceMgr 的加载与推理设备选择](https://github.com/MaaXYZ/MaaFramework/blob/v5.14.3/source/MaaFramework/Resource/ResourceMgr.cpp)。 + +`MaaHostTests` / `FrameFoundationTests` 覆盖真实 Maa + fake IConnection / 可控帧源:最终 HID、长按取消、中性写出、旧回调、双端点×双来源、独立停止、采集/输入掉线、帧租约、PNG/stride/alpha、临时身份与配置保存。1000 次短运行记录完整 GC 后内存、线程、native 绑定与帧租约,检查预热后增长上限。C VM 的 C99/32 位/64 位门禁继续保留。 + +运行报告写入本机应用数据目录 `EasyCon/run-reports/`,最多保留 128 份,包含业务状态、native 状态、错误、兼容 profile、有效配置和清理结果。Maa binding 的 `GetInfo` 中未绑定模块状态为 null,绑定能力与就绪状态单独列出,不能用作应用总连接开关。 + +## 后续验收 + +真实串口/采集卡热插拔、驱动 Read 阻塞、游戏反馈时序和 30 分钟业务任务仍需实机验收;macOS/Linux 的实际发行包需分别验证。P2 的完整标签业务覆盖、日文/OCR 置信度等价,P3 RunEcs、P4 Pipeline 推进 Flow、P5 完整任务面板/发行验收仍按任务书分批交付。 diff --git a/docs/maa/P2.md b/docs/maa/P2.md new file mode 100644 index 00000000..7eae2ace --- /dev/null +++ b/docs/maa/P2.md @@ -0,0 +1,93 @@ +# Maa P2:旧标签与 OCR 试点 + +2026-10-19。P2 初版基于 dev `d00b9284`,延续已合并 PR #78 的 P0/P1;本批验收修复基于最新 dev `3f5f8329`。原生运行库固定 5.14.3、C# binding 固定 5.10.0。本文描述已接入的产品路径;完整游戏与日文 OCR 兼容验收仍需对应素材和实机。 + +## P1 复验修复 + +- 同一有效采集后端的重复覆盖保留稳定来源 ID;显式换索引或换后端才清除旧身份。重排后按 ID 重新解析当前索引。 +- GUI 配置恢复和 CLI profile 共用校验,拒绝互相矛盾的来源后端/缓存索引、负索引和未知后端。重新解析后的新索引不参与保存快照的一致性比较。 +- `NeedIL` 保留编译器原义。宿主从链接结果判断实时采集需求:标签、FRAME、坐标 OCR 需要采集;已有图片 ROI、仅 OCR_INIT 不需要。ROI 无论是否连接采集都能装配。 + +## 标签转换 + +```powershell +# 参数是 ImgLabel 目录本身,不会再追加 ImgLabel +ezcon labels convert ./ImgLabel --output ./.easycon/maa-cache --compatibility LegacyGui +# 递归仅用于显式批量盘点;GUI/CLI 运行的标签来源不递归 +ezcon labels convert ./project --recursive --output ./converted +``` + +命令严格处理全部错误,失败退出 1,输出 JSON 报告,不覆盖上次 `current.json`。运行入口允许保留未使用的坏标签,但编译实际引用的名称必须通过预检。 + +按旧入口的有序父目录追加 ImgLabel;同名时第一个成功解析的标签胜出,坏 JSON 不占名,已解析但不支持的标签保留错误身份。源 IL/ECS/Flow 不改写。默认派生缓存位于本机 LocalApplicationData 下的 `EasyCon/maa-cache/<项目路径摘要>/`。 + +generation 包含 `pipeline/legacy.native.json`、`legacy.compat.json`、bootstrap、内容摘要命名的 PNG,以及 `easycon/` 下的索引、报告与 manifest。转换器 `easycon.labels/3` 的摘要包含来源绝对路径、内容、优先级、转换器版本、运行库组合、规范化、兼容 profile 和模型摘要;来源路径与不可变索引一致,相同内容迁移目录会生成新版本。写临时目录、真实 Maa 装载预检后再发布目录并原子更新指针。复用时校验模板、节点、索引、报告和模型;损坏缓存明确失败。当前版本及运行中的旧版本保留,其余本转换器版本的标签缓存最多保留七份。 + +每个识别会话以 .NET 文件 API 读取并校验模板摘要,经 Maa `OverrideImage` 注册原生内存图像,避免固定 Windows runtime 的模板文件长路径限制;不依赖短文件名、符号链接或切换进程工作目录。原生需要读取的 Pipeline 元数据与模型复制到会话拥有的短 TEMP 目录,Tasker/Resource 排空释放后删除;源 generation 保留。Windows 装载路径超出保守预算时明确失败,可配置更短的 TEMP,不会继续伪装资源就绪。大于 64 KiB 的 Pipeline 与长来源路径组合另有真实回归,避免仅以小文件样本宣称长路径通过。 + +发布预检和运行会话都对每张不同的原生模板执行一次真实 `TemplateMatch` 自匹配探针,确认模板已装入同一资源。缺失、损坏、解码/探针失败会阻止发布或启动;未验证模板引用、过小 ROI、原生作业失败会明确报错。`ValidatedTemplates` 与 `NativeResourceDirectory` 可用于记录实际覆盖数和原生装载位置。原生候选为空只在资源已验证后按候选策略处理,负相关样本的正常空结果仍由兼容查询给出旧分数。 + +## 评分和路由 + +GUI 运行与标签预览已使用 `MaaVisionService`,CLI `run` 使用同一路径。显式图像查询把**识别类型和参数**传给 `AppendRecognition`;资源节点名供 Pipeline 使用。 + +| 旧方法 | 旧浮点分数 | 执行策略 | +|---|---|---| +| 5 CCoeffNormed | `((r+1)/2)*100` | Maa TemplateMatch 后同帧兼容确认 | +| 3 CCorrNormed | `100*r` | 同上 | +| 1 SqDiffNormed | `100*(1-r)` | 同上 | +| 14 透明匹配 | `100*(1-r)` | CustomRecognition 使用原 alpha 掩码 | +| 11/12 边缘匹配 | `((r+1)/2)*100` | CustomRecognition 使用原预处理 | + +只有 `LegacyAlgorithmAdapter` 执行浮点和整数转换。LegacyGui 截断,LegacyCli 向上取整;CLI 支持 `run --compatibility` 显式固定。负分不钳零,NaN/Infinity 报诊断。当前没有跨平台误差上界,因此普通方法也执行同帧旧算法确认;这会增加计算成本,不能称为纯 Maa 原生匹配。 + +无 native best 不能返回零分。路由记录 `native-no-candidate`、`integer-boundary`、`best-box-semantics`、`native-verified`、掩码/预处理原因。每次查询只借一帧,PNG、frameId、sourceEpoch 固定,ROI 坐标以 1920×1080 整图解释,最终框只加一次偏移。追踪仅保存最后一项,可订阅事件自行持久化。 + +兼容查询的 hit=true 只代表计算有效。新 Pipeline 如需复刻旧整数条件,用 `MaaLabelConverter.ScoreCondition(entry, ">", 90, profile)` 生成 Custom 参数,识别类型为 `Custom`;扩展 `EasyCon.LegacyScoreCondition` 另外判断比较符及阈值。未配置业务阈值的查询节点不应当作成功条件。 + +原生标准节点可复用;直接将资源交给其他 Maa 宿主时,宿主需保证其模板路径可读或采用同样的内存装载。兼容节点依赖 EasyCon 注册的扩展,不能作为任意 Maa GUI 独立运行包。 + +## OCR 试点与启用 + +```powershell +# 唯一联网步骤:显式下载固定版本、固定 SHA-256 的官方中英 PP-OCR v4 +ezcon maa models install ./maa-ocr-pilot +ezcon maa doctor --ocr-profile ./maa-ocr-pilot/ocr-profile.json +ezcon run ./script.ecs --ocr-profile ./maa-ocr-pilot/ocr-profile.json +# GUI:从设置了此环境变量的进程启动 +$env:EASYCON_MAA_OCR_PROFILE = (Resolve-Path ./maa-ocr-pilot/ocr-profile.json).Path +``` + +安装目录含 det/rec/keys、profile、模型来源说明和 MaaCommonAssets/PaddleOCR 许可证。安装器保留已经存在的 profile。普通运行不会下载模型;profile 中的模型摘要必须匹配,核验后复制为固定版本,运行时编辑源文件不改变已有会话。 + +试点 profile 只声明 `chi_sim` / `eng`、Tessdata 路径别名、DEFAULT 引擎与 SINGLE_LINE 页面模式,采用 `only_rec`。其它语言、路径或选项没有唯一映射时初始化失败。`.traineddata` 不会当作 ONNX 模型加载。文字标签使用相同 profile 的 chi_sim 映射,在 Custom 回调内调用当前 Context 的 OCR 节点,保留 Trim、编辑距离及置信度相乘逻辑,不设置 expected 过滤。 + +`MaaOcrService` 接受调用方的图片,不借用控制器或摄像头。宽高 <=0 表示从收敛后的 X/Y 到图片边界;空区域保留 `OCR ARGS ERR!`。无效图片/普通失败返回空串并记日志,取消单独传播。成功结果更新 LastConfidence(原始均值×100,截断),提前返回、失败和仅 Init 保留先前置信度。普通文本不额外 Trim。每次独立 ECS 调用有新 OCR 状态,Flow 的 script.run 也创建独立状态。 + +**中英模型仅为试点,PP-OCR 与 Tesseract 的文本/置信度数值等价尚未证明;日文模型未配置。** 未显式启用 profile 时保留原 OCR 默认后端。NET_*、FFI、张量及旧字形算法维持原实现。 + +## 生命周期、验证与范围 + +识别使用专用无硬件 Tasker,串行执行,等待队列上限 32;脚本请求不丢弃。每次复制托管结果、排空取消/Stop 作业并释放图像后清原生历史。可观察等待数、活动图片数、保留结果数、识别会话数、活跃标签资源版本数及 OCR 模型资源数;这些宿主管理计数不冒充 Maa 内部模型对象计数。 + +```powershell +./ci/prepare-maa-ocr.ps1 -Configuration Release +dotnet test EasyCon2.slnx -c Release +dotnet format --verify-no-changes +``` + +Windows/macOS CI 强制安装真实模型并执行 OCR 测试,缺模型导致失败。dev 验收包携带 `maa-ocr-pilot`,默认不自动启用。原有 C VM 32/64 位差分门禁保留。 + +自动化覆盖真实 Maa 模板/OCR、两种整数 profile、无候选补救、掩码/边缘、条件 hit、缓存事务/编辑/损坏/旧版本引用、停止与结果清理,以及旧 ECS 分支到 fake IConnection 的最终 HID 字节。静态盘点和有限真实截图对拍另行记录,不能替代每标签正例/近似负例或完整游戏验收。 + +连接区的短状态、固定宽度选择框和完整名称 tooltip 已纳入生产文件。经典布局最小宽度的连接标题及按钮文字会在既有区域内收敛,卡片布局按钮同样适配可用宽度。M18 渲染/悬停/同名来源键盘选择的显式测试位于 `ConnectionPanelRenderTests.cs`;使用测试连接服务,不表示真实设备已连接。 + +CI 的 M18 渲染目录和 TRX 目录均使用 `github.workspace` 下的绝对路径,测试与上传共用同一目录配置。上传前由 `ci/verify-connection-evidence.ps1` 核验经典/卡片 × 1200/800 的 52 张 PNG、4 份 JSON(含 48 种状态及悬停/同名来源选择记录)和四项全部通过的 TRX。只有完整性校验通过才发布 `connection-m18` 附件,TRX 单独存在不能使门禁通过。`ci/test-connection-evidence.ps1` 在 CI 中检查完整证据可接受,并拒绝相对路径、漏传文件和失败记录等异常情况。 + +本地实际素材对拍:1164 个有效标签×3 张原始 1080p 游戏截图,共 3492 项;旧浮点、GUI 整数、CLI 适配整数和最佳位置均无差异,每种方法×每帧另用实际 LegacyCli 服务检查,共 15 项。1340 项 native 原始分数存在数值差别,样本最大绝对差为 `0.0001233582372665437`;该值不是跨平台误差上界。1086 项没有 native 候选,106 项 native 最佳框与旧选择不同,均由同帧兼容结果保留旧行为。1218 个源 IL/ECS/Flow 文件前后摘要一致。复现工具见 [MaaReplayCheck](../../tools/MaaReplayCheck/README.md)。 + +当前真实 1165 标签包中 `正在进化.IL` 的模板为 313×36,ROI 为 232×131,旧 OpenCV 也不能执行该尺寸组合。转换报告保留错误,引用它的脚本在运行前失败,等待正确模板/范围;不会自动改写原包。日文 OCR、旧 OCR_CONF 阈值、全标签业务覆盖、真实 Mac 摄像头与 30 分钟游戏流程仍待验收。P3 RunEcs 与 P4 Flow 编排不包含在本阶段。 + +## 下一批执行器拆分的契约 + +按 2026-10-19 的设计修订,正式执行端只有 PC 高层执行与 MCU C VM。先形成不经过 ECX 编码的完整项目 IR,再实现桌面高层执行;MCU 沿独立降低/导出/目标预检路径生成 ECX。C VM 的桌面宿主用于模拟 MCU 内核;旧 C# EcxVm 只作迁移工具,不增加长期第三端或三方对拍要求。本批仍使用迁移期执行入口,没有将上述结构计为已经实施。手柄与采集的重建随后分别交付验收。 \ No newline at end of file diff --git a/docs/models.md b/docs/models.md deleted file mode 100644 index db02adba..00000000 --- a/docs/models.md +++ /dev/null @@ -1,82 +0,0 @@ -## models - -通用自动化虚拟机平台插件模块,用于实现ffi - -**模块系统已实现** ✅ - -### 模块架构 - -不同模块提供的函数都应通过平台系统调用指令执行,调用编号各个模块应唯一不可冲突 - -对兼容的字节码文件调用未实现模块功能须有处理机制 - -### 已实现模块 - -#### 1. device模块 (required) -- **功能**: 实现单片机手柄操作和设备通信 -- **实现**: `EasyCon.Device` 项目 -- **接口**: `IDeviceService`, `NintendoSwitch` -- **特性**: - - 串口/蓝牙/网络连接 - - 自动设备发现 - - 协议抽象层 - - 状态监控和重连 - -#### 2. capture模块 (required) -- **功能**: 实现图像识别和视频采集 -- **实现**: `EasyCon.Capture` 项目 -- **接口**: `ICaptureService`, `OpenCVCapture` -- **特性**: - - 模板匹配算法 - - OCR文字识别 - - 多视频源支持 - - 性能优化缓存 - -#### 3. script模块 (required) -- **功能**: 脚本语言解析和执行 -- **实现**: `EasyCon.Script` 项目 -- **接口**: `IIoAdapter`, `ICGamePad`, `Compilation`, `Evaluator` -- **特性**: - - SourceText→Lexer→Parser→Binder→Evaluator 编译管线 - - 绑定器进行类型检查和作用域分析 - - 单片机字节码生成(Assembler) - - 控制流图和寄存器分配优化 - -#### 4. vpad模块 (optional) -- **功能**: 虚拟手柄UI和按键映射 -- **实现**: `EasyCon.VPad` 项目 -- **接口**: `IControllerAdapter`, `KeyMappingManager` -- **特性**: - - 可视化手柄UI - - 键盘映射配置 - - 物理手柄支持 - - 配置文件管理 - -### 规划中模块 - -#### sysbot模块 (optional) -- **功能**: 连接sys-botbase/noxos等后端实现内存操作 -- **状态**: 规划中 -- **应用**: 游戏内存修改、调试辅助 - -#### 扩展模块系统 -- **FFI接口**: 外部函数调用接口 -- **插件系统**: 第三方模块支持 -- **脚本API**: 脚本级别的模块访问 - -### 模块调用规范 - -#### 系统调用指令 -```assembly -srv, , , [args] -``` - -#### 调用编号分配 -- **0x00-0x0F**: 核心系统功能 -- **0x10-0x1F**: device模块 -- **0x20-0x2F**: capture模块 -- **0x30-0x3F**: script模块 -- **0x40-0x4F**: vpad模块 -- **0x50-0xFF**: 保留给扩展模块 - -> 不同平台(PC, MCU)应根据自身平台特点选择实现部分模块 \ No newline at end of file diff --git a/docs/obs-virtual-cam.md b/docs/obs-virtual-cam.md deleted file mode 100644 index 1be17fe1..00000000 --- a/docs/obs-virtual-cam.md +++ /dev/null @@ -1,17 +0,0 @@ -### 使用obs虚拟摄像头搜图 - -由于opencvsharp项目已经更新,目前伊机控相关依赖全部更新到最新版本,已经支持obs30+的内置虚拟摄像头,无需安装额外插件 - -~~由于伊机搜图依赖于[opencvsharp](https://github.com/shimat/opencvsharp)项目,~~ -~~虽然obs现在有了内置的虚拟摄像头功能,然而opencv暂时[不支持](https://github.com/opencv/opencv/issues/19746)~~ -~~在obs27之前可以使用[插件](https://github.com/Fenrirthviti/obs-virtual-cam/releases)~~ -~~obs28由于插件原作者不再更新,可以使用这个[维护项目](https://github.com/Avasam/obs-virtual-cam/releases)~~ - -伊机控将持续听取用户的意见 ~~正在积极寻求其他解决方案~~ - -ref: -- https://github.com/opencv/opencv/issues/19746 -- https://github.com/Avasam/obs-virtual-cam/releases -- https://github.com/Fenrirthviti/obs-virtual-cam/releases - - diff --git a/examples/audio-load-score.ecs b/examples/audio-load-score.ecs new file mode 100644 index 00000000..66632d65 --- /dev/null +++ b/examples/audio-load-score.ecs @@ -0,0 +1,19 @@ +# AUDIO_SCORE 返回 0~100 的整数峰值,百分制向下取整。 +# 在桌面 GUI 连接游戏音频和手柄后运行。 +# 将本脚本保存在自己的脚本目录;标签应为同级的 +# audiolabel/医疗.audiolabel/label.json 和 audio.wav。 +# 85 是示例相似度阈值,请用真实游戏声音校准。 +# 未命中会持续按 A;可以点击停止脚本结束。 +AUDIO_LOAD "医疗" +FOR + IF AUDIO_SCORE("医疗") >= 85 + FOR 5 + B + WAIT 800 + NEXT + BREAK + ENDIF + A + WAIT 800 +NEXT +AUDIO_UNLOAD "医疗" \ No newline at end of file diff --git a/examples/audio-observe.ecs b/examples/audio-observe.ecs new file mode 100644 index 00000000..6b9c44b9 --- /dev/null +++ b/examples/audio-observe.ecs @@ -0,0 +1,14 @@ +# AUDIO_SCORE 返回 0~100 的整数峰值,百分制向下取整。 +# 桌面 GUI:保存脚本、连接游戏音频,标签放在脚本旁 +# audiolabel/医疗.audiolabel/{label.json,audio.wav}。无需手柄。 +AUDIO_LOAD "医疗" +PRINT "开始识别,请在接下来的 8 秒内触发目标声音" +WAIT 8000 +$score = AUDIO_SCORE("医疗") +PRINT "本轮已完成分析的最高分:" & $score +IF $score >= 85 + PRINT "达到示例阈值" +ELSE + PRINT "尚未达到示例阈值" +ENDIF +AUDIO_UNLOAD "医疗" \ No newline at end of file diff --git a/examples/audio-reload.ecs b/examples/audio-reload.ecs new file mode 100644 index 00000000..8b4feca3 --- /dev/null +++ b/examples/audio-reload.ecs @@ -0,0 +1,20 @@ +# AUDIO_SCORE 返回 0~100 的整数峰值,百分制向下取整。 +# 桌面 GUI:先保存脚本并连接游戏音频,无需连接手柄。 +# 标签:脚本旁 audiolabel/医疗.audiolabel/{label.json,audio.wav}。 +# 每轮 8 秒由脚本 WAIT 决定,识别在后台持续进行。 +AUDIO_LOAD "医疗" +PRINT "第一轮开始,请触发目标声音" +WAIT 8000 +$first = AUDIO_SCORE("医疗") +PRINT "第一轮最高分:" & $first +AUDIO_UNLOAD "医疗" + +AUDIO_LOAD "医疗" +$initial = AUDIO_SCORE("医疗") +PRINT "第二轮初始分数:" & $initial +PRINT "第二轮开始,请再次触发目标声音" +WAIT 8000 +$second = AUDIO_SCORE("医疗") +PRINT "第二轮最高分:" & $second +AUDIO_UNLOAD "医疗" +# $first 保留第一轮结果;第二轮不会继承第一轮峰值。 \ No newline at end of file diff --git a/examples/csv_test/lib/csv_linux.ecs b/examples/csv_test/lib/linux/csv_linux.ecs similarity index 99% rename from examples/csv_test/lib/csv_linux.ecs rename to examples/csv_test/lib/linux/csv_linux.ecs index 8489c0aa..4014b1b5 100644 --- a/examples/csv_test/lib/csv_linux.ecs +++ b/examples/csv_test/lib/linux/csv_linux.ecs @@ -144,8 +144,8 @@ FUNC csv_open($path:STRING):INT NEXT # 释放资源 - lib_free($buf) - lib_fclose($fp) + lib_free $buf + lib_fclose $fp RETURN LEN($csv_lines) ENDFUNC diff --git a/examples/csv_test/lib/csv_windows.ecs b/examples/csv_test/lib/win/csv_windows.ecs similarity index 99% rename from examples/csv_test/lib/csv_windows.ecs rename to examples/csv_test/lib/win/csv_windows.ecs index aad744c0..775f1b93 100644 --- a/examples/csv_test/lib/csv_windows.ecs +++ b/examples/csv_test/lib/win/csv_windows.ecs @@ -144,8 +144,8 @@ FUNC csv_open($path:STRING):INT NEXT # 释放资源 - lib_free($buf) - lib_fclose($fp) + lib_free $buf + lib_fclose $fp RETURN LEN($csv_lines) ENDFUNC diff --git a/examples/csv_test/main.ecs b/examples/csv_test/main.ecs index 956ede9d..075f7223 100644 --- a/examples/csv_test/main.ecs +++ b/examples/csv_test/main.ecs @@ -1,7 +1,9 @@ # CSV文件读取测试脚本 # 测试extern导入libc函数实现的CSV解析器 -IMPORT "csv_windows.ecs" +# 平台实现二选一显式导入:平台库放 lib/ 子目录(R4 跨目录显式导入, +# 不被 lib/ 自动导入波及),Windows 用 win/、Linux 用 linux/ +IMPORT "lib/win/csv_windows.ecs" PRINT "========== CSV文件读取测试 ==========" PRINT "" diff --git a/examples/pokemon-box/lib/pokedex.ecs b/examples/pokemon-box/lib/pokedex.ecs new file mode 100644 index 00000000..09979ba7 --- /dev/null +++ b/examples/pokemon-box/lib/pokedex.ecs @@ -0,0 +1,34 @@ +# ============================================================ +# lib/pokedex.ecs — 关都图鉴 1-151 名表与查询 +# +# 英文名取 FRLG 盒子全大写显示(NIDORAN♀♂ / FARFETCH'D / MR. MIME 特殊名); +# 中文名为全国图鉴官方译名。lib 隔离约定:lib 自有全局/常量对 main 不可见, +# main 只经本文件导出的 FUNC 访问(LOOKUP / DEX_NAME)。 +# ============================================================ + +$P_EN = ["BULBASAUR", "IVYSAUR", "VENUSAUR", "CHARMANDER", "CHARMELEON", "CHARIZARD", "SQUIRTLE", "WARTORTLE", "BLASTOISE", "CATERPIE", "METAPOD", "BUTTERFREE", "WEEDLE", "KAKUNA", "BEEDRILL", "PIDGEY", "PIDGEOTTO", "PIDGEOT", "RATTATA", "RATICATE", "SPEAROW", "FEAROW", "EKANS", "ARBOK", "PIKACHU", "RAICHU", "SANDSHREW", "SANDSLASH", "NIDORAN♀", "NIDORINA", "NIDOQUEEN", "NIDORAN♂", "NIDORINO", "NIDOKING", "CLEFAIRY", "CLEFABLE", "VULPIX", "NINETALES", "JIGGLYPUFF", "WIGGLYTUFF", "ZUBAT", "GOLBAT", "ODDISH", "GLOOM", "VILEPLUME", "PARAS", "PARASECT", "VENONAT", "VENOMOTH", "DIGLETT", "DUGTRIO", "MEOWTH", "PERSIAN", "PSYDUCK", "GOLDUCK", "MANKEY", "PRIMEAPE", "GROWLITHE", "ARCANINE", "POLIWAG", "POLIWHIRL", "POLIWRATH", "ABRA", "KADABRA", "ALAKAZAM", "MACHOP", "MACHOKE", "MACHAMP", "BELLSPROUT", "WEEPINBELL", "VICTREEBEL", "TENTACOOL", "TENTACRUEL", "GEODUDE", "GRAVELER", "GOLEM", "PONYTA", "RAPIDASH", "SLOWPOKE", "SLOWBRO", "MAGNEMITE", "MAGNETON", "FARFETCH'D", "DODUO", "DODRIO", "SEEL", "DEWGONG", "GRIMER", "MUK", "SHELLDER", "CLOYSTER", "GASTLY", "HAUNTER", "GENGAR", "ONIX", "DROWZEE", "HYPNO", "KRABBY", "KINGLER", "VOLTORB", "ELECTRODE", "EXEGGCUTE", "EXEGGUTOR", "CUBONE", "MAROWAK", "HITMONLEE", "HITMONCHAN", "LICKITUNG", "KOFFING", "WEEZING", "RHYHORN", "RHYDON", "CHANSEY", "TANGELA", "KANGASKHAN", "HORSEA", "SEADRA", "GOLDEEN", "SEAKING", "STARYU", "STARMIE", "MR. MIME", "SCYTHER", "JYNX", "ELECTABUZZ", "MAGMAR", "PINSIR", "TAUROS", "MAGIKARP", "GYARADOS", "LAPRAS", "DITTO", "EEVEE", "VAPOREON", "JOLTEON", "FLAREON", "PORYGON", "OMANYTE", "OMASTAR", "KABUTO", "KABUTOPS", "AERODACTYL", "SNORLAX", "ARTICUNO", "ZAPDOS", "MOLTRES", "DRATINI", "DRAGONAIR", "DRAGONITE", "MEWTWO", "MEW"] +$P_ZH = ["妙蛙种子", "妙蛙草", "妙蛙花", "小火龙", "火恐龙", "喷火龙", "杰尼龟", "卡咪龟", "水箭龟", "绿毛虫", "铁甲蛹", "巴大蝶", "独角虫", "铁壳蛹", "大针蜂", "波波", "比比鸟", "大比鸟", "小拉达", "拉达", "烈雀", "大嘴雀", "阿柏蛇", "阿柏怪", "皮卡丘", "雷丘", "穿山鼠", "穿山王", "尼多兰", "尼多娜", "尼多后", "尼多朗", "尼多力诺", "尼多王", "皮皮", "皮可西", "六尾", "九尾", "胖丁", "胖可丁", "超音蝠", "大嘴蝠", "走路草", "臭臭花", "霸王花", "派拉斯", "派拉斯特", "毛球", "摩鲁蛾", "地鼠", "三地鼠", "喵喵", "猫老大", "可达鸭", "哥达鸭", "猴怪", "火暴猴", "卡蒂狗", "风速狗", "蚊香蝌蚪", "蚊香君", "蚊香泳士", "凯西", "勇基拉", "胡地", "腕力", "豪力", "怪力", "喇叭芽", "口呆花", "大食花", "玛瑙水母", "毒刺水母", "小拳石", "隆隆石", "隆隆岩", "小火马", "烈焰马", "呆呆兽", "呆壳兽", "小磁怪", "三合一磁怪", "大葱鸭", "嘟嘟", "嘟嘟利", "小海狮", "白海狮", "臭泥", "臭臭泥", "大舌贝", "刺甲贝", "鬼斯", "鬼斯通", "耿鬼", "大岩蛇", "催眠貘", "引梦貘人", "大钳蟹", "巨钳蟹", "霹雳电球", "顽皮雷弹", "蛋蛋", "椰蛋树", "卡拉卡拉", "嘎啦嘎啦", "飞腿郎", "快拳郎", "大舌头", "瓦斯弹", "双弹瓦斯", "独角犀牛", "钻角犀兽", "吉利蛋", "蔓藤怪", "袋兽", "墨海马", "海刺龙", "角金鱼", "金鱼王", "海星星", "宝石海星", "魔墙人偶", "飞天螳螂", "迷唇姐", "电击兽", "鸭嘴火兽", "凯罗斯", "肯泰罗", "鲤鱼王", "暴鲤龙", "拉普拉斯", "百变怪", "伊布", "水伊布", "雷伊布", "火伊布", "多边兽", "菊石兽", "多刺菊石兽", "化石盔", "镰刀盔", "化石翼龙", "卡比兽", "急冻鸟", "闪电鸟", "火焰鸟", "迷你龙", "哈克龙", "快龙", "超梦", "梦幻"] + +# 查表:文本命中英文名或别名行 "/名称" 返回图鉴编号(1-151),未命中 0 +FUNC LOOKUP($t:STRING):INT + $j = 0 + $r = 0 + WHILE $j < 151 + $name = $P_EN[$j] + IF $r == 0 + IF $t == $name + $r = $j + 1 + ENDIF + IF $t == "/" & $name + $r = $j + 1 + ENDIF + ENDIF + $j = $j + 1 + END + RETURN $r +ENDFUNC + +# 图鉴编号 → 中文名(main 不可见 lib 全局,经此函数取) +FUNC DEX_NAME($dex:INT):STRING + RETURN $P_ZH[$dex - 1] +ENDFUNC diff --git a/examples/pokemon-box/main.ecs b/examples/pokemon-box/main.ecs new file mode 100644 index 00000000..3efb452c --- /dev/null +++ b/examples/pokemon-box/main.ecs @@ -0,0 +1,78 @@ +# ============================================================ +# 宝可梦盒子识别主脚本(火红/叶绿 PC 存储 BOX 选中项) +# 输出:选中宝可梦 中文名 + 全国图鉴编号 + 等级 +# +# 运行约定:在脚本项目目录下执行(模型/字典为项目相对路径): +# cd examples/pokemon-box && easycon run main.ecs +# +# 采集帧固定 1920x1080。画面位置固定的界面不做找框,直接比例 ROI 三行: +# GBA 火红/叶绿盒子界面左侧信息面板位置固定,以 1264x860 基准坐标换算: +# 名字行 (25,470,440,80) 别名行 (25,555,440,80) +# 等级行 (170,640,220,78) —— Lv 之后的纯数字区 +# +# 模型:models/ppocr/PP-OCRv6_medium_rec.onnx(项目相对路径,不入库) +# 字典:models/ppocr/ppocrv6_dict.txt +# 模型代际常数(随资产指定,std 不钉常数): +# _DIGITS_BASE = '0' 的 class 下标(PP-OCRv6 = 33;v5 旧资产 16163) +# _REC_H = rec 输入行高(PP-OCRv6 = 48,输入固定 3x48xW;v5 旧资产按导出配置 32) +# ============================================================ + +_DIGITS_BASE = 33 +_REC_H = 48 +_BASEW = 1264 +_BASEH = 860 +$W = 1920 +$H = 1080 + +# ---- 主流程 ---- +$rec = NET_LOAD("models/ppocr/PP-OCRv6_medium_rec.onnx") +IF $rec <= 0 + PRINT "NET_LOAD failed (check models/ppocr/)" + RETURN +ENDIF + +# 同帧共享采集:整帧只采一次,各区域坐标相对该帧 +$frame = FRAME() +$dict = "models/ppocr/ppocrv6_dict.txt" + +# 名字行 / 别名行:整字典解码(lib LOOKUP 查表) +$x1 = 25 * $W / _BASEW +$y1 = 470 * $H / _BASEH +$w1 = 440 * $W / _BASEW +$h1 = 80 * $H / _BASEH +$t1 = PP_OCR_TEXT($rec, $frame, $x1, $y1, $w1, $h1, $dict, _REC_H) + +$x2 = 25 * $W / _BASEW +$y2 = 555 * $H / _BASEH +$w2 = 440 * $W / _BASEW +$h2 = 80 * $H / _BASEH +$t2 = PP_OCR_TEXT($rec, $frame, $x2, $y2, $w2, $h2, $dict, _REC_H) + +# 等级行:数字窗口解码('0'-'9' 子集) +$xl = 170 * $W / _BASEW +$yl = 640 * $H / _BASEH +$wl = 220 * $W / _BASEW +$hl = 78 * $H / _BASEH +$lv = PP_OCR_DIGITS($rec, $frame, _DIGITS_BASE, $xl, $yl, $wl, $hl, _REC_H) + +$dex = LOOKUP($t1) +IF $dex == 0 + $dex = LOOKUP($t2) +ENDIF + +IF $dex > 0 + $zh = DEX_NAME($dex) + PRINT "名称: " & $zh & " (No." & $dex & ")" + # 等级 ≤ 100:数字窗口混入伪影时按 3 位截断(%1000) + IF LEN($lv) > 3 + $lv = STRING(INT($lv) % 1000) + ENDIF + IF LEN($lv) > 0 + PRINT "等级: Lv" & $lv + ELSE + PRINT "等级: 未知" + ENDIF +ENDIF + +NET_UNLOAD $rec +PRINT "done" diff --git a/examples/pokemon-stats.ecs b/examples/pokemon-stats.ecs new file mode 100644 index 00000000..23c9bb5e --- /dev/null +++ b/examples/pokemon-stats.ecs @@ -0,0 +1,60 @@ +# ============================================================ +# 宝可梦状态页识别(名称 + 能力值计算)(std 配方 PP_OCR_TEXT / PP_OCR_DIGITS,固定 ROI) +# 布局基准:1920x1080 火红/叶绿英文状态页(见 demo 截图 ppocr2/3.png) +# 模型:__APP__/models/ppocr/PP-OCRv6_medium_rec.onnx(外部资产,换模型改这里) +# 字典:__APP__/models/ppocr/ppocrv6_dict.txt(名称识别经 NET_LABELS 标签表层) +# 模型代际常数(随资产指定,std 不钉常数): +# _DIGITS_BASE = '0' 的 class 下标——PP-OCRv6 字典 33(数字在表头);v5 旧资产 16163 +# _REC_H = rec 输入行高——PP-OCRv6 = 48(输入固定 3x48xW);v5 旧资产按导出配置 32 +# 原理:rec 输出 [1,T,C],数字行走 CTC_FOLD 打洞折叠(数字子集窗口); +# 名称行走 PP_OCR_TEXT 整字典折叠(NET_ARGMAX × NET_LABEL 逐帧解码)。 +# 自定义解码策略可在脚本侧经 NET_ROWS/NET_ARGMAX 逐帧遍历实现。 +# ============================================================ + +_DIGITS_BASE = 33 +_REC_H = 48 + +# ---- 主流程 ---- +$net = NET_LOAD(__APP__ + "/models/ppocr/PP-OCRv6_medium_rec.onnx") +IF $net > 0 + $dict = __APP__ + "/models/ppocr/ppocrv6_dict.txt" + + # 同帧共享采集:整帧只采一次,各区域坐标相对该帧(省 6 次采集 + base64 编解码) + $frame = FRAME() + + # 宝可梦名称(标签表层整字典识别,配方 PP_OCR_TEXT; + # 名称区 = 主行 (190,130,1530,85) 去掉左侧 Lv 段与右侧 HP 段) + $name = PP_OCR_TEXT($net, $frame, 400, 130, 690, 85, $dict, _REC_H) + PRINT "NAME: " & $name + + # 主行:Lv50 XXX HP65/65(整行数字混排,仅作展示) + $hp = PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 190, 130, 1530, 85, _REC_H) + PRINT "HP row: " & $hp + + # 右列能力值行(标签 + 数字整行,配方只解析数字); + # INT() 数值化吞掉模型伪影的前导零——具体游戏策略归脚本(SPEED 行模型吐 028) + $atk = INT(PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 990, 262, 730, 74, _REC_H)) + $def = INT(PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 990, 344, 730, 72, _REC_H)) + $spa = INT(PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 990, 426, 730, 74, _REC_H)) + $spd = INT(PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 990, 508, 730, 74, _REC_H)) + $spe = INT(PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 990, 592, 730, 78, _REC_H)) + + PRINT "ATTACK: " & $atk + PRINT "DEFENSE: " & $def + PRINT "SP.ATK: " & $spa + PRINT "SP.DEF: " & $spd + PRINT "SPEED: " & $spe + + # 计算能力值总和(不含 HP) + $sum = $atk + $def + $spa + $spd + $spe + PRINT "STAT SUM: " & $sum + + # 经验行(HP EXP.POINTS 19531) + $exp = PP_OCR_DIGITS($net, $frame, _DIGITS_BASE, 355, 680, 1370, 85, _REC_H) + PRINT "EXP POINTS: " & $exp + + NET_UNLOAD $net + PRINT "done" +ELSE + PRINT "NET_LOAD failed (check models/ppocr/)" +ENDIF diff --git a/examples/ppocr-det-find.ecs b/examples/ppocr-det-find.ecs new file mode 100644 index 00000000..88901c6a --- /dev/null +++ b/examples/ppocr-det-find.ecs @@ -0,0 +1,51 @@ +# ============================================================ +# PP-OCR det 自动找框 + rec 逐框识别(参考示例,非自动化测试) +# 布局基准:任意 1920x1080 界面截图(本例用火红/叶绿状态页 ppocr2.png) +# 模型:__APP__/models/ppocr/PP-OCRv6_medium_det.onnx(外部资产,换模型改这里) +# __APP__/models/ppocr/PP-OCRv6_medium_rec.onnx +# 字典:__APP__/models/ppocr/ppocrv6_dict.txt(整行文本识别用) +# 数字窗口:_DIGITS_BASE = '0' 的 class 下标,随模型代际指定(std 不钉常数)—— +# PP-OCRv6 字典 33(数字在表头);PP-OCRv5 旧资产为 16163(数字在表尾) +# 原理:det 全页推理一次找齐全部文本框(阅读序 y 优先 x), +# 逐框跑 rec 配方;数字窗口折叠得页面数值,整字典折叠得文本行。 +# 说明:det 框为文字行级(标签框/数字框分离),按需过滤或合并。 +# 真机资产布局见 test/assets/README.md。 +# ============================================================ + +# ---- 主流程 ---- +_DIGITS_BASE = 33 +_REC_H = 48 + +$det = NET_LOAD(__APP__ + "/models/ppocr/PP-OCRv6_medium_det.onnx") +$rec = NET_LOAD(__APP__ + "/models/ppocr/PP-OCRv6_medium_rec.onnx") +IF $det > 0 AND $rec > 0 + $frame = FRAME() + $dict = __APP__ + "/models/ppocr/ppocrv6_dict.txt" + + # det 全页一次找框,返回框数(-1 = 输出缺失/形状错) + $count = PP_DET_FIND($det, $frame, 0, 0, 1920, 1080) + PRINT "BOXES: " & $count + NET_UNLOAD($det) + + # 逐框 rec:数字窗口折叠收集页面数值;整字典折叠收集文本行 + $digits = "" + $lines = "" + $i = 0 + WHILE $i < DET_COUNT() + $n = PP_OCR_INFER($rec, $frame, DET_X($i), DET_Y($i), DET_W($i), DET_H($i), _REC_H) + $num = CTC_FOLD(0, "0123456789", _DIGITS_BASE) + IF LEN($num) > 0 + $digits = $digits + $num + "," + ENDIF + $text = PP_OCR_TEXT($rec, $frame, DET_X($i), DET_Y($i), DET_W($i), DET_H($i), $dict, _REC_H) + IF LEN($text) > 0 + $lines = $lines + $text + "," + ENDIF + $i = $i + 1 + END + NET_UNLOAD($rec) + PRINT "DIGITS: " & $digits + PRINT "LINES: " & $lines +ELSE + PRINT "NET_LOAD failed (check models/ppocr/)" +ENDIF diff --git a/frontend/.python-version b/frontend/.python-version new file mode 100644 index 00000000..6324d401 --- /dev/null +++ b/frontend/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..7975573f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,116 @@ +# EasyCon Flow 画布(Python 前端) + +EasyCon 编排图(`*.flow.json`)的可视化画布:拖节点、连 exec/数据边、调参数、跑图看节点计时。 +技术栈 **PySide6 + NodeGraphQt**(不引入 ComfyUI / torch 全家桶),环境用 **uv** 管理 +(`pyproject.toml` + `uv.lock`,`src/` 布局)。 + +> **节点功能全部由 EasyCon 后端提供**:本前端不含任何节点实现,节点类型、端口、参数、 +> slow 字段、按键名、OCR 后端选项全部来自 `GET /api/nodes`。后端加一个节点并登记到 +> `FlowNodeCatalog`,画布重新加载目录后即出现,前端无需改动。 + +--- + +## 1. 先启动后端(二选一) + +```bash +cd "/path/to/EasyCon" + +# A) 只跑服务(画布 / 外部工具的后端) +dotnet run --project src/EasyCon2.CLI -- serve --port 19391 + +# B) 启动 GUI:内嵌同一个服务(默认 19391,可用 EC_FLOW_PORT 覆盖,0 = 关闭) +``` + +验证: + +```bash +curl -s http://127.0.0.1:19391/api/health +curl -s http://127.0.0.1:19391/api/nodes | head -c 400 +``` + +## 2. 启动画布 + +先装 [uv](https://docs.astral.sh/uv/)(`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh` +/ `winget install astral-sh.uv`),然后: + +```bash +cd frontend +uv sync # 建 .venv 并按 uv.lock 装依赖(首次约 100+ MB,PySide6) +uv run easycon-flow # 打开界面(等价于 ./run.sh) +``` + +显式指定后端: + +```bash +uv run easycon-flow --backend http://127.0.0.1:19391 # 也可用环境变量 EC_FLOW_URL +./run.sh --backend http://127.0.0.1:19391 # 同一个东西的便捷包装 +``` + +自检(不需要界面;`--backend` 可选): + +```bash +uv run easycon-flow --self-test # 离线:画布 ↔ flow.json 往返(43 项) +uv run easycon-flow --self-test --backend http://127.0.0.1:19391 # 追加真后端端到端(48 项) + +# 无头 UI 冒烟(真的建窗、载图、跑图、试跑节点;需要后端在跑) +QT_QPA_PLATFORM=offscreen uv run python -m easycon_flow.uitest --backend http://127.0.0.1:19391 +``` + +> `uv run` 会自动按 `uv.lock` 同步环境,所以改完依赖只需 `uv sync`(或 `uv add <包>` 后重新 `uv lock`)。 +> `.python-version` 固定 3.14(PySide6 6.11 / NodeGraphQt 0.6.44 的实测组合);uv 会按需自动获取该版本。 + +## 3. 界面怎么用 + +| 区域 | 说明 | +|---|---| +| 左:节点库 | 按层(感知 / 决策 / 控制流 / 动作)分组,双击添加节点;悬停看端口与参数说明 | +| 中:画布 | exec 边(控制流)与数据边(图像/文本/数值)分别连到同名端口;`Del` / `Backspace` 删除选中节点,右键节点或画布空白处菜单也可删除;start 节点**最多一个**(再添加会被拒绝,校验/后端同样拦截)。**参数直接在节点上编辑**:bool 勾选框 / int 微调框 / enum 下拉 / 字符串输入框内嵌在节点体内,path 参数带「…」选文件按钮,多行文本(如 script.run 的内联脚本)带「编辑…」对话框;与右侧属性面板实时同步 | +| 右:属性 | 选中节点后编辑参数、数据入边字面量、以及 **slow 慢感知**(`everyFrames` / `onChange` / `intervalMs`) | +| 下:运行 / 设备 | 设备面板(视频源、单片机、OCR 后端)、运行状态表(执行/复用/计时)、日志 | + +工具栏:**运行图**(F5,提交当前画布给后端)、**停止**(Shift+F5)、**执行选中节点**(F6,双击节点同效)、 +图名 / 看门狗步数 / 时长上限、**重载节点目录**(Ctrl+R)。 + +- **运行图**:`POST /api/flow/run` → 每 300ms 轮询 `/api/flow/status` → 画布按节点着色 + (绿=已执行,红=出错节点),状态表显示每个节点的执行次数、slow 复用次数、最近/累计耗时。 +- **执行选中节点**(F6 / 双击节点本体):`POST /api/node/run` 传整图 + `nodeId`,所以该节点的数据入边引用的 + 上游会被一并求值(例如直接试跑 `ocr.text` 会自动先抓一帧),结果弹窗显示输出,输出里有图像时直接预览。 + **actuation 层(`pad.*` / `script.run`)会被后端拒绝**——试跑是只读的,不会驱动设备。 +- **设备面板**:直接操作后端的设备状态(连接/断开采集卡与单片机、切换 OCR 后端)。 + 视频源与单片机都用「刷新」重新扫描可用设备/串口(单片机下拉也可手输串口名); + OCR 的 PP-OCR 模型目录可点「打开目录…」用系统对话框选择。 +- **打开 / 保存**:`*.flow.json` 与后端 `flow` 命令 / `POST /api/flow/run` 完全同格式,文件可互换。 + 节点位置写在 `pos` 字段里(后端执行时忽略,仅用于布局)。 + +## 4. 目录 + +``` +frontend/ +├── pyproject.toml 项目元数据 + 依赖 + console script(uv / PEP 621) +├── uv.lock 锁定版本(提交入库,uv sync 复现) +├── .python-version 固定 Python 3.14 +├── run.sh 便捷包装:uv run easycon-flow +├── README.md +└── src/easycon_flow/ + ├── flowdoc.py flow.json 文档模型 + 画布映射(纯 Python,无 Qt,可单测) + ├── api.py 后端 HTTP 客户端 + ├── nodes.py 目录 → NodeGraphQt 节点类;画布 ↔ 状态搬运 + ├── window.py 主窗口(画布 / 节点库 / 属性 / 设备 / 运行面板) + ├── selftest.py 离线自检 + 对真后端的端到端往返(--backend) + ├── uitest.py 无头 UI 冒烟(建窗、载图、跑图、试跑节点;需要后端) + └── main.py 入口(console script: easycon-flow) +``` + +## 5. 常见问题 + +| 现象 | 原因 / 处理 | +|---|---| +| `ModuleNotFoundError: No module named 'distutils'` | Python ≥3.12 移除了 distutils,而 NodeGraphQt 仍在 import。`setuptools` 已写进 `pyproject.toml` 依赖(提供兼容层),跑过 `uv sync` 即不会再出现 | +| `uv sync` 报缓存目录不可写 | 受限环境(沙箱、CI)常见:`UV_CACHE_DIR=/tmp/uv-cache uv sync`(缓存位置与项目无关) | +| 界面提示「无法连接后端」 | 后端没起:先按第 1 节启动 `serve` 或 GUI;修好后点「重载节点目录」即可,无需重启界面 | +| 节点库是空的 | 同上;也可能后端目录拉取失败,看界面「日志」页签 | +| 连不上采集卡 | 同一块采集卡只能被一处独占:GUI 监控页连着时,画布这边请先断开监控页。单片机可先用 `mock` | +| 跑图不走 `chk.true` / `chk.false` 分支 | 条件节点的出口端口必须分开连;无标签 exec 边只作为兜底 | +| 感知节点总是拿到旧的画面 | 数据入边在同一张图里只惰性求值一次;把画面来源节点也接进 exec 路径(画布上正常串起来即可),或用 `slow` 明确表达复用意图 | + +规范与后端契约见 [`docs/Flow.md`](../docs/Flow.md)。 diff --git a/frontend/pyproject.toml b/frontend/pyproject.toml new file mode 100644 index 00000000..015fc154 --- /dev/null +++ b/frontend/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "easycon-flow" +version = "0.1.0" +description = "EasyCon 编排图(*.flow.json)画布前端:PySide6 + NodeGraphQt,节点全部由后端 /api/nodes 提供" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "Proprietary" } +dependencies = [ + "PySide6>=6.6", + "NodeGraphQt>=0.6.44", + "requests>=2.31", + # 必需项,不是可选项:NodeGraphQt 0.6.x 仍会 `import distutils`(PySide2 时代遗留), + # 而 Python >= 3.12 已把 distutils 移出标准库。setuptools 装好后提供 distutils 兼容层 + # (distutils-precedence.pth + _distutils_hack),否则 import NodeGraphQt 直接 ImportError。 + "setuptools>=68", +] + +[project.scripts] +easycon-flow = "easycon_flow.main:main" + +[project.urls] +Repository = "https://github.com/SlenderData/EasyCon" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/easycon_flow"] + +[tool.uv] +# 本项目是应用(非库):锁定并提交 uv.lock,用 `uv sync` / `uv run` 复现环境。 +# 画布自身不发布到 PyPI,console script 仅供本仓库 `uv run easycon-flow` 使用。 +package = true diff --git a/frontend/run.sh b/frontend/run.sh new file mode 100755 index 00000000..ddcbbdec --- /dev/null +++ b/frontend/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# 启动 EasyCon Flow 画布(环境由 uv 管理:pyproject.toml + uv.lock)。 +# +# ./run.sh # 打开界面(uv 首次会自动建 .venv 并装依赖) +# ./run.sh --backend http://127.0.0.1:19391 +# ./run.sh --self-test # 离线自检(画布 ↔ flow.json 往返),不开界面 +# ./run.sh --self-test --backend http://127.0.0.1:19391 # 追加对真后端的端到端检查 +# +# 等价于:uv sync && uv run easycon-flow [参数…] +set -euo pipefail + +cd "$(dirname "$0")" + +if ! command -v uv >/dev/null 2>&1; then + echo "[run.sh] 未找到 uv —— 本项目用 uv 管理 Python 环境与依赖。" >&2 + echo " 安装:curl -LsSf https://astral.sh/uv/install.sh | sh (macOS/Linux)" >&2 + echo " brew install uv / winget install astral-sh.uv" >&2 + echo " 也可不用本脚本,直接运行:uv run easycon-flow [参数…]" >&2 + exit 1 +fi + +# uv 会按 uv.lock 同步 .venv 后再执行 console script(首次运行会打印安装进度) +exec uv run easycon-flow "$@" diff --git a/frontend/src/easycon_flow/__init__.py b/frontend/src/easycon_flow/__init__.py new file mode 100644 index 00000000..b0544021 --- /dev/null +++ b/frontend/src/easycon_flow/__init__.py @@ -0,0 +1,11 @@ +"""EasyCon Flow 画布(Python 前端包)。 + +分层: + flowdoc.py flow.json 文档模型与画布映射(纯 Python,无 Qt,可单测) + api.py 后端 HTTP 客户端 + nodes.py 目录 → NodeGraphQt 节点类 + 画布 ↔ 状态搬运 + window.py 主窗口(画布 / 节点库 / 属性 / 设备 / 运行面板) + main.py 入口 +""" + +__all__ = ["api", "flowdoc", "nodes", "window"] diff --git a/frontend/src/easycon_flow/api.py b/frontend/src/easycon_flow/api.py new file mode 100644 index 00000000..01b8f887 --- /dev/null +++ b/frontend/src/easycon_flow/api.py @@ -0,0 +1,149 @@ +"""EasyCon Flow 服务 HTTP 客户端(后端 = CLI `serve` 或 GUI 内嵌服务)。 + +只依赖 requests;后端地址默认 http://127.0.0.1:19391(可用环境变量 EC_FLOW_URL 覆盖)。 +""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + +DEFAULT_BASE_URL = os.environ.get("EC_FLOW_URL", "http://127.0.0.1:19391") +DEFAULT_TIMEOUT = 20.0 + + +class ApiError(RuntimeError): + """后端返回的错误(带服务端给出的可读原因)。""" + + def __init__(self, message: str, status: int | None = None): + super().__init__(message) + self.status = status + + +class FlowApi: + """Flow 服务的薄封装:设备管理、图运行、单节点试跑、选项。""" + + def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + # ------------------------------------------------------------ 基础 + + def _url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def _request(self, method: str, path: str, **kwargs) -> Any: + kwargs.setdefault("timeout", self.timeout) + try: + response = requests.request(method, self._url(path), **kwargs) + except requests.RequestException as exc: + raise ApiError(f"无法连接 Flow 服务 {self.base_url}:{exc}") from exc + + if response.status_code >= 400: + raise ApiError(self._error_text(response), response.status_code) + if not response.content: + return None + try: + return response.json() + except ValueError: + return response.text + + @staticmethod + def _error_text(response: requests.Response) -> str: + try: + payload = response.json() + except ValueError: + return f"HTTP {response.status_code}: {response.text[:200]}" + if isinstance(payload, dict): + return str(payload.get("error") or payload.get("errors") or payload) + return f"HTTP {response.status_code}: {payload}" + + def get(self, path: str) -> Any: + return self._request("GET", path) + + def post(self, path: str, payload: dict | None = None) -> Any: + return self._request("POST", path, json=payload or {}) + + # ------------------------------------------------------------ 服务与目录 + + def health(self) -> dict: + return self.get("/api/health") + + def nodes(self) -> dict: + """节点目录(nodes / slow / keys / ocrBackends)。""" + return self.get("/api/nodes") + + def options(self) -> dict: + return self.get("/api/options") + + def set_ocr_backend(self, backend: str, model_dir: str | None = None) -> dict: + payload: dict = {"ocrBackend": backend} + if model_dir: + payload["ocrModelDir"] = model_dir + return self.post("/api/options", payload) + + # ------------------------------------------------------------ 设备 + + def video_info(self) -> dict: + return self.get("/api/device/video") + + def connect_video(self, index: int, api: int = 0) -> dict: + return self.post("/api/device/video/connect", {"index": int(index), "api": int(api)}) + + def disconnect_video(self) -> dict: + return self.post("/api/device/video/disconnect") + + def mcu_info(self) -> dict: + return self.get("/api/device/mcu") + + def connect_mcu(self, port: str) -> dict: + return self.post("/api/device/mcu/connect", {"port": port}) + + def disconnect_mcu(self) -> dict: + return self.post("/api/device/mcu/disconnect") + + # ------------------------------------------------------------ 图运行 + + def run_flow(self, graph: dict) -> str: + """提交图 JSON,返回 runId。""" + payload = self.post("/api/flow/run", {"json": _dumps(graph)}) + run_id = (payload or {}).get("runId") + if not run_id: + raise ApiError(f"后端未返回 runId: {payload!r}") + return str(run_id) + + def run_flow_file(self, path: str) -> str: + payload = self.post("/api/flow/run", {"path": path}) + run_id = (payload or {}).get("runId") + if not run_id: + raise ApiError(f"后端未返回 runId: {payload!r}") + return str(run_id) + + def stop_flow(self, run_id: str) -> dict: + return self.post("/api/flow/stop", {"runId": run_id}) + + def status(self, run_id: str | None = None) -> dict: + path = "/api/flow/status" if not run_id else f"/api/flow/status?runId={run_id}" + return self.get(path) + + # ------------------------------------------------------------ 单节点试跑 + + def run_node(self, node_type: str, params: dict | None = None, + inputs: dict | None = None) -> dict: + payload: dict = {"type": node_type} + if params: + payload["params"] = params + if inputs: + payload["inputs"] = inputs + return self.post("/api/node/run", payload) + + def run_node_in_graph(self, graph: dict, node_id: str) -> dict: + """按图试跑某个节点:其数据入边引用的上游会被一并求值。""" + return self.post("/api/node/run", {"graph": _dumps(graph), "nodeId": node_id}) + + +def _dumps(payload: dict) -> str: + import json + return json.dumps(payload, ensure_ascii=False) diff --git a/frontend/src/easycon_flow/flowdoc.py b/frontend/src/easycon_flow/flowdoc.py new file mode 100644 index 00000000..38ab5308 --- /dev/null +++ b/frontend/src/easycon_flow/flowdoc.py @@ -0,0 +1,326 @@ +"""flow.json 文档模型与画布映射(纯 Python,无 Qt 依赖,可独立测试)。 + +后端契约(`docs/Flow.md` 与 `GET /api/nodes` 是唯一事实源): + + { + "version": 1, + "name": "battle", + "maxSteps": 3000, # 看门狗:节点执行步数 + "timeoutSec": 1800, # 看门狗:总时长 + "nodes": [ { "id": "cap", "type": "capture.frame", + "pos": {"x": 10, "y": 20}, # 仅前端布局,执行忽略 + "params": {...}, "slow": {...}, + "inputs": { "image": {"node": "cap", "port": "image"}, + "b": 3 } } ], # 引用 或 字面量 + "exec": [ ["cap", "ocr"], ["chk.true", "hit"] ] # "node" 或 "node.outPort" + } + +本模块只做两件事:把 flow.json 解析成画布状态、把画布状态写回 flow.json。 +Qt 侧(nodes.py / window.py)负责 NodeGraphQt ↔ 画布状态的搬运。 +""" + +from __future__ import annotations + +import copy +import re +from dataclasses import dataclass, field + +DEFAULT_MAX_STEPS = 3000 +DEFAULT_TIMEOUT_SEC = 1800 +FORMAT_VERSION = 1 + +EXEC_KIND = "exec" +DATA_KIND = "data" + + +class FlowDocError(ValueError): + """flow.json 结构错误(前端侧预校验,后端还会再校验一次)。""" + + +@dataclass +class NodeState: + """画布上的一个节点(与 flow.json 的 node 对象一一对应)。""" + + id: str + type: str + pos: tuple[float, float] | None = None + params: dict = field(default_factory=dict) + literals: dict = field(default_factory=dict) + slow: dict = field(default_factory=dict) + + def clone(self) -> NodeState: + return NodeState(self.id, self.type, self.pos, copy.deepcopy(self.params), + copy.deepcopy(self.literals), copy.deepcopy(self.slow)) + + +@dataclass +class Connection: + """画布上的一条连线:exec 控制流或数据流。""" + + src_id: str + src_port: str + dst_id: str + dst_port: str + kind: str = EXEC_KIND + + +# ---------------------------------------------------------------- 端点表示 + +def exec_endpoint(node_id: str, port: str | None) -> str: + """exec 边的 from 表示:单出口写 "id",条件出口写 "id.true"。""" + if port in (None, "", "out"): + return node_id + return f"{node_id}.{port}" + + +def split_endpoint(text: str) -> tuple[str, str | None]: + """把 "id.true" 拆成 ("id", "true");"id" → ("id", None)。 + + 节点 id 里可能含点号(用户自定义),因此从**第一个**点号切分: + 节点的 id 约定不含点号(前端生成 id 时也会保证),这里按后端 FromNode 的同一规则处理。 + """ + if "." in text: + node, port = text.split(".", 1) + return node, (port or None) + return text, None + + +def normalize_exec_edge(edge) -> tuple[str, str | None, str]: + """兼容后端的两种 exec 边形态,返回 (from_node, from_port, to_node)。""" + if isinstance(edge, (list, tuple)): + if not edge: + raise FlowDocError("exec 边不能为空数组") + src = str(edge[0]) + dst = str(edge[1]) if len(edge) > 1 else "" + elif isinstance(edge, dict): + src = str(edge.get("from", "")) + dst = str(edge.get("to", "")) + else: + raise FlowDocError(f"无法识别的 exec 边: {edge!r}") + node, port = split_endpoint(src) + return node, port, dst + + +def make_exec_edge(node_id: str, port: str | None, dst_id: str) -> list[str]: + endpoint = exec_endpoint(node_id, port) + return [endpoint, dst_id] + + +# ---------------------------------------------------------------- 解析 + +def parse_doc(doc: dict, catalog: dict[str, dict] | None = None) -> tuple[str, int, int, list[NodeState], list[Connection]]: + """flow.json → (name, maxSteps, timeoutSec, nodes, connections)。 + + catalog(type → 目录项)用于把与参数同名的数据入边字面量并回参数,避免画布上出现 + 「参数 x」与「数据输入字面量 x」两个重复控件。 + """ + if not isinstance(doc, dict): + raise FlowDocError("图文档必须是 JSON 对象") + + name = str(doc.get("name") or "untitled") + max_steps = _as_int(doc.get("maxSteps"), DEFAULT_MAX_STEPS) + timeout_sec = _as_int(doc.get("timeoutSec"), DEFAULT_TIMEOUT_SEC) + + raw_nodes = doc.get("nodes") or [] + if not isinstance(raw_nodes, list): + raise FlowDocError("nodes 必须是数组") + + nodes: list[NodeState] = [] + seen: set[str] = set() + data_connections: list[Connection] = [] + for raw in raw_nodes: + if not isinstance(raw, dict): + raise FlowDocError("节点必须是对象") + node_id = str(raw.get("id") or "") + node_type = str(raw.get("type") or "") + if not node_id: + raise FlowDocError("节点缺少 id") + if not node_type: + raise FlowDocError(f"节点 {node_id} 缺少 type") + if node_id in seen: + raise FlowDocError(f"节点 id 重复: {node_id}") + seen.add(node_id) + + pos_raw = raw.get("pos") or {} + pos = None + if isinstance(pos_raw, dict) and ("x" in pos_raw or "y" in pos_raw): + pos = (float(pos_raw.get("x") or 0.0), float(pos_raw.get("y") or 0.0)) + + params = copy.deepcopy(raw.get("params") or {}) + slow = copy.deepcopy(raw.get("slow") or {}) + + literals: dict = {} + inputs = raw.get("inputs") or {} + if isinstance(inputs, dict): + for port, value in inputs.items(): + if is_reference(value): + data_connections.append(Connection( + src_id=str(value.get("node") or ""), + src_port=str(value.get("port") or ""), + dst_id=node_id, + dst_port=str(port), + kind=DATA_KIND, + )) + else: + literals[str(port)] = value + + # 与参数同名的数据入边字面量并回参数(后端也是「入边优先于参数」) + spec = (catalog or {}).get(node_type) + if spec: + param_names = {p["name"] for p in (spec.get("params") or [])} + for port in list(literals): + if port in param_names: + params[port] = literals.pop(port) + + nodes.append(NodeState(id=node_id, type=node_type, pos=pos, params=params, + literals=literals, slow=slow)) + + exec_edges = doc.get("exec") or [] + if not isinstance(exec_edges, list): + raise FlowDocError("exec 必须是数组") + for edge in exec_edges: + src_node, src_port, dst_node = normalize_exec_edge(edge) + if not dst_node: + raise FlowDocError(f"exec 边缺少目标节点: {edge!r}") + data_connections.append(Connection(src_id=src_node, src_port=src_port or "out", + dst_id=dst_node, dst_port="in", kind=EXEC_KIND)) + + return name, max_steps, timeout_sec, nodes, data_connections + + +def is_reference(value) -> bool: + """判断数据入边值是否为 {"node","port"} 引用。""" + return isinstance(value, dict) and "node" in value + + +# ---------------------------------------------------------------- 序列化 + +def build_doc(name: str, nodes: list[NodeState], connections: list[Connection], + max_steps: int = DEFAULT_MAX_STEPS, timeout_sec: int = DEFAULT_TIMEOUT_SEC, + catalog: dict[str, dict] | None = None) -> dict: + """画布状态 → flow.json。同一端口既有字面量又有连线时以连线为准。""" + doc: dict = { + "version": FORMAT_VERSION, + "name": name or "untitled", + "maxSteps": int(max_steps), + "timeoutSec": int(timeout_sec), + "nodes": [], + "exec": [], + } + + node_objects: dict[str, dict] = {} + for state in nodes: + node: dict = {"id": state.id, "type": state.type} + if state.pos is not None: + node["pos"] = {"x": round(float(state.pos[0]), 1), "y": round(float(state.pos[1]), 1)} + + params = copy.deepcopy(state.params) + spec = (catalog or {}).get(state.type) + param_names = {p["name"] for p in (spec.get("params") or [])} if spec else set() + + inputs: dict = {} + for port, value in (state.literals or {}).items(): + if value is None or value == "": + continue + if port in param_names and port not in params: + params[port] = value + elif port not in param_names: + inputs[port] = value + + for conn in connections: + if conn.dst_id == state.id and conn.kind == DATA_KIND: + inputs[conn.dst_port] = {"node": conn.src_id, "port": conn.src_port} + + if params: + node["params"] = params + if inputs: + node["inputs"] = inputs + if state.slow: + node["slow"] = copy.deepcopy(state.slow) + + doc["nodes"].append(node) + node_objects[state.id] = node + + for conn in connections: + if conn.kind != EXEC_KIND: + continue + doc["exec"].append(make_exec_edge(conn.src_id, conn.src_port, conn.dst_id)) + + return doc + + +# ---------------------------------------------------------------- 预校验 + +def validate_doc(doc: dict) -> list[str]: + """前端侧结构校验(与后端 FlowGraph.Validate 对齐):返回可读问题列表,空 = 通过。""" + problems: list[str] = [] + nodes = doc.get("nodes") or [] + ids = [str(n.get("id") or "") for n in nodes] + + duplicates = {i for i in ids if ids.count(i) > 1} + for dup in sorted(duplicates): + problems.append(f"节点 id 重复: {dup}") + if not nodes: + problems.append("图中没有任何节点") + + known = set(ids) + starts = [i for i, n in zip(ids, nodes) if n.get("type") == "start"] + if len(starts) > 1: + problems.append(f"start 节点只能有一个,当前 {len(starts)} 个: {', '.join(starts)}") + for edge in doc.get("exec") or []: + try: + src_node, _src_port, dst_node = normalize_exec_edge(edge) + except FlowDocError as exc: + problems.append(str(exc)) + continue + if src_node != "start" and src_node not in known: + problems.append(f"exec 边引用了不存在的节点: {src_node}") + if dst_node not in known: + problems.append(f"exec 边引用了不存在的节点: {dst_node}") + + if not starts: + # 与后端 FindStart 一致:无 start 时靠「唯一无 exec 入边节点」兜底 + targets = set() + for edge in doc.get("exec") or []: + try: + _src, _port, dst = normalize_exec_edge(edge) + except FlowDocError: + continue + targets.add(dst) + roots = [i for i in ids if i not in targets] + if len(roots) != 1: + problems.append("未定义 start 节点,且无 exec 入边的节点不唯一(后端会拒绝执行)") + + return problems + + +# ---------------------------------------------------------------- 画布辅助 + +_ID_SAFE = re.compile(r"[^0-9A-Za-z_]+") + + +def make_node_id(node_type: str, existing: set[str]) -> str: + """按类型生成唯一节点 id(点号换成下划线,点号会破坏 exec 端点解析)。""" + base = _ID_SAFE.sub("_", node_type).strip("_") or "node" + if base not in existing: + return base + index = 2 + while f"{base}{index}" in existing: + index += 1 + return f"{base}{index}" + + +def unique_name(name: str, existing: set[str]) -> str: + if name not in existing: + return name + index = 2 + while f"{name}_{index}" in existing: + index += 1 + return f"{name}_{index}" + + +def _as_int(value, fallback: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return fallback diff --git a/frontend/src/easycon_flow/main.py b/frontend/src/easycon_flow/main.py new file mode 100644 index 00000000..e65e0cec --- /dev/null +++ b/frontend/src/easycon_flow/main.py @@ -0,0 +1,60 @@ +"""入口:启动 EasyCon Flow 画布。 + +用法: + python -m easycon_flow.main [--backend http://127.0.0.1:19391] + +后端地址也可用环境变量 EC_FLOW_URL 指定;界面内可随时“重载节点目录”重连。 +""" + +from __future__ import annotations + +import argparse +import os +import sys + +from PySide6 import QtWidgets + +from .api import ApiError, DEFAULT_BASE_URL, FlowApi +from .window import FlowWindow + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="EasyCon Flow 编排画布(后端必须已启动)") + parser.add_argument("--backend", default=None, + help=f"后端 Flow 服务地址(默认 {DEFAULT_BASE_URL},可用 EC_FLOW_URL 覆盖)") + parser.add_argument("--self-test", action="store_true", help="只做离线自检,不打开界面") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + args = parse_args(argv) + + if args.self_test: + # 自检用独立参数表;显式给了 --backend 才跑对真后端的端到端检查 + from .selftest import run_self_test + return run_self_test(["--backend", args.backend] if args.backend else []) + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) + app.setApplicationName("EasyCon Flow") + + api = FlowApi(args.backend or DEFAULT_BASE_URL) + window = FlowWindow(api) + + try: + api.health() + except ApiError as exc: + window.log(f"[后端] 连接失败:{exc}") + QtWidgets.QMessageBox.warning( + window, "后端未就绪", + f"{exc}\n\n启动后端之一:\n" + " 1) dotnet run --project src/EasyCon2.CLI -- serve --port 19391\n" + f" 2) 启动 EasyCon GUI(内嵌服务,默认 19391;端口 {os.environ.get('EC_FLOW_PORT', '19391')})\n\n" + "界面已打开,修复后点“重载节点目录”即可。") + + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/src/easycon_flow/nodes.py b/frontend/src/easycon_flow/nodes.py new file mode 100644 index 00000000..c2c2b292 --- /dev/null +++ b/frontend/src/easycon_flow/nodes.py @@ -0,0 +1,553 @@ +"""目录 → NodeGraphQt 节点类(动态生成)+ 画布 ↔ NodeState 搬运。 + +节点**全部由后端目录驱动**(`GET /api/nodes`):前端不硬编码任何节点类型、参数或端口。 +后端加一个节点并登记到 FlowNodeCatalog,画布刷新后即出现,无需改前端。 + +参数控件:每个参数除属性面板(右侧 PropertiesBin)外,还会在**节点体内嵌一个同值控件**, +可直接在画布上编辑。控件与属性的双向同步由 NodeGraphQt 自带机制完成—— +控件编辑经 value_changed → set_property 写回模型;任何 set_property(含属性面板编辑) +经 PropertyChangedCmd 自动回填 view.widgets[属性名]。因此内嵌控件的 get_value 必须 +与属性值**严格等值往返**(见 IntNodeSpinBox / _PreviewEdit)。 +""" + +from __future__ import annotations + +import re + +from NodeGraphQt import BaseNode +from NodeGraphQt.constants import NodePropWidgetEnum +from NodeGraphQt.widgets.node_widgets import (NodeButton, NodeCheckBox, + NodeComboBox, NodeLineEdit, + NodeSpinBox) +from PySide6 import QtWidgets + +from .flowdoc import (DATA_KIND, EXEC_KIND, Connection, NodeState, is_reference) + +NODE_IDENTIFIER = "easycon" +NODE_ID_PROPERTY = "node_id" +LITERAL_PREFIX = "lit_" +SLOW_PREFIX = "slow_" +PARAM_PREFIX = "p_" +BUTTON_EDIT_PREFIX = "btn_edit_" +BUTTON_PICK_PREFIX = "btn_pick_" + +# NodeGraphQt 预置属性名(model.py: "reserved for default property")。 +# 目录里的参数若与之同名(如 state.step 的 name),必须加 p_ 前缀,读取时再剥掉。 +RESERVED_PROPERTY_NAMES = { + "type_", "icon", "name", "color", "border_color", "text_color", "disabled", + "selected", "visible", "width", "height", "pos", "layout_direction", + "port_deletion_allowed", "subgraph_session", "id", + "_TEMP_accept_connection_types", "_TEMP_reject_connection_types", +} + + +def param_property(param_name: str) -> str: + """参数名 → 节点属性名(避开 NodeGraphQt 预置属性)。""" + return PARAM_PREFIX + param_name if param_name in RESERVED_PROPERTY_NAMES else param_name + + +def param_name(property_name: str) -> str: + """节点属性名 → 参数名。""" + return property_name[len(PARAM_PREFIX):] if property_name.startswith(PARAM_PREFIX) else property_name + +# 层配色(节点底色) +LAYER_COLORS = { + "flow": (86, 92, 104), + "sense": (52, 88, 132), + "decision": (140, 96, 40), + "actuation": (124, 56, 56), +} + +_WIDGET_BY_TYPE = { + "bool": NodePropWidgetEnum.QCHECK_BOX.value, + "int": NodePropWidgetEnum.QSPIN_BOX.value, + "number": NodePropWidgetEnum.QSPIN_BOX.value, + "float": NodePropWidgetEnum.FLOAT.value, + "enum": NodePropWidgetEnum.QCOMBO_BOX.value, + "path": NodePropWidgetEnum.FILE_OPEN.value, + "string": NodePropWidgetEnum.QLINE_EDIT.value, + "text": NodePropWidgetEnum.QTEXT_EDIT.value, + "image": NodePropWidgetEnum.QLINE_EDIT.value, + "any": NodePropWidgetEnum.QLINE_EDIT.value, +} + +_INT_RANGE = (-10_000_000, 10_000_000) +_FLOAT_RANGE = (-1.0e9, 1.0e9) + + +class IntNodeSpinBox(NodeSpinBox): + """内嵌整数微调框。NodeGraphQt 自带 NodeSpinBox.get_value 返回字符串, + 这里保持 int,属性 ↔ 控件才能严格等值往返(否则 set_property 会把 int 覆盖成 str)。""" + + def get_value(self): + return int(self.get_custom_widget().value()) + + def set_value(self, value=0): + if value != self.get_value(): + self.get_custom_widget().setValue(int(value)) + + +class FloatNodeSpinBox(NodeSpinBox): + """同 IntNodeSpinBox,浮点版。""" + + def get_value(self): + return float(self.get_custom_widget().value()) + + def set_value(self, value=0.0): + if value != self.get_value(): + self.get_custom_widget().setValue(float(value)) + + +class PreviewLineEdit(NodeLineEdit): + """text(多行)参数的节点内只读预览:显示首行,值原样保留。 + + get_value 必须原样返回完整多行文本——属性面板/对话框修改参数后,NodeGraphQt 的 + PropertyChangedCmd 会比较 widgets[name].get_value() 与新值来决定是否回填控件; + 若这里返回被行编辑器截断的文本,多行内容会被反向写回模型而丢失。 + 控件只读、value_changed 不接线:预览永不回写,值以节点属性为唯一事实源。 + """ + + def __init__(self, parent=None, name="", label="", text=""): + super().__init__(parent, name, label, "") + self._full_text = str(text or "") + line = self.get_custom_widget() + line.setReadOnly(True) + line.setText(self._first_line()) + line.setPlaceholderText("(空 — 点「编辑…」填写)") + + def _first_line(self) -> str: + return self._full_text.splitlines()[0] if self._full_text else "" + + def get_value(self): + return self._full_text + + def set_value(self, text=""): + if str(text) != self._full_text: + self._full_text = str(text) + self.get_custom_widget().setText(self._first_line()) + + +def class_name_for(node_type: str) -> str: + """节点类型 → Python 类名(点号等非法字符换成下划线)。""" + return "easycon_" + (re.sub(r"[^0-9A-Za-z_]+", "_", node_type).strip("_") or "node") + + +def type_key(node_type: str) -> str: + """NodeGraphQt 的节点类型键(.)。""" + return f"{NODE_IDENTIFIER}.{class_name_for(node_type)}" + + +def default_value(param: dict): + if "default" in param and param["default"] is not None: + return param["default"] + ptype = param.get("type") + if ptype == "bool": + return False + if ptype in ("int", "number"): + return 0 + if ptype in ("enum",) and param.get("options"): + return param["options"][0] + return "" + + +def make_node_class(spec: dict, slow_fields: list[dict]) -> type: + """按目录项生成一个 NodeGraphQt 节点类。 + + 命名约定(序列化时按前缀还原): + - 节点参数 → 属性名 = 参数名 + - slow 策略字段 → 属性名 = slow_<字段> + - 未连线的数据入边字面量 → 属性名 = lit_<端口> + """ + node_type = spec["type"] + ports = spec.get("ports") or [] + params = spec.get("params") or [] + + def __init__(self): + BaseNode.__init__(self) + self._spec = spec + self._slow_field_names = [f["name"] for f in slow_fields] + + for port in ports: + kind = port.get("kind") + name = port.get("name") or "out" + if kind == "exec-in": + # exec 入口必须允许多条入边:循环回跳 + 分支汇合都会指向同一个 in + self.add_input("in", multi_input=True) + elif kind == "exec-out": + self.add_output(name) + elif kind == "data-in": + self.add_input(name, multi_input=False, display_name=True) + elif kind == "data-out": + self.add_output(name, multi_output=True, display_name=True) + + self.create_property(NODE_ID_PROPERTY, "", widget_type=NodePropWidgetEnum.HIDDEN.value) + + for param in params: + prop = param_property(param["name"]) + value_type = param.get("type", "string") + stored = _create_property(self, prop, value_type, default_value(param), + param.get("description", ""), param.get("options")) + try: + _embed_param_widget(self, prop, param["name"], value_type, stored, + param.get("description", ""), param.get("options")) + except Exception: # noqa: BLE001 - 内嵌失败只损失节点内编辑,属性面板仍可用 + pass + + # 未连线的数据入边字面量(与参数同名的端口不重复建控件——参数本身就是那个值) + param_names = {p["name"] for p in params} + for port in ports: + if port.get("kind") != "data-in" or port["name"] in param_names: + continue + _create_property(self, LITERAL_PREFIX + port["name"], port.get("type", "any"), + "", port.get("description", ""), None) + + for field in slow_fields: + _create_property(self, SLOW_PREFIX + field["name"], field.get("type", "string"), + default_value(field), f"slow: {field.get('description', '')}", + field.get("options")) + + self.set_color(*LAYER_COLORS.get(spec.get("layer", "flow"), LAYER_COLORS["flow"])) + self.view.draw_node() + + return type(class_name_for(node_type), (BaseNode,), { + "__identifier__": NODE_IDENTIFIER, + "NODE_NAME": node_type, + "__init__": __init__, + }) + + +def _create_property(node: BaseNode, name: str, value_type: str, value, tooltip: str, + options: list[str] | None) -> object: + """建属性(只进右侧属性面板,不在节点体内嵌控件);返回实际存入的(归一化)值。 + 控件类型不兼容时退化为字符串控件(UI 不能因为目录变化而崩)。""" + widget = _WIDGET_BY_TYPE.get(value_type, NodePropWidgetEnum.QLINE_EDIT.value) + kwargs: dict = {"widget_type": widget, "widget_tooltip": tooltip or None} + if value_type == "enum" and options: + kwargs["items"] = list(options) + if value not in options: + value = options[0] + elif value_type in ("int", "number"): + kwargs["range"] = list(_INT_RANGE) + value = int(value or 0) + elif value_type == "bool": + value = bool(value) + else: + value = "" if value is None else str(value) + + try: + node.create_property(name, value, **kwargs) + except Exception: # noqa: BLE001 - 目录里出现未知控件类型时保底 + value = str(value) + node.create_property(name, value) + return value + + +def _embed_param_widget(node: BaseNode, prop: str, label: str, value_type: str, value, + tooltip: str, options: list[str] | None) -> None: + """在节点体内嵌参数控件(节点上直接编辑参数)。 + + 控件编辑 → value_changed → set_property 写回模型;属性面板/加载图对参数的任何 + set_property 由 NodeGraphQt(PropertyChangedCmd)自动回填同名列控件—— + 两边永远显示同一个值。控件类型按参数类型选择: + bool 勾选框、int/number/float 微调框、enum 下拉、path 输入框+「…」选文件、 + text 只读首行预览+「编辑…」多行对话框、其余单行输入。 + """ + if value_type == "bool": + widget = NodeCheckBox(node.view, prop, label, "", bool(value)) + elif value_type in ("int", "number"): + widget = IntNodeSpinBox(node.view, prop, label, int(value or 0), *_INT_RANGE) + elif value_type == "float": + widget = FloatNodeSpinBox(node.view, prop, label, float(value or 0.0), + *_FLOAT_RANGE, True) + elif value_type == "enum": + widget = NodeComboBox(node.view, prop, label, list(options or [])) + widget.set_value(str(value)) + elif value_type == "text": + widget = PreviewLineEdit(node.view, prop, label, str(value or "")) + _embed_button(node, BUTTON_EDIT_PREFIX + prop, label, "编辑…", tooltip, + lambda checked=False, n=node, p=prop, l=label: _edit_multiline(n, p, l)) + elif value_type == "path": + widget = NodeLineEdit(node.view, prop, label, str(value or "")) + _embed_button(node, BUTTON_PICK_PREFIX + prop, label, "…", f"{tooltip}(点击选择文件)", + lambda checked=False, n=node, p=prop: _pick_file(n, p)) + else: + widget = NodeLineEdit(node.view, prop, label, str(value or "")) + + widget.setToolTip(tooltip or "") + widget.value_changed.connect(lambda name, val, n=node: n.set_property(name, val)) + node.view.add_widget(widget) + + +def _embed_button(node: BaseNode, name: str, label: str, text: str, tooltip: str, + on_clicked) -> None: + """内嵌一个动作按钮(不承载参数值)。注册同名隐藏属性:update_model() 会把 + view.widgets 的值写回模型,未登记的属性名会让它抛异常。""" + node.create_property(name, "", widget_type=NodePropWidgetEnum.HIDDEN.value) + widget = NodeButton(node.view, name, label, text) + widget.setToolTip(tooltip or "") + widget.get_custom_widget().clicked.connect(on_clicked) + node.view.add_widget(widget) + + +def _edit_multiline(node: BaseNode, prop: str, label: str) -> None: + """「编辑…」按钮:多行文本对话框(text 参数,如 script.run 的内联脚本)。""" + dialog = _MultiLineDialog(f"编辑 {label}", str(node.get_property(prop) or ""), + QtWidgets.QApplication.activeWindow()) + if dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + node.set_property(prop, dialog.text()) + + +def _pick_file(node: BaseNode, prop: str) -> None: + """「…」按钮:文件选择对话框(path 参数),写回属性并同步内嵌输入框。""" + current = str(node.get_property(prop) or "") + path, _ = QtWidgets.QFileDialog.getOpenFileName( + QtWidgets.QApplication.activeWindow(), "选择文件", current, "所有文件 (*)") + if path: + node.set_property(prop, path) + + +class _MultiLineDialog(QtWidgets.QDialog): + """多行文本编辑对话框(确定返回 True)。""" + + def __init__(self, title: str, content: str, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.resize(560, 420) + layout = QtWidgets.QVBoxLayout(self) + self._edit = QtWidgets.QPlainTextEdit() + self._edit.setPlainText(content) + layout.addWidget(self._edit) + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def text(self) -> str: + return self._edit.toPlainText() + + +class NodeCatalog: + """后端目录的本地视图:按类型查规格,并把动态节点类注册到画布实例。 + + NodeGraphQt 的节点注册挂在 graph 实例上,因此换画布(新建/打开/重载目录)时 + 必须对目标 graph 重新 `register_into`。 + """ + + def __init__(self, payload: dict): + self.specs: dict[str, dict] = {} + self.types: dict[str, str] = {} # flow 类型 → NodeGraphQt 类型键 + self.slow_fields: list[dict] = list(payload.get("slow") or []) + self.keys: list[str] = list(payload.get("keys") or []) + self.ocr_backends: list[str] = list(payload.get("ocrBackends") or []) + self.version = payload.get("version", 1) + self.register_errors: list[str] = [] + + for spec in payload.get("nodes") or []: + self.specs[spec["type"]] = spec + self.types[spec["type"]] = type_key(spec["type"]) + + def register_into(self, graph) -> None: + """把目录节点类注册到指定画布(重复注册会被 NodeGraphQt 拒绝,故逐个容错)。""" + self.register_errors = [] + for node_type, spec in self.specs.items(): + try: + graph.register_node(make_node_class(spec, self.slow_fields)) + except Exception as exc: # noqa: BLE001 - 单个节点注册失败不该拖垮整个画布 + self.register_errors.append(f"{node_type}: {exc}") + + def spec(self, node_type: str) -> dict: + return self.specs.get(node_type) or {"type": node_type, "layer": "flow", "ports": [], "params": []} + + def layers(self) -> dict[str, list[dict]]: + grouped: dict[str, list[dict]] = {} + for spec in self.specs.values(): + grouped.setdefault(spec.get("layer", "flow"), []).append(spec) + return grouped + + +# ---------------------------------------------------------------- 端口查找 + +def output_port(node: BaseNode, name: str): + """按名取输出端口(NodeGraphQt 的 output_ports() 是列表,不是字典)。""" + return next((p for p in node.output_ports() if p.name() == name), None) + + +def input_port(node: BaseNode, name: str): + """按名取输入端口。""" + return next((p for p in node.input_ports() if p.name() == name), None) + + +def port_name(port) -> str: + return port.name() if port is not None else "" + + +# ---------------------------------------------------------------- 画布 → 状态 + +def node_id(node: BaseNode) -> str: + value = node.get_property(NODE_ID_PROPERTY) + return str(value) if value else node.name() + + +def node_type_of(node: BaseNode) -> str: + spec = getattr(node, "_spec", None) + if spec: + return spec["type"] + type_ = node.type_ or "" + return type_.split(".", 1)[1] if "." in type_ else type_ + + +def collect_states(graph) -> list[NodeState]: + """画布 → NodeState 列表(参数 / 字面量 / slow)。 + + 按目录规格逐项读取,而不是遍历节点属性表——后者会把 NodeGraphQt 的预置属性 + (name/color/pos/...)误当成节点参数,且无法区分同名冲突项。 + """ + states: list[NodeState] = [] + for node in graph.all_nodes(): + spec = getattr(node, "_spec", None) + if spec is None: + continue # 非本目录节点(如 BackdropNode)不参与 flow.json + + params: dict = {} + for param in spec.get("params") or []: + prop = param_property(param["name"]) + if node.has_property(prop): + value = node.get_property(prop) + if _is_meaningful(value): + params[param["name"]] = value + + param_names = {p["name"] for p in (spec.get("params") or [])} + literals: dict = {} + for port in spec.get("ports") or []: + if port.get("kind") != "data-in" or port["name"] in param_names: + continue # 与参数同名的入边由参数控件承载 + prop = LITERAL_PREFIX + port["name"] + if node.has_property(prop): + value = node.get_property(prop) + if _is_meaningful(value): + literals[port["name"]] = value + + slow: dict = {} + for field in getattr(node, "_slow_field_names", []): + prop = SLOW_PREFIX + field + if node.has_property(prop): + value = node.get_property(prop) + # slow 的 0/False 语义就是「该条件不启用」→ 不写进 flow.json + if value: + slow[field] = value + + pos = node.pos() + states.append(NodeState( + id=node_id(node), + type=spec["type"], + pos=(float(pos[0]), float(pos[1])) if pos else None, + params=params, + literals=literals, + slow=slow, + )) + return states + + +def collect_connections(graph) -> list[Connection]: + """画布 → 连线列表(按源端口的种类区分 exec / data)。""" + result: list[Connection] = [] + for node in graph.all_nodes(): + spec = getattr(node, "_spec", None) + if spec is None: + continue + exec_outs = {p["name"] for p in (spec.get("ports") or []) if p.get("kind") == "exec-out"} + for port in node.output_ports(): + port_name = port.name() + for other in port.connected_ports(): + target = other.node() + if getattr(target, "_spec", None) is None: + continue + result.append(Connection( + src_id=node_id(node), + src_port=port_name, + dst_id=node_id(target), + dst_port=other.name() if port_name not in exec_outs else "in", + kind=EXEC_KIND if port_name in exec_outs else DATA_KIND, + )) + return result + + +def _is_meaningful(value) -> bool: + """空字符串 / None 视为「未设置」,不写进 flow.json(保持文件干净)。""" + if value is None: + return False + if isinstance(value, str): + return value.strip() != "" + return True + + +# ---------------------------------------------------------------- 状态 → 画布 + +def add_state(graph, catalog: NodeCatalog, state: NodeState) -> BaseNode: + """按 NodeState 在画布上建节点并回填属性。""" + type_ = catalog.types.get(state.type) + if type_ is None: + raise KeyError(f"目录里没有节点类型: {state.type}") + + node = graph.create_node(type_, name=state.id, + pos=tuple(state.pos) if state.pos else None) + node.set_property(NODE_ID_PROPERTY, state.id) + + spec = catalog.spec(state.type) + param_names = {p["name"] for p in (spec.get("params") or [])} + for name, value in (state.params or {}).items(): + prop = param_property(name) + if node.has_property(prop): + node.set_property(prop, value) + elif node.has_property(LITERAL_PREFIX + name): + # 旧图/手写图可能把「只是数据入边」的端口写在 params 里(如 capture.frame.x): + # 后端只读 inputs,这里把它落到该端口的字面量控件上,值不至于丢失 + node.set_property(LITERAL_PREFIX + name, value) + + for port, value in (state.literals or {}).items(): + prop = LITERAL_PREFIX + port + if node.has_property(prop): + node.set_property(prop, value) + elif node.has_property(param_property(port)): # 与参数同名的入边字面量 + node.set_property(param_property(port), value) + + for field, value in (state.slow or {}).items(): + prop = SLOW_PREFIX + field + if node.has_property(prop): + node.set_property(prop, value) + + return node + + +def literal_inputs(node: BaseNode) -> dict: + """节点的字面量数据入边(供 validate/试跑使用)。""" + result: dict = {} + for prop in node.properties().values(): + name = prop.name + if name.startswith(LITERAL_PREFIX): + value = node.get_property(name) + if _is_meaningful(value): + result[name[len(LITERAL_PREFIX):]] = value + return result + + +def reference_ports(node: BaseNode) -> set[str]: + """已被连线占用的数据入边端口名。""" + used: set[str] = set() + exec_ins = {p.get("name") for p in (getattr(node, "_spec", {}) or {}).get("ports", []) + if p.get("kind") == "exec-in"} + for port in node.input_ports(): + if port.name() in exec_ins: + continue + if port.connected_ports(): + used.add(port.name()) + return used + + +__all__ = [ + "DATA_KIND", "EXEC_KIND", "LAYER_COLORS", "NODE_ID_PROPERTY", "NodeCatalog", + "add_state", "collect_connections", "collect_states", "is_reference", + "literal_inputs", "node_id", "node_type_of", "reference_ports", "type_key", +] diff --git a/frontend/src/easycon_flow/selftest.py b/frontend/src/easycon_flow/selftest.py new file mode 100644 index 00000000..017f7700 --- /dev/null +++ b/frontend/src/easycon_flow/selftest.py @@ -0,0 +1,435 @@ +"""离线自检:不依赖后端,验证画布 ↔ flow.json 的双向映射。 + + QT_QPA_PLATFORM=offscreen python -m easycon_flow.selftest + # 或对着真后端跑(会用 GET /api/nodes 的真实目录做同样的往返检查) + QT_QPA_PLATFORM=offscreen python -m easycon_flow.selftest --backend http://127.0.0.1:19391 + +覆盖:目录 → 动态节点类、参数/字面量/slow 采集、exec 与数据连线序列化、 +flow.json 解析回填、以及「参数名与 NodeGraphQt 预置属性同名」的冲突处理。 +""" + +from __future__ import annotations + +import argparse +import json +import sys + +from PySide6 import QtWidgets + +from . import flowdoc +from .api import ApiError, FlowApi +from .nodes import (NodeCatalog, add_state, collect_connections, collect_states, + input_port, output_port) + +FAKE_CATALOG = { + "version": 1, + "slow": [ + {"name": "everyFrames", "type": "int", "description": "帧号推进不足 N 帧即复用"}, + {"name": "onChange", "type": "bool", "description": "输入未变即复用"}, + {"name": "intervalMs", "type": "int", "description": "间隔未到即复用"}, + ], + "keys": ["A", "B", "TOP"], + "ocrBackends": ["none", "tesseract", "ppocr"], + "nodes": [ + {"type": "start", "layer": "flow", "summary": "起点", + "ports": [{"name": "out", "kind": "exec-out", "type": "exec", "description": ""}], + "params": []}, + {"type": "capture.frame", "layer": "sense", "summary": "抓帧", + "ports": [ + {"name": "in", "kind": "exec-in", "type": "exec", "description": ""}, + {"name": "out", "kind": "exec-out", "type": "exec", "description": ""}, + {"name": "image", "kind": "data-out", "type": "image", "description": "画面"}, + {"name": "x", "kind": "data-in", "type": "number", "description": "ROI 左"}, + ], + "params": [ + {"name": "waitForNew", "type": "bool", "description": "等新帧", "default": False}, + {"name": "timeoutMs", "type": "int", "description": "等待上限", "default": 2000}, + {"name": "x", "type": "int", "description": "ROI 左", "default": 0}, + ]}, + {"type": "ocr.text", "layer": "sense", "summary": "OCR", + "ports": [ + {"name": "in", "kind": "exec-in", "type": "exec", "description": ""}, + {"name": "out", "kind": "exec-out", "type": "exec", "description": ""}, + {"name": "image", "kind": "data-in", "type": "image", "description": "画面"}, + {"name": "text", "kind": "data-out", "type": "text", "description": "文本"}, + {"name": "conf", "kind": "data-out", "type": "number", "description": "置信度"}, + ], + "params": [{"name": "lang", "type": "string", "description": "语言", "default": "eng"}]}, + {"type": "compare", "layer": "decision", "summary": "比较", + "ports": [ + {"name": "in", "kind": "exec-in", "type": "exec", "description": ""}, + {"name": "true", "kind": "exec-out", "type": "exec", "description": "成立"}, + {"name": "false", "kind": "exec-out", "type": "exec", "description": "不成立"}, + {"name": "a", "kind": "data-in", "type": "any", "description": "左"}, + {"name": "b", "kind": "data-in", "type": "any", "description": "右"}, + ], + "params": [{"name": "op", "type": "enum", "description": "运算符", "default": "==", + "options": ["==", "!=", ">", "<", ">=", "<="]}]}, + # 参数名 name 与 NodeGraphQt 预置属性冲突 → 必须走 p_ 前缀 + {"type": "state.step", "layer": "decision", "summary": "计数器", + "ports": [ + {"name": "in", "kind": "exec-in", "type": "exec", "description": ""}, + {"name": "out", "kind": "exec-out", "type": "exec", "description": ""}, + {"name": "done", "kind": "exec-out", "type": "exec", "description": ""}, + {"name": "value", "kind": "data-out", "type": "number", "description": "当前值"}, + ], + "params": [ + {"name": "name", "type": "string", "description": "计数器名", "default": "step"}, + {"name": "op", "type": "enum", "description": "操作", "default": "inc", + "options": ["inc", "set", "reset"]}, + {"name": "value", "type": "int", "description": "增量", "default": 1}, + ]}, + {"type": "pad.key", "layer": "actuation", "summary": "按键", + "ports": [ + {"name": "in", "kind": "exec-in", "type": "exec", "description": ""}, + {"name": "out", "kind": "exec-out", "type": "exec", "description": ""}, + ], + "params": [{"name": "key", "type": "enum", "description": "键名", "default": "A", + "options": ["A", "B", "TOP"]}]}, + {"type": "script.run", "layer": "actuation", "summary": "执行脚本", + "ports": [ + {"name": "in", "kind": "exec-in", "type": "exec", "description": ""}, + {"name": "out", "kind": "exec-out", "type": "exec", "description": ""}, + {"name": "arg1", "kind": "data-in", "type": "text", "description": "ARG(1)"}, + {"name": "ok", "kind": "data-out", "type": "number", "description": "完成"}, + ], + "params": [ + {"name": "script", "type": "text", "description": "内联脚本(多行)"}, + {"name": "file", "type": "path", "description": "脚本路径"}, + ]}, + ], +} + +SAMPLE_DOC = { + "version": 1, + "name": "selftest", + "maxSteps": 500, + "timeoutSec": 60, + "nodes": [ + {"id": "start", "type": "start", "pos": {"x": 0.0, "y": 0.0}}, + {"id": "cap", "type": "capture.frame", + "pos": {"x": 10.0, "y": 40.0}, + "params": {"waitForNew": True, "timeoutMs": 3000, "x": -1}, + "slow": {"everyFrames": 2}}, + {"id": "ocr", "type": "ocr.text", + "pos": {"x": 120.0, "y": 40.0}, + "params": {"lang": "eng"}, + "inputs": {"image": {"node": "cap", "port": "image"}, "x": 12}}, + {"id": "chk", "type": "compare", + "pos": {"x": 240.0, "y": 40.0}, + "params": {"op": ">="}, + "inputs": {"a": {"node": "ocr", "port": "conf"}, "b": 50}}, + {"id": "loop", "type": "state.step", + "pos": {"x": 360.0, "y": 40.0}, + "params": {"name": "retry", "op": "inc", "value": 1}}, + {"id": "press", "type": "pad.key", + "pos": {"x": 480.0, "y": 40.0}, + "params": {"key": "A"}}, + {"id": "run", "type": "script.run", + "pos": {"x": 600.0, "y": 40.0}, + "params": {"script": "$n = ARG(1)\nPRINT \"n=\" & $n", "file": "scripts/hello.ecs"}}, + ], + "exec": [ + ["start", "cap"], + ["cap", "ocr"], + ["ocr", "chk"], + ["chk.true", "loop"], + ["chk.false", "press"], + ["loop.out", "press"], + ["press", "run"], + ["run", "cap"], + ], +} + + +class Checker: + def __init__(self) -> None: + self.passed = 0 + self.failures: list[str] = [] + + def check(self, condition: bool, message: str) -> None: + if condition: + self.passed += 1 + else: + self.failures.append(message) + + def equal(self, actual, expected, message: str) -> None: + self.check(actual == expected, f"{message}\n 期望: {expected!r}\n 实际: {actual!r}") + + +def run_self_test(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="EasyCon Flow 画布离线自检") + parser.add_argument("--backend", default=None, help="用真后端的 /api/nodes 目录跑自检") + args = parser.parse_args(list(argv if argv is not None else sys.argv[1:])) + + payload = FAKE_CATALOG + if args.backend: + try: + payload = FlowApi(args.backend).nodes() + print(f"[自检] 使用真后端目录:{args.backend}") + except ApiError as exc: + print(f"[自检] 后端不可用({exc}),退回内置目录") + + _ = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + checker = Checker() + _check_flowdoc_pure(checker) + _check_canvas_roundtrip(checker, payload) + if args.backend: + _check_backend_roundtrip(checker, payload, args.backend) + + print(f"[自检] 通过 {checker.passed} 项") + if checker.failures: + print(f"[自检] 失败 {len(checker.failures)} 项:") + for failure in checker.failures: + print(f" - {failure}") + return 1 + print("[自检] OK") + return 0 + + +def _check_flowdoc_pure(checker: Checker) -> None: + checker.equal(flowdoc.exec_endpoint("a", "out"), "a", "单出口不写端口后缀") + checker.equal(flowdoc.exec_endpoint("a", None), "a", "无端口不写后缀") + checker.equal(flowdoc.exec_endpoint("chk", "true"), "chk.true", "条件出口带端口") + checker.equal(flowdoc.split_endpoint("chk.true"), ("chk", "true"), "端点拆分") + checker.equal(flowdoc.split_endpoint("cap"), ("cap", None), "无端口拆分") + checker.equal(flowdoc.normalize_exec_edge({"from": "chk.false", "to": "x"}), + ("chk", "false", "x"), "对象形态 exec 边") + + existing = {"a"} + checker.equal(flowdoc.make_node_id("capture.frame", existing), "capture_frame", "点号换成下划线") + checker.equal(flowdoc.make_node_id("a", existing), "a2", "重名递增") + + problems = flowdoc.validate_doc({"nodes": [], "exec": []}) + checker.check(any("没有任何节点" in p for p in problems), "空图应报错") + + dup = {"nodes": [{"id": "a", "type": "wait"}, {"id": "a", "type": "wait"}], "exec": []} + checker.check(any("重复" in p for p in flowdoc.validate_doc(dup)), "重复 id 应报错") + + dangling = {"nodes": [{"id": "a", "type": "start"}], "exec": [["a", "nope"]]} + checker.check(any("不存在" in p for p in flowdoc.validate_doc(dangling)), "悬空边应报错") + + two_starts = {"nodes": [{"id": "s1", "type": "start"}, {"id": "s2", "type": "start"}, + {"id": "e", "type": "end"}], + "exec": [["s1", "e"], ["s2", "e"]]} + problems = flowdoc.validate_doc(two_starts) + checker.check(any("start 节点只能有一个" in p and "s2" in p for p in problems), + "多个 start 应报错且列出节点 id") + + ok = {"nodes": [{"id": "s", "type": "start"}, {"id": "e", "type": "end"}], "exec": [["s", "e"]]} + checker.equal(flowdoc.validate_doc(ok), [], "合法图应无问题") + + +def _check_canvas_roundtrip(checker: Checker, payload: dict) -> None: + from NodeGraphQt import NodeGraph + + graph = NodeGraph() + catalog = NodeCatalog(payload) + catalog.register_into(graph) + checker.equal(catalog.register_errors, [], "所有目录节点都应能注册成画布节点类") + + if catalog.register_errors: + return + + # 1) 载入示例图 → 画布 + name, max_steps, timeout_sec, nodes, connections = flowdoc.parse_doc(SAMPLE_DOC, catalog.specs) + checker.equal(name, "selftest", "图名解析") + checker.equal(max_steps, 500, "看门狗步数解析") + checker.equal(timeout_sec, 60, "看门狗时长解析") + checker.equal(len(nodes), len(SAMPLE_DOC["nodes"]), "节点数解析") + checker.equal(len(connections), 10, "exec 8 条 + 数据 2 条") + + by_id = {} + for state in nodes: + by_id[state.id] = add_state(graph, catalog, state) + + for conn in connections: + src = by_id[conn.src_id] + dst = by_id[conn.dst_id] + if conn.kind == flowdoc.EXEC_KIND: + output_port(src, conn.src_port).connect_to(input_port(dst, "in")) + else: + output_port(src, conn.src_port).connect_to(input_port(dst, conn.dst_port)) + + # 2) 画布 → 采集状态与连线 + states = collect_states(graph) + checker.equal(len(states), len(SAMPLE_DOC["nodes"]), "节点采集数一致") + + by_flow_id = {s.id: s for s in states} + checker.equal(by_flow_id["cap"].params.get("waitForNew"), True, "bool 参数往返") + checker.equal(by_flow_id["cap"].params.get("timeoutMs"), 3000, "int 参数往返") + cap_state = by_flow_id["cap"] + # capture.frame.x 在内置目录里是「参数 + 数据入边」,在真后端目录里只是数据入边; + # 两种形态下值都必须落到画布控件上并往返回来 + checker.equal(cap_state.params.get("x", cap_state.literals.get("x")), -1, + "参数 x 与同名数据入边字面量统一") + checker.equal(by_flow_id["cap"].slow, {"everyFrames": 2}, "slow 往返") + checker.equal(by_flow_id["ocr"].params.get("lang"), "eng", "字符串参数往返") + checker.equal(by_flow_id["chk"].params.get("op"), ">=", "enum 参数往返") + checker.equal(by_flow_id["loop"].params.get("name"), "retry", + "参数名与 NodeGraphQt 预置属性同名时仍能往返") + checker.equal(by_flow_id["loop"].params.get("op"), "inc", "enum 缺省与显式值共存") + + # 内嵌参数控件:节点上直接编辑,值与属性双向一致 + from .nodes import BUTTON_EDIT_PREFIX, BUTTON_PICK_PREFIX + script_text = "$n = ARG(1)\nPRINT \"n=\" & $n" + run_node = by_id["run"] + checker.check("script" in run_node.view.widgets, "text 参数应内嵌预览控件") + checker.check(BUTTON_EDIT_PREFIX + "script" in run_node.view.widgets, + "text 参数应内嵌「编辑…」按钮") + checker.check(BUTTON_PICK_PREFIX + "file" in run_node.view.widgets, + "path 参数应内嵌「…」选文件按钮") + checker.equal(run_node.view.widgets["script"].get_value(), script_text, + "多行预览控件应保留完整文本(不得被行编辑器截断)") + cap_node = by_id["cap"] + checker.check(isinstance(cap_node.view.widgets["waitForNew"].get_value(), bool), + "bool 内嵌控件取值应为 bool") + checker.equal(cap_node.view.widgets["timeoutMs"].get_value(), 3000, "int 内嵌控件与属性同步") + checker.check(isinstance(cap_node.view.widgets["timeoutMs"].get_value(), int), + "int 内嵌控件取值应保持 int(不得退化成字符串)") + cap_node.set_property("timeoutMs", 1500) + checker.equal(cap_node.view.widgets["timeoutMs"].get_value(), 1500, + "set_property 应回填内嵌控件(属性面板编辑路径)") + + canvas_connections = collect_connections(graph) + exec_edges = sorted([c for c in canvas_connections if c.kind == flowdoc.EXEC_KIND], + key=lambda c: (c.src_id, c.src_port, c.dst_id)) + data_edges = sorted([c for c in canvas_connections if c.kind == flowdoc.DATA_KIND], + key=lambda c: (c.src_id, c.src_port, c.dst_id)) + checker.equal(len(exec_edges), 8, "exec 连线数") + checker.equal(len(data_edges), 2, "数据连线数") + checker.check(any(c.src_id == "chk" and c.src_port == "true" and c.dst_id == "loop" for c in exec_edges), + "条件出口 chk.true 连线被识别为 exec") + + # 3) 画布 → 文档 → 与原始文档等价 + document = flowdoc.build_doc(name, states, canvas_connections, max_steps, timeout_sec, catalog.specs) + by_doc_id = {n["id"]: n for n in document["nodes"]} + checker.equal(by_doc_id["ocr"]["inputs"]["image"], {"node": "cap", "port": "image"}, "数据入边写回引用") + checker.equal(by_doc_id["chk"]["inputs"]["b"], 50, "数据入边写回字面量") + checker.equal(_normalize_exec(document["exec"]), _normalize_exec(SAMPLE_DOC["exec"]), + "exec 边集合一致(无条件出口写裸 id 与显式 .out 等价)") + checker.equal(len(document["nodes"]), len(SAMPLE_DOC["nodes"]), "节点数一致") + + # 4) 文档 → 画布 → 文档:二次往返必须稳定 + graph2 = NodeGraph() + catalog.register_into(graph2) + _, _, _, nodes2, conn2 = flowdoc.parse_doc(document, catalog.specs) + for state in nodes2: + add_state(graph2, catalog, state) + for conn in conn2: + src = graph2.get_node_by_name(conn.src_id) + dst = graph2.get_node_by_name(conn.dst_id) + if conn.kind == flowdoc.EXEC_KIND: + output_port(src, conn.src_port).connect_to(input_port(dst, "in")) + else: + output_port(src, conn.src_port).connect_to(input_port(dst, conn.dst_port)) + + document2 = flowdoc.build_doc(name, collect_states(graph2), collect_connections(graph2), + max_steps, timeout_sec, catalog.specs) + checker.equal(_normalized(document2), _normalized(document), "二次往返稳定(文档等价)") + + +SMOKE_DOC = { + "name": "selftest-smoke", + "maxSteps": 50, + "timeoutSec": 30, + "nodes": [ + {"id": "start", "type": "start"}, + {"id": "chk", "type": "text.contains", + "params": {"mode": "contains", "pattern": "TAUROS"}, + "inputs": {"text": "Lv25 TAUROS No.128"}}, + {"id": "step", "type": "state.step", + "params": {"name": "n", "op": "inc", "value": 1, "max": 2}}, + {"id": "wait", "type": "wait", "params": {"ms": 1}}, + {"id": "end", "type": "end"}, + ], + "exec": [["start", "chk"], ["chk.true", "step"], ["chk.false", "end"], + ["step.out", "wait"], ["wait", "step"], ["step.done", "end"]], +} + + +def _check_backend_roundtrip(checker: Checker, payload: dict, backend: str) -> None: + """对着真后端:把前端序列化出来的图提交执行,并试跑一个节点(端到端契约)。""" + import time + + from NodeGraphQt import NodeGraph + + from .api import ApiError, FlowApi + + api = FlowApi(backend) + try: + api.health() + except ApiError as exc: + checker.check(False, f"后端不可用:{exc}") + return + + # 画布 → 文档(与界面「运行图」完全同一条代码路径) + graph = NodeGraph() + catalog = NodeCatalog(payload) + catalog.register_into(graph) + _, max_steps, timeout_sec, nodes, connections = flowdoc.parse_doc(SMOKE_DOC, catalog.specs) + by_id = {state.id: add_state(graph, catalog, state) for state in nodes} + for conn in connections: + src, dst = by_id[conn.src_id], by_id[conn.dst_id] + if conn.kind == flowdoc.EXEC_KIND: + output_port(src, conn.src_port).connect_to(input_port(dst, "in")) + else: + output_port(src, conn.src_port).connect_to(input_port(dst, conn.dst_port)) + + document = flowdoc.build_doc("selftest-smoke", collect_states(graph), collect_connections(graph), + max_steps, timeout_sec, catalog.specs) + checker.equal(flowdoc.validate_doc(document), [], "前端校验应通过") + + try: + run_id = api.run_flow(document) + except ApiError as exc: + checker.check(False, f"提交运行失败:{exc}") + return + checker.check(bool(run_id), "后端返回 runId") + + run = None + for _ in range(60): + time.sleep(0.2) + runs = (api.status(run_id) or {}).get("runs") or [] + if not runs: + break + run = runs[0] + if run.get("status") != "running": + break + + checker.check(run is not None and run.get("status") == "completed", + f"前端提交的图应在后端跑完:{run and run.get('status')} {run and run.get('error')}") + if run: + steps = {r["id"]: r["count"] for r in run.get("records") or []} + checker.equal(steps.get("step"), 2, "state.step 边界(max=2 → 第 2 次走 done)") + + # 单节点试跑:数据入边引用的上游要被一并求值 + try: + trial = api.run_node_in_graph(document, "step") + checker.equal(trial.get("port"), "out", "试跑 state.step 的出口端口") + except ApiError as exc: + checker.check(False, f"单节点试跑失败:{exc}") + + +def _normalize_exec(edges: list) -> list: + """把 ["a.out","b"] 归一成 ["a","b"]:后端对无条件出口只看裸 id(FromPort == null 兜底)。""" + normalized = [] + for edge in edges: + src, dst = (edge[0], edge[1]) if isinstance(edge, (list, tuple)) else (edge["from"], edge["to"]) + if isinstance(src, str) and src.endswith(".out"): + src = src[:-4] + normalized.append([src, dst]) + return sorted(normalized) + + +def _normalized(document: dict) -> dict: + """去掉节点顺序差异后比较(画布遍历顺序不保证稳定)。""" + clone = json.loads(json.dumps(document)) + clone["nodes"] = sorted(clone["nodes"], key=lambda n: n["id"]) + clone["exec"] = sorted(clone["exec"]) + return clone + + +if __name__ == "__main__": + raise SystemExit(run_self_test()) diff --git a/frontend/src/easycon_flow/uitest.py b/frontend/src/easycon_flow/uitest.py new file mode 100644 index 00000000..7a870c62 --- /dev/null +++ b/frontend/src/easycon_flow/uitest.py @@ -0,0 +1,125 @@ +"""无头 UI 冒烟测试:真的把窗口建起来、载图、跑图、试跑节点。 + + QT_QPA_PLATFORM=offscreen python -m easycon_flow.uitest [--backend URL] + +不需要显示器(offscreen 平台插件),但**需要后端在跑**——它走的是与界面完全相同的 +HTTP 路径(/api/nodes、/api/flow/run、/api/flow/status、/api/node/run)。 +适合在改动画布代码后快速确认「窗口能开、图能载、图能跑、节点能试跑」。 +""" + +from __future__ import annotations + +import argparse +import sys +import time + +from PySide6 import QtWidgets + +from .api import ApiError, DEFAULT_BASE_URL, FlowApi +from .nodes import collect_connections +from .window import FlowWindow + +SMOKE_GRAPH = { + "name": "ui-smoke", + "maxSteps": 50, + "timeoutSec": 30, + "nodes": [ + {"id": "start", "type": "start", "pos": {"x": 0, "y": 0}}, + {"id": "chk", "type": "text.contains", "pos": {"x": 120, "y": 0}, + "params": {"mode": "contains", "pattern": "TAUROS"}, + "inputs": {"text": "Lv25 TAUROS No.128"}}, + {"id": "step", "type": "state.step", "pos": {"x": 260, "y": 0}, + "params": {"name": "n", "op": "inc", "value": 1, "max": 2}}, + {"id": "wait", "type": "wait", "pos": {"x": 400, "y": 0}, "params": {"ms": 1}}, + {"id": "end", "type": "end", "pos": {"x": 540, "y": 0}}, + ], + "exec": [["start", "chk"], ["chk.true", "step"], ["chk.false", "end"], + ["step.out", "wait"], ["wait", "step"], ["step.done", "end"]], +} + + +def run_ui_test(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="EasyCon Flow 画布无头 UI 冒烟测试") + parser.add_argument("--backend", default=DEFAULT_BASE_URL) + args = parser.parse_args(list(argv if argv is not None else sys.argv[1:])) + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + api = FlowApi(args.backend) + + try: + api.health() + except ApiError as exc: + print(f"[uitest] 后端不可用:{exc}") + return 2 + + failures: list[str] = [] + passed = 0 + + def check(condition: bool, message: str) -> None: + nonlocal passed + if condition: + passed += 1 + else: + failures.append(message) + + window = FlowWindow(api) + window.show() + app.processEvents() + + check(window.catalog is not None and len(window.catalog.specs) > 5, "节点目录应加载") + check(window.palette_tree.topLevelItemCount() >= 3, "节点库应按层分组") + + for node_type in ("start", "text.contains", "state.step", "wait", "end"): + window.add_node(node_type) + app.processEvents() + check(len(window.graph.all_nodes()) == 5, "添加节点应生效") + check(len(window.canvas_to_doc()["nodes"]) == 5, "画布应能序列化为 flow.json") + + window.doc_to_canvas(SMOKE_GRAPH) + app.processEvents() + check(len(window.graph.all_nodes()) == len(SMOKE_GRAPH["nodes"]), "载图应还原节点") + exec_edges = [c for c in collect_connections(window.graph) if c.kind == "exec"] + check(len(exec_edges) == len(SMOKE_GRAPH["exec"]), f"载图应还原 exec 边({len(exec_edges)})") + + # 内嵌参数控件(真后端目录):控件存在、int 保持 int、属性 → 控件同步 + wait_node = window.graph.get_node_by_name("wait") + check("ms" in wait_node.view.widgets, "wait 节点应内嵌 ms 参数控件") + check(isinstance(wait_node.view.widgets["ms"].get_value(), int), + "ms 内嵌控件取值应保持 int") + wait_node.set_property("ms", 5) + check(wait_node.view.widgets["ms"].get_value() == 5, "set_property 应回填内嵌控件") + + window.refresh_devices() + app.processEvents() + check(window.video_status.text() != "未知", "设备面板应刷新出状态") + + # 运行(真实后端)+ 轮询高亮 + window.run_flow() + deadline = time.time() + 20 + while window._run_id and window._poll.isActive() and time.time() < deadline: + app.processEvents() + time.sleep(0.1) + app.processEvents() + check("completed" in window.run_status.text(), f"图应跑完:{window.run_status.text()}") + check(window.records.rowCount() >= len(SMOKE_GRAPH["nodes"]), "运行状态表应填满节点记录") + + # 单节点试跑(拦掉模态弹窗,避免阻塞无头环境) + trialed: dict = {} + window._show_trial_result = lambda node_id, spec, result: trialed.update(node_id=node_id, **result) + window.trial_run_selected(window.graph.get_node_by_name("step")) + app.processEvents() + check(trialed.get("ok") is True, f"单节点试跑应成功:{trialed.get('error')}") + check(trialed.get("port") == "out", f"state.step 首次试跑应走 out:{trialed.get('port')}") + + print(f"[uitest] 通过 {passed} 项(后端 {args.backend})") + if failures: + print(f"[uitest] 失败 {len(failures)} 项:") + for failure in failures: + print(f" - {failure}") + return 1 + print("[uitest] OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run_ui_test()) diff --git a/frontend/src/easycon_flow/window.py b/frontend/src/easycon_flow/window.py new file mode 100644 index 00000000..6bdaa8ea --- /dev/null +++ b/frontend/src/easycon_flow/window.py @@ -0,0 +1,804 @@ +"""EasyCon Flow 画布主窗口(PySide6 + NodeGraphQt)。 + +职责边界:画布只做「布局 + 参数编辑 + 触发」,节点语义、执行、设备状态全在后端 +(HTTP Flow 服务)。前端不含任何节点实现,节点类型与参数由 /api/nodes 决定。 +""" + +from __future__ import annotations + +import base64 +import json +import os + +from NodeGraphQt import NodeGraph, PropertiesBinWidget +from PySide6 import QtCore, QtGui, QtWidgets + +from . import flowdoc +from .api import ApiError, FlowApi +from .nodes import (LAYER_COLORS, NodeCatalog, add_state, collect_connections, + collect_states, input_port, node_id, node_type_of, output_port) + +LAYER_LABELS = { + "sense": "感知", + "decision": "决策", + "actuation": "动作(驱动设备)", + "flow": "控制流", +} +LAYER_ORDER = ["flow", "sense", "decision", "actuation"] + +COLOR_EXECUTED = (46, 122, 74) +COLOR_ERROR = (170, 52, 52) +COLOR_RUNNING = (168, 140, 40) + +POLL_INTERVAL_MS = 300 + + +class FlowWindow(QtWidgets.QMainWindow): + """编排画布 + 设备面板 + 运行面板。""" + + def __init__(self, api: FlowApi): + super().__init__() + self.api = api + self.catalog: NodeCatalog | None = None + self._path: str | None = None + self._run_id: str | None = None + self._dirty = False + + self.setWindowTitle("EasyCon Flow 画布") + self.resize(1500, 950) + + self._build_graph_host() + self._build_toolbar() + self._build_palette() + self._build_inspector() + self._build_run_panel() + + self._poll = QtCore.QTimer(self) + self._poll.setInterval(POLL_INTERVAL_MS) + self._poll.timeout.connect(self._poll_status) + + self.reload_catalog() + self.refresh_devices() + + # ------------------------------------------------------------ 画布宿主 + + def _build_graph_host(self) -> None: + self._graph_host = QtWidgets.QWidget() + self._graph_layout = QtWidgets.QVBoxLayout(self._graph_host) + self._graph_layout.setContentsMargins(0, 0, 0, 0) + self.setCentralWidget(self._graph_host) + self.graph = self._new_graph() + + def _new_graph(self) -> NodeGraph: + graph = NodeGraph() + graph.set_background_color(38, 41, 48) + graph.set_grid_color(52, 56, 64) + graph.node_double_clicked.connect(self._on_node_double_clicked) + return graph + + def _install_graph(self, graph: NodeGraph) -> None: + # NodeGraphQt 的 graph.widget 是属性(NodeGraphWidget 实例),不是方法 + old = self.graph + self._graph_layout.removeWidget(old.widget) + old.widget.setParent(None) + old.widget.deleteLater() + self.graph = graph + self._graph_layout.addWidget(graph.widget) + self._attach_properties_bin() + self._wire_canvas(graph) + + def _wire_canvas(self, graph: NodeGraph) -> None: + """画布实例级的删除入口(菜单/快捷键都挂在 graph 实例上,每次换画布必须重接)。""" + for key in (QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace): + shortcut = QtGui.QShortcut(QtGui.QKeySequence(key), graph.widget) + shortcut.setContext(QtCore.Qt.WidgetWithChildrenShortcut) + shortcut.activated.connect(self._delete_selected_nodes) + + graph.get_context_menu("graph").add_command( + "删除选中节点", lambda _graph: self._delete_selected_nodes()) + if self.catalog is not None: + nodes_menu = graph.get_context_menu("nodes") + for type_key in self.catalog.types.values(): + nodes_menu.add_command( + "删除此节点", lambda _graph, node: self._delete_nodes([node]), + node_type=type_key) + + # ------------------------------------------------------------ 画布删除 + + def _delete_nodes(self, nodes: list) -> None: + if not nodes: + return + names = ", ".join(node_id(n) for n in nodes) + self.graph.delete_nodes(nodes) + self._mark_dirty() + self.log(f"[画布] 已删除节点: {names}") + + def _delete_selected_nodes(self) -> None: + self._delete_nodes(self.graph.selected_nodes()) + + # ------------------------------------------------------------ 工具栏 + + def _build_toolbar(self) -> None: + bar = self.addToolBar("主") + bar.setMovable(False) + + def action(text: str, slot, shortcut: str | None = None, tip: str = "") -> QtGui.QAction: + act = QtGui.QAction(text, self) + act.triggered.connect(slot) + if shortcut: + act.setShortcut(QtGui.QKeySequence(shortcut)) + if tip: + act.setToolTip(tip) + bar.addAction(act) + return act + + action("新建", self.new_doc, "Ctrl+N") + action("打开…", self.open_doc, "Ctrl+O") + action("保存", self.save_doc, "Ctrl+S") + action("另存为…", lambda: self.save_doc(force_dialog=True)) + bar.addSeparator() + + self.act_run = action("▶ 运行图", self.run_flow, "F5", "把当前画布提交给后端执行") + self.act_stop = action("■ 停止", self.stop_flow, "Shift+F5") + self.act_stop.setEnabled(False) + action("执行选中节点", self.trial_run_selected, "F6", + "只跑这一个节点(含其数据入边引用的上游),actuation 层会被后端拒绝") + bar.addSeparator() + action("适配视图", lambda: self.graph.fit_to_selection() if self.graph.selected_nodes() + else self.graph.auto_layout_nodes(), "Ctrl+F") + action("重载节点目录", self.reload_catalog, "Ctrl+R", "重新拉取 /api/nodes") + + bar.addSeparator() + bar.addWidget(QtWidgets.QLabel(" 图名 ")) + self.name_edit = QtWidgets.QLineEdit("untitled") + self.name_edit.setMaximumWidth(180) + self.name_edit.textChanged.connect(lambda *_: self._mark_dirty()) + bar.addWidget(self.name_edit) + + bar.addWidget(QtWidgets.QLabel(" 步数上限 ")) + self.max_steps = QtWidgets.QSpinBox() + self.max_steps.setRange(1, 1_000_000) + self.max_steps.setValue(flowdoc.DEFAULT_MAX_STEPS) + bar.addWidget(self.max_steps) + + bar.addWidget(QtWidgets.QLabel(" 时长上限(s) ")) + self.timeout_sec = QtWidgets.QSpinBox() + self.timeout_sec.setRange(1, 86400) + self.timeout_sec.setValue(flowdoc.DEFAULT_TIMEOUT_SEC) + bar.addWidget(self.timeout_sec) + + # ------------------------------------------------------------ 节点库 + + def _build_palette(self) -> None: + dock = QtWidgets.QDockWidget("节点库", self) + dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea) + + self.palette_tree = QtWidgets.QTreeWidget() + self.palette_tree.setHeaderLabels(["节点", "说明"]) + self.palette_tree.setColumnWidth(0, 150) + self.palette_tree.itemDoubleClicked.connect(self._on_palette_activated) + + hint = QtWidgets.QLabel( + "双击添加节点(start 最多一个);Del/Backspace 删除选中节点," + "右键节点或画布空白处也可删除;拖动画布空白处平移") + hint.setWordWrap(True) + hint.setStyleSheet("color:#9aa0aa; padding:4px;") + + container = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(container) + layout.setContentsMargins(4, 4, 4, 4) + layout.addWidget(hint) + layout.addWidget(self.palette_tree) + dock.setWidget(container) + self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock) + + def _rebuild_palette(self) -> None: + self.palette_tree.clear() + if self.catalog is None: + return + grouped = self.catalog.layers() + for layer in LAYER_ORDER: + specs = grouped.get(layer) + if not specs: + continue + root = QtWidgets.QTreeWidgetItem([LAYER_LABELS.get(layer, layer), f"{len(specs)} 个"]) + root.setFlags(QtCore.Qt.ItemIsEnabled) + for spec in sorted(specs, key=lambda s: s["type"]): + child = QtWidgets.QTreeWidgetItem([spec["type"], spec.get("summary", "")]) + child.setData(0, QtCore.Qt.UserRole, spec["type"]) + child.setToolTip(0, self._spec_tooltip(spec)) + root.addChild(child) + self.palette_tree.addTopLevelItem(root) + self.palette_tree.expandAll() + + @staticmethod + def _spec_tooltip(spec: dict) -> str: + lines = [spec.get("summary", ""), ""] + for port in spec.get("ports") or []: + lines.append(f" [{port['kind']}] {port['name']} — {port.get('description', '')}") + for param in spec.get("params") or []: + default = f"(默认 {param['default']!r})" if param.get("default") is not None else "" + lines.append(f" 参数 {param['name']}: {param.get('description', '')}{default}") + if spec.get("layer") == "actuation": + lines.append("") + lines.append("⚠ 该层会驱动设备,不支持单节点试跑") + return "\n".join(lines) + + def _on_palette_activated(self, item: QtWidgets.QTreeWidgetItem, _column: int) -> None: + node_type = item.data(0, QtCore.Qt.UserRole) + if node_type: + self.add_node(node_type) + + # ------------------------------------------------------------ 属性面板 + + def _build_inspector(self) -> None: + self.inspector_dock = QtWidgets.QDockWidget("属性(含 slow 慢感知)", self) + self.inspector_dock.setAllowedAreas(QtCore.Qt.RightDockWidgetArea) + self._inspector_holder = QtWidgets.QWidget() + self._inspector_layout = QtWidgets.QVBoxLayout(self._inspector_holder) + self._inspector_layout.setContentsMargins(0, 0, 0, 0) + self.inspector_dock.setWidget(self._inspector_holder) + self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.inspector_dock) + + def _attach_properties_bin(self) -> None: + while self._inspector_layout.count(): + item = self._inspector_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self.prop_bin = PropertiesBinWidget(node_graph=self.graph) + self._inspector_layout.addWidget(self.prop_bin) + self.graph.add_properties_bin(self.prop_bin) + + # ------------------------------------------------------------ 设备面板 + + def _build_run_panel(self) -> None: + dock = QtWidgets.QDockWidget("运行 / 设备", self) + dock.setAllowedAreas(QtCore.Qt.BottomDockWidgetArea | QtCore.Qt.RightDockWidgetArea) + + tabs = QtWidgets.QTabWidget() + tabs.addTab(self._build_device_tab(), "设备") + tabs.addTab(self._build_status_tab(), "运行状态") + tabs.addTab(self._build_log_tab(), "日志") + dock.setWidget(tabs) + self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, dock) + dock.setMinimumHeight(260) + + def _build_device_tab(self) -> QtWidgets.QWidget: + page = QtWidgets.QWidget() + layout = QtWidgets.QGridLayout(page) + + self.video_status = QtWidgets.QLabel("未知") + self.video_combo = QtWidgets.QComboBox() + self.video_combo.setMinimumWidth(260) + btn_refresh = QtWidgets.QPushButton("刷新") + btn_refresh.clicked.connect(self.refresh_devices) + btn_video_on = QtWidgets.QPushButton("连接") + btn_video_on.clicked.connect(self.connect_video) + btn_video_off = QtWidgets.QPushButton("断开") + btn_video_off.clicked.connect(self.disconnect_video) + + layout.addWidget(QtWidgets.QLabel("视频源"), 0, 0) + layout.addWidget(self.video_status, 0, 1) + layout.addWidget(self.video_combo, 0, 2) + layout.addWidget(btn_refresh, 0, 3) + layout.addWidget(btn_video_on, 0, 4) + layout.addWidget(btn_video_off, 0, 5) + + self.mcu_status = QtWidgets.QLabel("未知") + self.mcu_combo = QtWidgets.QComboBox() + self.mcu_combo.setEditable(True) + self.mcu_combo.setMinimumWidth(260) + btn_mcu_refresh = QtWidgets.QPushButton("刷新") + btn_mcu_refresh.setToolTip("重新扫描可用串口") + btn_mcu_refresh.clicked.connect(self.refresh_devices) + btn_mcu_on = QtWidgets.QPushButton("连接") + btn_mcu_on.clicked.connect(lambda: self.connect_mcu(self.mcu_combo.currentText().strip())) + btn_mcu_off = QtWidgets.QPushButton("断开") + btn_mcu_off.clicked.connect(self.disconnect_mcu) + + layout.addWidget(QtWidgets.QLabel("单片机"), 1, 0) + layout.addWidget(self.mcu_status, 1, 1) + layout.addWidget(self.mcu_combo, 1, 2) + layout.addWidget(btn_mcu_refresh, 1, 3) + layout.addWidget(btn_mcu_on, 1, 4) + layout.addWidget(btn_mcu_off, 1, 5) + + self.ocr_status = QtWidgets.QLabel("未知") + self.ocr_backend = QtWidgets.QComboBox() + self.ocr_model_dir = QtWidgets.QLineEdit() + self.ocr_model_dir.setPlaceholderText("PP-OCR 模型目录(ppocr 后端必填)") + btn_ocr_dir = QtWidgets.QPushButton("打开目录…") + btn_ocr_dir.setToolTip("选择 PP-OCR 模型目录") + btn_ocr_dir.clicked.connect(self.pick_ocr_model_dir) + btn_ocr_apply = QtWidgets.QPushButton("应用 OCR 设置") + btn_ocr_apply.clicked.connect(self.apply_ocr) + + layout.addWidget(QtWidgets.QLabel("OCR 后端"), 2, 0) + layout.addWidget(self.ocr_status, 2, 1) + layout.addWidget(self.ocr_backend, 2, 2) + layout.addWidget(self.ocr_model_dir, 2, 3) + layout.addWidget(btn_ocr_dir, 2, 4) + layout.addWidget(btn_ocr_apply, 2, 5) + + note = QtWidgets.QLabel( + "设备状态由 EasyCon(后端)单一持有:画布上的连接/断开直接作用于后端服务," + "与 GUI 监控页各持一份,同一块采集卡不要在两处同时打开。") + note.setWordWrap(True) + note.setStyleSheet("color:#9aa0aa;") + layout.addWidget(note, 3, 0, 1, 6) + return page + + def _build_status_tab(self) -> QtWidgets.QWidget: + page = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(page) + self.run_status = QtWidgets.QLabel("未运行") + self.run_status.setStyleSheet("font-weight:bold;") + layout.addWidget(self.run_status) + + self.records = QtWidgets.QTableWidget(0, 6) + self.records.setHorizontalHeaderLabels(["节点", "类型", "执行", "复用", "最近(ms)", "累计(ms)"]) + self.records.horizontalHeader().setStretchLastSection(True) + self.records.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + layout.addWidget(self.records) + return page + + def _build_log_tab(self) -> QtWidgets.QWidget: + page = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(page) + self.log_view = QtWidgets.QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setMaximumBlockCount(2000) + layout.addWidget(self.log_view) + return page + + # ------------------------------------------------------------ 后端交互 + + def log(self, message: str) -> None: + self.log_view.appendPlainText(message) + + def reload_catalog(self) -> None: + document = self.canvas_to_doc() if self.catalog is not None else None + try: + payload = self.api.nodes() + except ApiError as exc: + self.log(f"[目录] 拉取失败:{exc}") + QtWidgets.QMessageBox.warning( + self, "无法连接后端", + f"{exc}\n\n请先启动后端:\n" + " dotnet run --project src/EasyCon2.CLI -- serve --port 19391\n" + "或启动 GUI(内嵌服务,默认 19391)。") + return + + graph = self._new_graph() + self.catalog = NodeCatalog(payload) + self.catalog.register_into(graph) + self._install_graph(graph) + self._rebuild_palette() + for problem in self.catalog.register_errors: + self.log(f"[目录] 注册失败 {problem}") + self.log(f"[目录] 已加载 {len(self.catalog.specs)} 个节点类型(v{self.catalog.version})") + + if self.catalog.ocr_backends: + self.ocr_backend.clear() + self.ocr_backend.addItems(self.catalog.ocr_backends) + + if document and document.get("nodes"): + self.doc_to_canvas(document) + self.log("[目录] 已按新目录重建画布内容") + + def refresh_devices(self) -> None: + try: + video = self.api.video_info() + mcu = self.api.mcu_info() + options = self.api.options() + except ApiError as exc: + self.log(f"[设备] 查询失败:{exc}") + return + + self.video_combo.clear() + for source in video.get("sources") or []: + self.video_combo.addItem(f"[{source.get('index')}] {source.get('name')}", source.get("index")) + self._set_status(self.video_status, bool(video.get("connected"))) + + current = self.mcu_combo.currentText() + self.mcu_combo.clear() + self.mcu_combo.addItems([str(p) for p in (mcu.get("ports") or [])]) + if current: + self.mcu_combo.setEditText(current) + self._set_status(self.mcu_status, bool(mcu.get("connected"))) + + ocr = options.get("ocr") or {} + backend = str(ocr.get("backend") or "none") + index = self.ocr_backend.findText(backend) + if index >= 0: + self.ocr_backend.setCurrentIndex(index) + if ocr.get("modelDir") and not self.ocr_model_dir.text(): + self.ocr_model_dir.setText(str(ocr["modelDir"])) + detail = f"{backend}" + if ocr.get("error"): + detail += f"(装配失败:{ocr['error']})" + self.ocr_status.setText(detail) + self.ocr_status.setStyleSheet("color:#e0b050;" if ocr.get("error") else "color:#7fc97f;") + + @staticmethod + def _set_status(label: QtWidgets.QLabel, connected: bool) -> None: + label.setText("已连接" if connected else "未连接") + label.setStyleSheet("color:#7fc97f; font-weight:bold;" if connected else "color:#9aa0aa;") + + def connect_video(self) -> None: + index = self.video_combo.currentData() + if index is None: + QtWidgets.QMessageBox.information(self, "提示", "没有可用的视频源,先点“刷新”。") + return + self._guard(lambda: self.api.connect_video(int(index)), "视频源已连接") + + def disconnect_video(self) -> None: + self._guard(self.api.disconnect_video, "视频源已断开") + + def connect_mcu(self, port: str) -> None: + if not port: + QtWidgets.QMessageBox.information( + self, "提示", "请选择或输入串口名(没有列表时可先点「刷新」扫描)。") + return + self._guard(lambda: self.api.connect_mcu(port), f"单片机已连接: {port}") + + def disconnect_mcu(self) -> None: + self._guard(self.api.disconnect_mcu, "单片机已断开") + + def pick_ocr_model_dir(self) -> None: + current = self.ocr_model_dir.text().strip() + path = QtWidgets.QFileDialog.getExistingDirectory( + self, "选择 PP-OCR 模型目录", current or "") + if path: + self.ocr_model_dir.setText(path) + + def apply_ocr(self) -> None: + backend = self.ocr_backend.currentText().strip() or "none" + model_dir = self.ocr_model_dir.text().strip() or None + self._guard(lambda: self.api.set_ocr_backend(backend, model_dir), f"OCR 后端已设为 {backend}") + + def _guard(self, call, success_message: str) -> None: + try: + call() + except ApiError as exc: + QtWidgets.QMessageBox.warning(self, "操作失败", str(exc)) + self.log(f"[设备] {exc}") + else: + self.log(f"[设备] {success_message}") + self.refresh_devices() + + # ------------------------------------------------------------ 文档 + + def _mark_dirty(self, dirty: bool = True) -> None: + self._dirty = dirty + title = "EasyCon Flow 画布" + if self._path: + title += f" — {os.path.basename(self._path)}" + self.setWindowTitle(("*" if dirty else "") + title) + + def canvas_to_doc(self) -> dict: + if self.catalog is None: + return flowdoc.build_doc("untitled", [], []) + return flowdoc.build_doc( + name=self.name_edit.text().strip() or "untitled", + nodes=self._collect_states_fixed_ids(), + connections=collect_connections(self.graph), + max_steps=self.max_steps.value(), + timeout_sec=self.timeout_sec.value(), + catalog=self.catalog.specs, + ) + + def _collect_states_fixed_ids(self): + """采集节点状态,顺手修复重复/缺失 id(画布复制节点会产生重复 id)。""" + states = collect_states(self.graph) + seen: set[str] = set() + for state in states: + if not state.id or state.id in seen: + state.id = flowdoc.make_node_id(state.type, seen) + node = self._node_by_old_id(state.id) + if node is not None: + node.set_property("node_id", state.id) + node.set_name(state.id) + seen.add(state.id) + return states + + def _node_by_old_id(self, node_id: str): + for node in self.graph.all_nodes(): + if str(node.name()) == node_id: + return node + return None + + def doc_to_canvas(self, document: dict) -> None: + name, max_steps, timeout_sec, nodes, connections = flowdoc.parse_doc( + document, self.catalog.specs if self.catalog else None) + + graph = self._new_graph() + if self.catalog is not None: + self.catalog.register_into(graph) + self._install_graph(graph) + + self.name_edit.setText(name) + self.max_steps.setValue(max_steps) + self.timeout_sec.setValue(timeout_sec) + + by_id = {} + for state in nodes: + try: + by_id[state.id] = add_state(graph, self.catalog, state) + except Exception as exc: # noqa: BLE001 - 未知类型等不该中断整图加载 + self.log(f"[加载] 跳过节点 {state.id}({state.type}): {exc}") + + for conn in connections: + src = by_id.get(conn.src_id) + dst = by_id.get(conn.dst_id) + if src is None or dst is None: + self.log(f"[加载] 跳过悬空连线 {conn.src_id}.{conn.src_port} → {conn.dst_id}") + continue + try: + if conn.kind == flowdoc.EXEC_KIND: + if output_port(src, conn.src_port) is None: + src.add_output(conn.src_port) + output_port(src, conn.src_port).connect_to(input_port(dst, "in")) + else: + output_port(src, conn.src_port).connect_to(input_port(dst, conn.dst_port)) + except Exception as exc: # noqa: BLE001 - 端口缺失等不该中断加载 + self.log(f"[加载] 连线失败 {conn.src_id}.{conn.src_port} → {conn.dst_id}.{conn.dst_port}: {exc}") + + self._mark_dirty(False) + if nodes and not any(state.pos for state in nodes): + graph.auto_layout_nodes() + self.log(f"[加载] {len(nodes)} 个节点 / {len(connections)} 条 exec+数据连线") + + def add_node(self, node_type: str, pos=None) -> None: + if self.catalog is None or node_type not in self.catalog.types: + self.log(f"[画布] 未知节点类型: {node_type}") + return + if node_type == "start" and any(node_type_of(n) == "start" for n in self.graph.all_nodes()): + self.statusBar().showMessage("start 节点只能有一个(图中已有 start,请先删除再添加)。", 5000) + self.log("[画布] 拒绝添加第二个 start 节点") + return + existing = {n.name() for n in self.graph.all_nodes()} + new_id = flowdoc.make_node_id(node_type, existing) + node = self.graph.create_node(self.catalog.types[node_type], name=new_id, pos=pos) + node.set_property("node_id", new_id) + self._mark_dirty() + self.log(f"[画布] 已添加 {node_type} → {new_id}") + + def new_doc(self) -> None: + self.doc_to_canvas(flowdoc.build_doc("untitled", [], [])) + self._path = None + self._mark_dirty(False) + + def open_doc(self) -> None: + path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "打开编排图", "", "Flow 图 (*.flow.json *.json)") + if not path: + return + try: + with open(path, encoding="utf-8") as handle: + document = json.load(handle) + except (OSError, ValueError) as exc: + QtWidgets.QMessageBox.critical(self, "打开失败", str(exc)) + return + + problems = flowdoc.validate_doc(document) + if problems: + QtWidgets.QMessageBox.warning(self, "图有问题(仍会载入)", "\n".join(problems)) + self.doc_to_canvas(document) + self._path = path + self._mark_dirty(False) + self.log(f"[打开] {path}") + + def save_doc(self, force_dialog: bool = False) -> None: + path = self._path + if force_dialog or not path: + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "保存编排图", path or "untitled.flow.json", "Flow 图 (*.flow.json)") + if not path: + return + if not path.endswith(".json"): + path += ".flow.json" + + document = self.canvas_to_doc() + problems = flowdoc.validate_doc(document) + if problems: + answer = QtWidgets.QMessageBox.question( + self, "图有问题", "\n".join(problems) + "\n\n仍要保存吗?") + if answer != QtWidgets.QMessageBox.Yes: + return + + try: + with open(path, "w", encoding="utf-8") as handle: + json.dump(document, handle, ensure_ascii=False, indent=2) + except OSError as exc: + QtWidgets.QMessageBox.critical(self, "保存失败", str(exc)) + return + + self._path = path + self._mark_dirty(False) + self.log(f"[保存] {path}") + + # ------------------------------------------------------------ 运行 + + def run_flow(self) -> None: + document = self.canvas_to_doc() + problems = flowdoc.validate_doc(document) + if problems: + QtWidgets.QMessageBox.warning(self, "无法运行", "\n".join(problems)) + return + try: + self._run_id = self.api.run_flow(document) + except ApiError as exc: + QtWidgets.QMessageBox.critical(self, "运行失败", str(exc)) + return + + self._reset_highlight() + self.run_status.setText(f"运行中… runId={self._run_id}") + self.act_run.setEnabled(False) + self.act_stop.setEnabled(True) + self._poll.start() + self.log(f"[运行] runId={self._run_id}({len(document['nodes'])} 节点)") + + def stop_flow(self) -> None: + if not self._run_id: + return + try: + self.api.stop_flow(self._run_id) + self.log(f"[运行] 已请求停止 {self._run_id}") + except ApiError as exc: + self.log(f"[运行] 停止失败:{exc}") + + def _poll_status(self) -> None: + if not self._run_id: + self._poll.stop() + return + try: + payload = self.api.status(self._run_id) + except ApiError as exc: + self._poll.stop() + self.run_status.setText(f"状态查询失败:{exc}") + self._finish_run() + return + + runs = payload.get("runs") or [] + if not runs: + self._poll.stop() + self.run_status.setText("运行记录已丢失") + self._finish_run() + return + + run = runs[0] + status = run.get("status") + self.run_status.setText( + f"{status} 步数 {run.get('steps', 0)} 用时 {run.get('totalMs', 0):.0f}ms" + + (f" 错误节点 {run.get('errorNode')}: {run.get('error')}" if run.get("error") else "")) + + records = run.get("records") or [] + self._fill_records(records) + self._highlight(records, run.get("errorNode")) + + if status not in ("running", None): + self._poll.stop() + self._finish_run() + events = run.get("events") or [] + for event in events[-12:]: + self.log(f"[事件] {event}") + + def _finish_run(self) -> None: + self.act_run.setEnabled(True) + self.act_stop.setEnabled(False) + + def _fill_records(self, records: list[dict]) -> None: + self.records.setRowCount(len(records)) + for row, record in enumerate(sorted(records, key=lambda r: -float(r.get("totalMs") or 0))): + values = [ + str(record.get("id", "")), + str(record.get("type", "")), + str(record.get("count", 0)), + str(record.get("reused", 0)), + f"{float(record.get('lastMs') or 0):.0f}", + f"{float(record.get('totalMs') or 0):.0f}", + ] + for column, value in enumerate(values): + self.records.setItem(row, column, QtWidgets.QTableWidgetItem(value)) + + def _reset_highlight(self) -> None: + for node in self.graph.all_nodes(): + spec = getattr(node, "_spec", None) + if spec is None: + continue + node.set_color(*LAYER_COLORS.get(spec.get("layer", "flow"), LAYER_COLORS["flow"])) + + def _highlight(self, records: list[dict], error_node: str | None) -> None: + by_id = {n.get_property("node_id") or n.name(): n for n in self.graph.all_nodes()} + self._reset_highlight() + for record in records: + node = by_id.get(str(record.get("id"))) + if node is None: + continue + node.set_color(*(COLOR_ERROR if str(record.get("id")) == error_node else COLOR_EXECUTED)) + if error_node and error_node in by_id: + by_id[error_node].set_color(*COLOR_ERROR) + + # ------------------------------------------------------------ 单节点试跑 + + def _on_node_double_clicked(self, node) -> None: + self.trial_run_selected(node) + + def trial_run_selected(self, node=None) -> None: + if node is None or not hasattr(node, "_spec"): + selected = self.graph.selected_nodes() + if not selected: + QtWidgets.QMessageBox.information(self, "提示", "先在画布上选中一个节点。") + return + node = selected[0] + + spec = getattr(node, "_spec", None) + if spec is None: + return + + node_identifier = str(node.get_property("node_id") or node.name()) + document = self.canvas_to_doc() + try: + result = self.api.run_node_in_graph(document, node_identifier) + except ApiError as exc: + QtWidgets.QMessageBox.warning(self, "试跑被拒绝", str(exc)) + self.log(f"[试跑] {node_identifier} 被拒绝:{exc}") + return + + self._show_trial_result(node_identifier, spec, result) + + def _show_trial_result(self, node_identifier: str, spec: dict, result: dict) -> None: + outputs = result.get("outputs") or {} + port = result.get("port") + summary = [ + f"节点: {node_identifier}({spec['type']})", + f"耗时: {float(result.get('ms') or 0):.1f} ms" + + ("(slow 复用,未真正执行)" if result.get("reused") else ""), + ] + if port: + summary.append(f"exec 出口: {port}") + if result.get("error"): + summary.append(f"错误: {result['error']}") + if outputs: + summary.append("") + summary.append("数据输出:") + for key, value in outputs.items(): + text = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value + if key == "image": + text = f"<图像 {max(len(value) // 1024, 1)} KB>" + summary.append(f" {key} = {text[:400]}") + + box = QtWidgets.QMessageBox(self) + box.setWindowTitle("节点试跑结果") + box.setText("\n".join(summary)) + image = outputs.get("image") + if isinstance(image, str) and image: + pixmap = self._decode_pixmap(image) + if pixmap is not None: + box.setIconPixmap(pixmap.scaledToWidth(480, QtCore.Qt.SmoothTransformation)) + box.exec() + self.log(f"[试跑] {node_identifier} ok={result.get('ok')} port={port} ms={result.get('ms')}") + + @staticmethod + def _decode_pixmap(base64_png: str): + try: + raw = base64.b64decode(base64_png, validate=False) + except (ValueError, TypeError): + return None + pixmap = QtGui.QPixmap() + if not pixmap.loadFromData(raw, "PNG"): + return None + return pixmap + + # ------------------------------------------------------------ 生命周期 + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: # noqa: N802 - Qt 命名 + if self._dirty: + answer = QtWidgets.QMessageBox.question( + self, "未保存", "画布有未保存的修改,仍要退出吗?") + if answer != QtWidgets.QMessageBox.Yes: + event.ignore() + return + self._poll.stop() + super().closeEvent(event) diff --git a/frontend/uv.lock b/frontend/uv.lock new file mode 100644 index 00000000..a0824dc0 --- /dev/null +++ b/frontend/uv.lock @@ -0,0 +1,383 @@ +version = 1 +revision = 5 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" }, +] + +[[package]] +name = "easycon-flow" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "nodegraphqt" }, + { name = "pyside6" }, + { name = "requests" }, + { name = "setuptools" }, +] + +[package.metadata] +requires-dist = [ + { name = "nodegraphqt", specifier = ">=0.6.44" }, + { name = "pyside6", specifier = ">=6.6" }, + { name = "requests", specifier = ">=2.31" }, + { name = "setuptools", specifier = ">=68" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" }, +] + +[[package]] +name = "nodegraphqt" +version = "0.6.44" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +dependencies = [ + { name = "qt-py" }, +] +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/57/23/4966003f70f6bc18cba62ae7b484d84cb0cb669c6e5992778671c1ea758a/nodegraphqt-0.6.44.tar.gz", hash = "sha256:d905a8c100da5f45dc403d2701c09ed2e4c13bcdc3a21b0da5e8f0f5ceedaeaa" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c5/d7/deb8ddfbe1df5710407acb4f4d4e679845841c78924cd67ee3a4e428b418/nodegraphqt-0.6.44-py3-none-any.whl", hash = "sha256:2ea4bcef7ac73827bd52d7c1bd9120e886cfb8395b187f3dd471244790dd8cb3" }, +] + +[[package]] +name = "pyside6" +version = "6.11.2" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ae/b5/99ff75f604d69eda1c5d1fe85cc43fb697d95eabc30ba86af099c2ddcd96/pyside6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:13a3c79816879d9743672a669f1988f74459a78f89a97a2624fae4fe059ffa3a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/b2/fe/968844def58150a41568b505edca0d68df6901b20ea4222ccb0c069cc703/pyside6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc6d03990489a5085842770718392ef5de36352d8f608d9a9fe03e60af4d7b66" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/5e/5c/f4ecc6defaec645e5ac3ca2f99e3da286a28d1c647c2d10b584e0392b94c/pyside6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:57fe867a6c93821a085d74e8a26f863fc2363516848181c466032595b33b1729" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/94/fe/4aea9d30300fff4cda9bb729c1363b9e1095a8544e0d6b57065465e16599/pyside6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:3201d67e3c10be2eaedd3910ff0f02351eca7e88c95a291cde5e7f2f55ef207f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/26/b0/8e2fb01e9ee15ad29fe10d0dd2d37f503e4aa3714c9fbb07aca380618de7/pyside6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:0444ac71d0791a19bded35f6d9a94515941f23a6eb7098ca45a1b64a338be697" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.2" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e3/91/339fe9f7c94163815004ccd6b5c58b926b2dfb4e51c3b5563d412eab151f/pyside6_addons-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:354c574a839f7d0751960ba03f33bec87c95e0f2796dca3287f54d03abe97dac" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/28/cd/ce470e20f68901f4dfa5f742d6a3a58ba865684a232e4b87d5a1a2179cc0/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a8b00956925fbdeffc7052cf933506391289f35ac4f3f71a0a167ec195cd47b4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/11/bd/f725a8984703612c72cd17d0871d047132bacaccc13fc0d288a5e8a37dad/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a2ca3c73e5f060d4aca49451abd9fba68dd0e1c66ff40cea3a1fe0415def579a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/fb/c1/08bf6594101b42fc88834009f1dac59a983c5b6805c62c5081f077d09b4a/pyside6_addons-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:f449ea4431da20e7b86752cca8d166f93434516fe417f981c27e5f8e1b554407" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/26/60/e4b7f09343e9f29e565f488fb44c4f1fe260db481ba2c08ad8a1a7d48216/pyside6_addons-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:d5606af369484b862b4d5741cfc179e19d2b8819c3e04d210b8188e0c9f85988" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.2" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/6f/16/0b7ecf89ebada82ed0430be33809ff325761ece83104f8635b1bd101fcd0/pyside6_essentials-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:77795c145202e65a78d88f7cd409d186e3ba23d159bdb3ba2dcd159ae5e5f0d9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/0e/1a/84a84bbc75835ee88abdcb66eb875b8e1a21834af9b833bc1893f2c1bfaf/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:aaf9f25f0f324874085fa5b26a610318db8a8e243cf85bb3e5400595191c7778" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/60/4c/d92b08a2151d095b9a3725fa0e71b215ec686d40bbd5d8b702d21a9b5eaa/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d3ec6e1885c46e57f16364f38ea8040ffc5dc0b18341058f608fede3ba930567" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/dc/09/d2662684aee4d3f027ff9b1e965f22301bc28ffc5b7f167658508e4475dc/pyside6_essentials-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:c8a29def77032773a30879f7f24415b5395ad08592d147c170824ef4c735dfc1" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c" }, +] + +[[package]] +name = "qt-py" +version = "2.0.7" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +dependencies = [ + { name = "types-pyside6" }, +] +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/16/ad/cd4dbe883f891c8bb8c876eebca935275a5f204da7744a6b26c57fb666e7/qt_py-2.0.7.tar.gz", hash = "sha256:4d053c3bd64b7af5d35ece16c29962c5f0d3dbbf759c94d3fe3d5af68f09623b" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/f7/bc/23f3dff80819f495ad5c783ff55ff5d71f57b8c7294b37cb535190e3c602/qt_py-2.0.7-py3-none-any.whl", hash = "sha256:a17896d06dd00dfabe37706360a3f4ce06fa1759c1b3132f87e2f34d22471b85" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" }, +] + +[[package]] +name = "shiboken6" +version = "6.11.2" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/47/44/11bf71c36e71936ab4e923e5dd23599dab527c4488b01860b0f689ce9947/shiboken6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:53659683b1f7a08e9f87eff9b1065f1ceb7110cd7a4bc09fdf5efe43d286604d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/a5/9a/f41bea4316d0c1bec4800842dcb4bd8fd3e29d6a79c496bc32e1aec42583/shiboken6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7a7a0a72a9ed26c9bf77d42246b1c736486befb8f31aa2fb29957ea4cdd1c1c2" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/56/05/79ed0697515d4fc7435459e4ed4c4a3b5621a55b7c91d0f3f2cc3d617aa0/shiboken6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4bbbd6fa4d7cff5ec5e12bc4c10e1d845fa30c89457ecb13cc64f7bddb77f6f9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396" }, + { url = "https://mirror.nju.edu.cn/pypi/web/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" }, +] + +[[package]] +name = "types-pyside6" +version = "6.10.3.0" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/49/a2/d4df95667ad290249bfb45f8082c7e4d5b46b8f3f01aa94a7420b9974116/types_pyside6-6.10.3.0-py2.py3-none-any.whl", hash = "sha256:abb3360637b1980c0d8b456c9b3f3031e0d0dd146df03b677e17a577e9892b8e" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://mirror.nju.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirror.nju.edu.cn/pypi/web/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" } +wheels = [ + { url = "https://mirror.nju.edu.cn/pypi/web/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" }, +] diff --git a/src/EasyCon.Capture/Audio/AudioCaptureModels.cs b/src/EasyCon.Capture/Audio/AudioCaptureModels.cs new file mode 100644 index 00000000..86569c19 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioCaptureModels.cs @@ -0,0 +1,56 @@ +namespace EasyCon.Capture.Audio; + +public enum AudioCaptureStatus +{ + Disconnected, + Connecting, + Connected, + Failed, +} + +public sealed record AudioDeviceInfo(string Id, string Name, string Backend = "wasapi"); + +public sealed record AudioFormat(int SampleRate, int Channels, int BitsPerSample, string Encoding); + +public sealed record AudioCaptureState( + AudioCaptureStatus Status, + AudioDeviceInfo? Device = null, + AudioFormat? Format = null, + string? Error = null); + +public sealed record AudioChunk( + long TimestampNs, + AudioFormat Format, + ReadOnlyMemory Pcm, + int BytesRecorded) +{ + /// The immutable connected state that produced this block, even if delivery races a reconnect. + public AudioCaptureState? Session { get; init; } +} + +public sealed record AudioLevel(double Peak, double Rms, bool Silent); + +public interface IAudioCaptureSource : IDisposable +{ + bool IsSupported { get; } + + AudioCaptureState State { get; } + + IReadOnlyList GetAvailableDevices(); + + bool TryConnect(string deviceId, CancellationToken cancellationToken = default); + + Task DisconnectAsync(); + + /// + /// Stops the source only when it is still waiting for its first data block. + /// Returns when the connecting operation was stopped. + /// + Task DisconnectIfConnectingAsync(); + + event Action? StateChanged; + + event Action? LevelChanged; + + event Action? DataAvailable; +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioClip.cs b/src/EasyCon.Capture/Audio/AudioClip.cs new file mode 100644 index 00000000..c327b4b2 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioClip.cs @@ -0,0 +1,86 @@ +using System.Buffers.Binary; + +namespace EasyCon.Capture.Audio; + +public readonly record struct AudioWaveformPeak(float Minimum, float Maximum); + +/// An owned, frame-aligned snapshot. Editing never changes live capture data. +public sealed class AudioClip +{ + public const int MaximumBytes = 16 * 1024 * 1024; + private readonly byte[] _pcm; + + public AudioClip(AudioFormat format, ReadOnlySpan pcm) + { + int alignment = GetBlockAlign(format); + if (pcm.Length == 0 || pcm.Length > MaximumBytes || pcm.Length % alignment != 0) + throw new ArgumentException("Audio must contain complete frames within the 16 MiB limit.", nameof(pcm)); + Format = format; + _pcm = pcm.ToArray(); + } + + public AudioFormat Format { get; } + public ReadOnlyMemory Pcm => _pcm; + public int FrameCount => _pcm.Length / GetBlockAlign(Format); + public double DurationSeconds => FrameCount / (double)Format.SampleRate; + + public static int GetBlockAlign(AudioFormat format) + { + ArgumentNullException.ThrowIfNull(format); + if (format.SampleRate is < 8000 or > 384000 || format.Channels is < 1 or > 32 + || (format.Encoding == "pcm" && format.BitsPerSample is not (8 or 16 or 24 or 32)) + || (format.Encoding == "float" && format.BitsPerSample != 32) + || format.Encoding is not ("pcm" or "float")) + throw new ArgumentException("Unsupported audio format.", nameof(format)); + return format.Channels * (format.BitsPerSample / 8); + } + + public AudioClip Slice(double startSeconds, double endSeconds) + { + if (!double.IsFinite(startSeconds) || !double.IsFinite(endSeconds) + || startSeconds < 0 || endSeconds > DurationSeconds || endSeconds <= startSeconds) + throw new ArgumentOutOfRangeException(nameof(startSeconds)); + int start = Math.Clamp((int)Math.Floor(startSeconds * Format.SampleRate), 0, FrameCount - 1); + int end = Math.Clamp((int)Math.Ceiling(endSeconds * Format.SampleRate), start + 1, FrameCount); + int alignment = GetBlockAlign(Format); + return new AudioClip(Format, _pcm.AsSpan(start * alignment, (end - start) * alignment)); + } + + public AudioWaveformPeak[] CreateWaveform(int maxBins = 1200) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBins); + int count = Math.Min(maxBins, FrameCount); + AudioWaveformPeak[] peaks = new AudioWaveformPeak[count]; + int sampleBytes = Format.BitsPerSample / 8; + for (int bin = 0; bin < count; bin++) + { + int start = (int)((long)bin * FrameCount / count) * Format.Channels; + int end = (int)((long)(bin + 1) * FrameCount / count) * Format.Channels; + float minimum = 0; + float maximum = 0; + for (int sample = start; sample < end; sample++) + { + float value = ReadSample(_pcm.AsSpan(sample * sampleBytes)); + minimum = Math.Min(minimum, value); + maximum = Math.Max(maximum, value); + } + peaks[bin] = new AudioWaveformPeak(minimum, maximum); + } + return peaks; + } + + private float ReadSample(ReadOnlySpan sample) + { + double value = Format.Encoding == "float" + ? BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(sample)) + : Format.BitsPerSample switch + { + 8 => (sample[0] - 128) / 128d, + 16 => BinaryPrimitives.ReadInt16LittleEndian(sample) / 32768d, + 24 => ((sample[0] | (sample[1] << 8) | (sample[2] << 16)) << 8 >> 8) / 8388608d, + 32 => BinaryPrimitives.ReadInt32LittleEndian(sample) / 2147483648d, + _ => 0, + }; + return double.IsFinite(value) ? (float)Math.Clamp(value, -1, 1) : 0; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioClipRecording.cs b/src/EasyCon.Capture/Audio/AudioClipRecording.cs new file mode 100644 index 00000000..f0d215f1 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioClipRecording.cs @@ -0,0 +1,155 @@ +namespace EasyCon.Capture.Audio; + +/// A finite recording of new game audio. Never owns or disconnects the capture device. +public sealed class AudioClipRecording : IDisposable +{ + private readonly IAudioCaptureSource _source; + private readonly AudioCaptureState _session; + private readonly AudioFormat _format; + private readonly Func _clock; + private readonly long _start; + private readonly int _alignment; + private readonly int _maximumBytes; + private readonly object _gate = new(); + private readonly MemoryStream _pcm = new(); + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Timer? _watchdog; + private long _lastData; + private bool _disposed; + + public AudioClipRecording(IAudioCaptureSource source) + : this(source, AudioClock.NowNanoseconds, true) + { + } + + internal AudioClipRecording(IAudioCaptureSource source, Func clock, bool watchClock) + { + _source = source; + _clock = clock; + _start = _lastData = clock(); + _session = source.State; + if (!source.IsSupported || _session.Status != AudioCaptureStatus.Connected || _session.Format == null) + throw new InvalidOperationException("请先连接游戏音频,再开始录制。"); + _format = _session.Format; + _alignment = AudioClip.GetBlockAlign(_format); + _maximumBytes = (int)Math.Min((long)_format.SampleRate * 15, AudioClip.MaximumBytes / _alignment) * _alignment; + _source.DataAvailable += OnData; + _source.StateChanged += OnState; + if (!ReferenceEquals(_source.State, _session)) + Fail("录制期间音频源已断开或重新连接,请重新录制。"); + if (watchClock) + _watchdog = new Timer(_ => CheckInput(), null, 100, 100); + } + + public Task Completion => _completion.Task; + public double MaximumSeconds => _maximumBytes / (double)_alignment / _format.SampleRate; + public double Duration { get { lock (_gate) return _pcm.Length / (double)_alignment / _format.SampleRate; } } + public bool ReachedLimit { get; private set; } + + public void Stop() + { + lock (_gate) + { + CheckInput(); + Finish(); + } + } + + internal void CheckInput() + { + lock (_gate) + { + if (_disposed || Completion.IsCompleted) + return; + long now = _clock(); + if (!ReferenceEquals(_source.State, _session)) + Fail("录制期间音频源已断开或重新连接,请重新录制。"); + else if (now - _lastData > 1_000_000_000L) + Fail("录制期间超过 1 秒没有音频数据,请检查输入并重新录制。"); + else if (now - _start >= MaximumSeconds * 1_000_000_000L) + { + ReachedLimit = true; + Finish(); + } + } + } + + private void OnData(AudioChunk chunk) + { + lock (_gate) + { + if (_disposed || Completion.IsCompleted) + return; + if (!ReferenceEquals(_source.State, _session)) + { + Fail("录制期间音频源已断开或重新连接,请重新录制。"); + return; + } + // Ignore callbacks from older sessions or audio already captured before the click. + if ((chunk.Session != null && !ReferenceEquals(chunk.Session, _session)) + || (chunk.TimestampNs > 0 && chunk.TimestampNs <= _start)) + return; + if (chunk.Format != _format || chunk.BytesRecorded <= 0 || chunk.BytesRecorded > chunk.Pcm.Length + || chunk.BytesRecorded % _alignment != 0) + { + Fail("录制收到无效音频或格式变化,请重新录制。"); + return; + } + long now = _clock(); + if (now - _lastData > 1_000_000_000L) + { + Fail("录制期间音频输入中断,请重新录制。"); + return; + } + _lastData = now; + int frames = chunk.BytesRecorded / _alignment; + long skip = chunk.TimestampNs > 0 + ? Math.Clamp((long)Math.Ceiling((_start - chunk.TimestampNs) * (_format.SampleRate / 1_000_000_000d) + frames), 0, frames) + : 0; + int offset = (int)skip * _alignment; + int count = (int)Math.Min(chunk.BytesRecorded - offset, _maximumBytes - _pcm.Length); + _pcm.Write(chunk.Pcm.Span.Slice(offset, count)); + if (_pcm.Length == _maximumBytes) + { + ReachedLimit = true; + Finish(); + } + } + } + + private void OnState(AudioCaptureState state) + { + lock (_gate) + { + if (!_disposed && !ReferenceEquals(_source.State, _session)) + Fail("录制期间音频源已断开或重新连接,请重新录制。"); + } + } + + private void Fail(string message) => _completion.TrySetException(new InvalidOperationException(message)); + + private void Finish() + { + if (_disposed || Completion.IsCompleted) + return; + if (_pcm.Length == 0) + Fail("尚未录到声音,请确认输入有数据后再录制。"); + else + _completion.TrySetResult(new AudioClip(_format, _pcm.GetBuffer().AsSpan(0, (int)_pcm.Length))); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _completion.TrySetCanceled(); + _pcm.Dispose(); + } + _watchdog?.Dispose(); + _source.DataAvailable -= OnData; + _source.StateChanged -= OnState; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioClock.cs b/src/EasyCon.Capture/Audio/AudioClock.cs new file mode 100644 index 00000000..9041d4f4 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioClock.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace EasyCon.Capture.Audio; + +internal static class AudioClock +{ + public static long NowNanoseconds() => ToNanoseconds(Stopwatch.GetTimestamp()); + + public static long ToNanoseconds(long ticks) + => (ticks / Stopwatch.Frequency * 1_000_000_000L) + + (ticks % Stopwatch.Frequency * 1_000_000_000L / Stopwatch.Frequency); +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioHistoryBuffer.cs b/src/EasyCon.Capture/Audio/AudioHistoryBuffer.cs new file mode 100644 index 00000000..19cdfc57 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioHistoryBuffer.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; + +namespace EasyCon.Capture.Audio; + +/// +/// Bounded, in-memory history for clip editing. Borrows the existing source; +/// never opens a device or owns its connection. Timestamps are callback times, +/// not hardware sample timestamps, and must not be used for script detection. +/// +public sealed class AudioHistoryBuffer : IDisposable +{ + private readonly IAudioCaptureSource _source; + private readonly object _gate = new(); + private readonly double _seconds; + private readonly Func _clock; + private byte[] _buffer = []; + private AudioCaptureState? _session; + private AudioFormat? _format; + private int _write; + private int _length; + private long _lastPacket; + private bool _disposed; + + public bool IsSupported => _source.IsSupported; + + public AudioClipRecording BeginRecording() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return new AudioClipRecording(_source); + } + } + + public AudioHistoryBuffer(IAudioCaptureSource source, double seconds = 15) + : this(source, seconds, Stopwatch.GetTimestamp) + { + } + + internal AudioHistoryBuffer(IAudioCaptureSource source, double seconds, Func clock) + { + if (!double.IsFinite(seconds) || seconds < 0.001 || seconds > 15) + throw new ArgumentOutOfRangeException(nameof(seconds)); + _source = source; + _seconds = seconds; + _clock = clock; + _source.DataAvailable += OnDataAvailable; + _source.StateChanged += OnStateChanged; + } + + public AudioClip? Snapshot(double seconds = 10) + { + if (!double.IsFinite(seconds) || seconds <= 0 || seconds > _seconds) + throw new ArgumentOutOfRangeException(nameof(seconds)); + lock (_gate) + { + if (_disposed || _format == null || _length == 0 || !ReferenceEquals(_session, _source.State) + || _source.State.Status != AudioCaptureStatus.Connected + || _clock() - _lastPacket > Stopwatch.Frequency) + return null; + int alignment = AudioClip.GetBlockAlign(_format); + int frames = (int)Math.Min(_length / alignment, Math.Floor(seconds * _format.SampleRate)); + if (frames == 0) + return null; + byte[] pcm = new byte[frames * alignment]; + int start = (_write - pcm.Length + _buffer.Length) % _buffer.Length; + int first = Math.Min(pcm.Length, _buffer.Length - start); + _buffer.AsSpan(start, first).CopyTo(pcm); + _buffer.AsSpan(0, pcm.Length - first).CopyTo(pcm.AsSpan(first)); + return new AudioClip(_format, pcm); + } + } + + private void OnDataAvailable(AudioChunk chunk) + { + lock (_gate) + { + if (_disposed) + return; + AudioCaptureState state = _source.State; + // A delayed callback must not populate or invalidate another connection's history. + if (chunk.Session != null && !ReferenceEquals(chunk.Session, state)) + return; + if (state.Status != AudioCaptureStatus.Connected || state.Format != chunk.Format) + { + Clear(); + return; + } + int alignment; + try + { + alignment = AudioClip.GetBlockAlign(chunk.Format); + } + catch (ArgumentException) + { + Clear(); + return; + } + if (chunk.BytesRecorded <= 0 || chunk.BytesRecorded > chunk.Pcm.Length || chunk.BytesRecorded % alignment != 0) + { + Clear(); + return; + } + long now = _clock(); + if (!ReferenceEquals(_session, state) || _format != chunk.Format || now - _lastPacket > Stopwatch.Frequency) + { + Clear(); + _session = state; + _format = chunk.Format; + int frames = (int)Math.Min(Math.Floor(_seconds * chunk.Format.SampleRate), AudioClip.MaximumBytes / alignment); + _buffer = new byte[frames * alignment]; + } + ReadOnlySpan data = chunk.Pcm.Span[..chunk.BytesRecorded]; + if (data.Length > _buffer.Length) + data = data[^_buffer.Length..]; + int first = Math.Min(data.Length, _buffer.Length - _write); + data[..first].CopyTo(_buffer.AsSpan(_write)); + data[first..].CopyTo(_buffer); + _write = (_write + data.Length) % _buffer.Length; + _length = Math.Min(_length + data.Length, _buffer.Length); + _lastPacket = now; + } + } + + private void OnStateChanged(AudioCaptureState state) + { + lock (_gate) + { + // An old queued disconnect must not erase a newer connection's history. + if (ReferenceEquals(state, _source.State) && state.Status != AudioCaptureStatus.Connected) + Clear(); + } + } + + private void Clear() + { + _buffer = []; + _format = null; + _session = null; + _write = 0; + _length = 0; + } + + public void Dispose() + { + lock (_gate) + { + _disposed = true; + Clear(); + } + _source.DataAvailable -= OnDataAvailable; + _source.StateChanged -= OnStateChanged; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioLabelStore.cs b/src/EasyCon.Capture/Audio/AudioLabelStore.cs new file mode 100644 index 00000000..30277ebf --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioLabelStore.cs @@ -0,0 +1,109 @@ +using NAudio.Dmo; +using NAudio.Wave; +using System.Text.Json; + +namespace EasyCon.Capture.Audio; + +public sealed record AudioLabelMetadata(int Version, string Name, AudioFormat Format, int FrameCount, DateTimeOffset CreatedAtUtc); +public sealed record AudioLabel(AudioLabelMetadata Metadata, AudioClip Clip); + +/// A movable label directory containing label.json and an unprocessed audio.wav. +public static class AudioLabelStore +{ + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + + public static bool IsValidName(string? name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 80 || name != name.Trim() + || name.EndsWith('.') || name.Any(c => c < 32 || "<>:\"/\\|?*".Contains(c))) + return false; + string stem = name.Split('.')[0].ToUpperInvariant(); + return stem is not ("CON" or "PRN" or "AUX" or "NUL") + && !(stem.Length == 4 && (stem.StartsWith("COM") || stem.StartsWith("LPT")) && stem[3] is >= '0' and <= '9'); + } + + public static string Save(string parentDirectory, string name, AudioClip clip) + { + if (!IsValidName(name)) + throw new ArgumentException("Invalid audio label name.", nameof(name)); + ArgumentNullException.ThrowIfNull(clip); + string parent = Path.GetFullPath(parentDirectory); + Directory.CreateDirectory(parent); + string target = Path.Combine(parent, name + ".audiolabel"); + if (Directory.Exists(target) || File.Exists(target)) + throw new IOException("An audio label with this name already exists."); + string temporary = Path.Combine(parent, ".audio-label-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temporary); + try + { + using (WaveFileWriter writer = new(Path.Combine(temporary, "audio.wav"), ToWaveFormat(clip.Format))) + writer.Write(clip.Pcm.Span); + AudioLabelMetadata metadata = new(1, name, clip.Format, clip.FrameCount, DateTimeOffset.UtcNow); + File.WriteAllText(Path.Combine(temporary, "label.json"), JsonSerializer.Serialize(metadata, _jsonOptions)); + // Publish both files together; collisions never overwrite an existing label. + Directory.Move(temporary, target); + return Path.Combine(target, "label.json"); + } + finally + { + if (Directory.Exists(temporary)) + Directory.Delete(temporary, true); + } + } + + public static AudioLabel Load(string metadataPath) + { + if (new FileInfo(metadataPath).Length > 64 * 1024) + throw new InvalidDataException("Audio label metadata is too large."); + AudioLabelMetadata metadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)) + ?? throw new InvalidDataException("Missing audio label metadata."); + if (metadata.Version != 1 || !IsValidName(metadata.Name) || metadata.FrameCount <= 0) + throw new InvalidDataException("Unsupported or invalid audio label."); + AudioClip clip = ReadWave(Path.Combine(Path.GetDirectoryName(Path.GetFullPath(metadataPath))!, "audio.wav")); + if (metadata.Format != clip.Format || metadata.FrameCount != clip.FrameCount) + throw new InvalidDataException("Audio label metadata does not match its WAV file."); + return new AudioLabel(metadata, clip); + } + + public static AudioClip ReadWave(string path) + { + using WaveFileReader reader = new(path); + WaveFormat wave = reader.WaveFormat; + WaveFormatEncoding waveEncoding = wave.Encoding; + if (waveEncoding == WaveFormatEncoding.Extensible) + { + // WaveFileReader preserves the fmt extension as raw data, not WaveFormatExtensible. + Guid subtype = wave switch + { + WaveFormatExtensible extensible => extensible.SubFormat, + WaveFormatExtraData { ExtraSize: >= 22 } extra when extra.ExtraData.Length >= 22 + => new Guid(extra.ExtraData.AsSpan(6, 16)), + _ => throw new InvalidDataException("The extensible WAV format is incomplete."), + }; + waveEncoding = subtype == AudioMediaSubtypes.MEDIASUBTYPE_PCM ? WaveFormatEncoding.Pcm + : subtype == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT ? WaveFormatEncoding.IeeeFloat + : throw new InvalidDataException("Only PCM and float WAV files are supported."); + } + string encoding = waveEncoding switch + { + WaveFormatEncoding.Pcm => "pcm", + WaveFormatEncoding.IeeeFloat => "float", + _ => throw new InvalidDataException("Only PCM and float WAV files are supported."), + }; + AudioFormat format = new(wave.SampleRate, wave.Channels, wave.BitsPerSample, encoding); + int alignment = AudioClip.GetBlockAlign(format); + if (reader.Length <= 0 || reader.Length > AudioClip.MaximumBytes || reader.Length % alignment != 0) + throw new InvalidDataException("WAV must contain complete frames within the 16 MiB limit."); + byte[] pcm = new byte[(int)reader.Length]; + reader.ReadExactly(pcm); + return new AudioClip(format, pcm); + } + + public static WaveFormat ToWaveFormat(AudioFormat format) + { + AudioClip.GetBlockAlign(format); + return format.Encoding == "float" + ? WaveFormat.CreateIeeeFloatWaveFormat(format.SampleRate, format.Channels) + : new WaveFormat(format.SampleRate, format.BitsPerSample, format.Channels); + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioLevelCalculator.cs b/src/EasyCon.Capture/Audio/AudioLevelCalculator.cs new file mode 100644 index 00000000..05314e19 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioLevelCalculator.cs @@ -0,0 +1,52 @@ +using System.Buffers.Binary; + +namespace EasyCon.Capture.Audio; + +public static class AudioLevelCalculator +{ + public static AudioLevel Calculate(ReadOnlySpan pcm, AudioFormat format) + { + if (format.SampleRate <= 0 || format.Channels <= 0 || format.BitsPerSample is not (8 or 16 or 24 or 32)) + return new AudioLevel(0, 0, true); + + if (format.Encoding == "float" && format.BitsPerSample != 32) + return new AudioLevel(0, 0, true); + + int sampleBytes = format.BitsPerSample / 8; + if (sampleBytes <= 0 || pcm.Length < sampleBytes) + return new AudioLevel(0, 0, true); + + double square = 0; + double peak = 0; + int sampleCount = 0; + for (int offset = 0; offset + sampleBytes <= pcm.Length; offset += sampleBytes) + { + var value = format.Encoding switch + { + "float" when format.BitsPerSample == 32 => BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(pcm[offset..])), + "pcm" when format.BitsPerSample == 8 => (pcm[offset] - 128) / 128d, + "pcm" when format.BitsPerSample == 16 => BinaryPrimitives.ReadInt16LittleEndian(pcm[offset..]) / 32768d, + "pcm" when format.BitsPerSample == 24 => ReadPcm24(pcm[offset..]) / 8388608d, + "pcm" when format.BitsPerSample == 32 => BinaryPrimitives.ReadInt32LittleEndian(pcm[offset..]) / 2147483648d, + _ => 0, + }; + + if (!double.IsFinite(value)) + value = 0; + value = Math.Clamp(value, -1, 1); + var absolute = Math.Abs(value); + peak = Math.Max(peak, absolute); + square += value * value; + sampleCount++; + } + + var rms = sampleCount == 0 ? 0 : Math.Sqrt(square / sampleCount); + return new AudioLevel(peak, rms, peak < 0.00001); + } + + private static int ReadPcm24(ReadOnlySpan value) + { + var result = value[0] | (value[1] << 8) | (value[2] << 16); + return (result & 0x800000) != 0 ? result - 0x1000000 : result; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioMatchMonitor.cs b/src/EasyCon.Capture/Audio/AudioMatchMonitor.cs new file mode 100644 index 00000000..63bcee05 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioMatchMonitor.cs @@ -0,0 +1,346 @@ +using System.Diagnostics; + +namespace EasyCon.Capture.Audio; + +public enum AudioMatchStatus +{ + WaitingForAudio, + WarmingUp, + Listening, + Interrupted, + Disconnected, + InvalidAudio, +} + +public sealed record AudioMatchSettings +{ + public AudioMatchSettings(int threshold = 85, double cooldownSeconds = 1) + { + if (threshold is < 10 or > 100) + throw new ArgumentOutOfRangeException(nameof(threshold)); + if (!double.IsFinite(cooldownSeconds) || cooldownSeconds is < 0 or > 30) + throw new ArgumentOutOfRangeException(nameof(cooldownSeconds)); + Threshold = threshold; + CooldownSeconds = cooldownSeconds; + } + + public int Threshold { get; } + public double CooldownSeconds { get; } +} + +public sealed record AudioMatchHit(DateTimeOffset DetectedAt, int Score, int Threshold); +public sealed record AudioMatchUpdate(AudioMatchStatus Status, int? Score, IReadOnlyList Hits) +{ + internal long Generation { get; init; } = -1; +} + +/// +/// Borrows an existing capture source. Call Poll on a single background consumer. +/// The callback only copies into a bounded queue; FFT and comparison never run on capture/UI threads. +/// Callback arrival times detect gross stalls, not hardware discontinuities or script timing boundaries. +/// +public sealed class AudioMatchMonitor : IDisposable +{ + private const int MaximumQueuedBytes = 4 * 1024 * 1024; + private const double MaximumQueuedSeconds = 2; + private readonly IAudioCaptureSource _source; + private readonly AudioMatchTemplate _template; + private readonly Func _clock; + private readonly bool _requireContinuousSession; + private readonly long _start; + private readonly AudioCaptureState? _loadedSession; + private string? _failure; + private long _lastTimestampNs; + private readonly object _gate = new(); + private readonly Queue _pending = new(); + private AudioCaptureState? _session; + private AudioMatchStatus _resetStatus = AudioMatchStatus.WaitingForAudio; + private int _queuedBytes; + private double _queuedSeconds; + private long _lastPacket; + private long _epoch; + private long _processedEpoch = -1; + private bool _disposed; + private AudioMatchSettings _settings = new(); + private AudioTemplateMatcher? _matcher; + private int? _lastScore; + private readonly AudioMatchTrigger _trigger = new(); + + public AudioMatchMonitor(IAudioCaptureSource source, AudioMatchTemplate template) + : this(source, template, Stopwatch.GetTimestamp) + { + } + + internal AudioMatchMonitor(IAudioCaptureSource source, AudioMatchTemplate template, Func clock, + bool requireContinuousSession = false) + { + _source = source; + _template = template; + _clock = clock; + _requireContinuousSession = requireContinuousSession; + _start = clock(); + if (requireContinuousSession) + { + _loadedSession = source.State; + if (!source.IsSupported || _loadedSession.Status != AudioCaptureStatus.Connected || _loadedSession.Format == null) + throw new InvalidOperationException("请先连接游戏音频,再使用 AUDIO_LOAD。"); + _session = _loadedSession; + } + _source.DataAvailable += OnDataAvailable; + _source.StateChanged += OnStateChanged; + try { CheckHealth(); } + catch + { + Dispose(); + throw; + } + } + + public AudioMatchSettings Settings + { + get => Volatile.Read(ref _settings); + set => Volatile.Write(ref _settings, value ?? throw new ArgumentNullException(nameof(value))); + } + + private void OnDataAvailable(AudioChunk chunk) + { + lock (_gate) + { + if (_disposed || _failure != null) + return; + AudioCaptureState state = _source.State; + if (_requireContinuousSession && !ReferenceEquals(state, _loadedSession)) + { + ResetLocked(AudioMatchStatus.Disconnected, state); + return; + } + // Check identity before validation: even a malformed old packet must leave new audio intact. + if (chunk.Session != null && !ReferenceEquals(chunk.Session, state)) + return; + long now = _clock(); + if (_requireContinuousSession) + { + if (chunk.TimestampNs <= AudioClock.ToNanoseconds(_start)) + return; + if (chunk.Session == null || chunk.TimestampNs <= _lastTimestampNs + || chunk.TimestampNs > AudioClock.ToNanoseconds(now) + || AudioClock.ToNanoseconds(now) - chunk.TimestampNs > 1_000_000_000L) + { + ResetLocked(AudioMatchStatus.InvalidAudio, state); + return; + } + } + if (state.Status != AudioCaptureStatus.Connected || state.Format != chunk.Format) + { + ResetLocked(AudioMatchStatus.Interrupted, state); + return; + } + int alignment; + try { alignment = AudioClip.GetBlockAlign(chunk.Format); } + catch (ArgumentException) + { + ResetLocked(AudioMatchStatus.InvalidAudio, state); + return; + } + double seconds = chunk.BytesRecorded / (double)alignment / chunk.Format.SampleRate; + if (chunk.BytesRecorded <= 0 || chunk.BytesRecorded > chunk.Pcm.Length || chunk.BytesRecorded % alignment != 0 + || chunk.BytesRecorded > MaximumQueuedBytes || seconds > MaximumQueuedSeconds) + { + ResetLocked(AudioMatchStatus.InvalidAudio, state); + return; + } + if (!ReferenceEquals(_session, state)) + ResetLocked(AudioMatchStatus.WarmingUp, state); + else if ((_lastPacket != 0 && now - _lastPacket > Stopwatch.Frequency) + || _pending.Count >= 256 || _queuedBytes + chunk.BytesRecorded > MaximumQueuedBytes + || _queuedSeconds + seconds > MaximumQueuedSeconds) + ResetLocked(AudioMatchStatus.Interrupted, state); + if (_failure != null) + return; + // A callback can straddle LOAD. Keep only complete sample frames after its boundary. + int skip = _requireContinuousSession + ? (int)Math.Clamp(Math.Ceiling((AudioClock.ToNanoseconds(_start) - chunk.TimestampNs) + * (chunk.Format.SampleRate / 1_000_000_000d) + chunk.BytesRecorded / alignment), 0, chunk.BytesRecorded / alignment) + : 0; + int bytes = chunk.BytesRecorded - skip * alignment; + if (bytes > 0) + { + _pending.Enqueue(chunk.Pcm.Span.Slice(skip * alignment, bytes).ToArray()); + _queuedBytes += bytes; + _queuedSeconds += bytes / (double)alignment / chunk.Format.SampleRate; + } + _lastPacket = now; + _lastTimestampNs = chunk.TimestampNs; + } + } + + private void OnStateChanged(AudioCaptureState state) + { + lock (_gate) + { + if (!_disposed && ReferenceEquals(state, _source.State) && !ReferenceEquals(state, _session)) + ResetLocked(state.Status == AudioCaptureStatus.Connected ? AudioMatchStatus.WarmingUp : AudioMatchStatus.Disconnected, state); + } + } + + private void ResetLocked(AudioMatchStatus status, AudioCaptureState? state) + { + if (_requireContinuousSession && !_disposed) + { + if (!ReferenceEquals(state, _loadedSession)) + _failure ??= "音频源已断开或重新连接,请重新运行脚本。"; + else if (status == AudioMatchStatus.InvalidAudio) + _failure ??= "识别收到无效音频、格式变化或不连续的时间戳。"; + else if (status == AudioMatchStatus.Interrupted) + _failure ??= "音频输入中断或后台分析积压,识别已停止,请检查输入和处理负载。"; + } + _epoch++; + _pending.Clear(); + _queuedBytes = 0; + _queuedSeconds = 0; + _lastPacket = 0; + _session = state; + _resetStatus = status; + } + + public AudioMatchUpdate Poll(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + CheckHealth(); + AudioCaptureState state; + byte[][] packets; + long epoch; + AudioMatchStatus resetStatus; + lock (_gate) + { + if (_disposed) + return new AudioMatchUpdate(AudioMatchStatus.Disconnected, null, []) { Generation = _epoch }; + state = _source.State; + if (!ReferenceEquals(state, _session)) + ResetLocked(state.Status == AudioCaptureStatus.Connected ? AudioMatchStatus.WaitingForAudio : AudioMatchStatus.Disconnected, state); + if (_lastPacket != 0 && _clock() - _lastPacket > Stopwatch.Frequency) + ResetLocked(AudioMatchStatus.Interrupted, state); + packets = _pending.ToArray(); + _pending.Clear(); + _queuedBytes = 0; + _queuedSeconds = 0; + epoch = _epoch; + resetStatus = _resetStatus; + } + + bool reset = _processedEpoch != epoch; + if (reset) + { + _processedEpoch = epoch; + _matcher = null; + _lastScore = null; + _trigger.Reset(); + } + List hits = []; + int? score = null; + AudioMatchSettings settings = Settings; + try + { + foreach (byte[] packet in packets) + { + cancellationToken.ThrowIfCancellationRequested(); + _matcher ??= new AudioTemplateMatcher(_template, state.Format!); + _matcher.Append(packet, match => + { + int similarity = ToPercentage(match.Score); + score = Math.Max(score ?? 0, similarity); + if (_trigger.Observe(similarity, match.EndSeconds, settings)) + hits.Add(new AudioMatchHit(DateTimeOffset.Now, similarity, settings.Threshold)); + }, cancellationToken); + } + } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException) + { + lock (_gate) + { + if (_epoch == epoch) + ResetLocked(AudioMatchStatus.InvalidAudio, state); + CheckHealth(); + return new AudioMatchUpdate(AudioMatchStatus.InvalidAudio, null, []) { Generation = _epoch }; + } + } + + // A disconnect/overflow during analysis invalidates the whole in-flight batch, including hits. + lock (_gate) + { + CheckHealth(); + if (_disposed || _epoch != epoch || !ReferenceEquals(state, _source.State)) + return new AudioMatchUpdate(AudioMatchStatus.Interrupted, null, []) { Generation = _epoch }; + } + if (score.HasValue) + _lastScore = score; + AudioMatchStatus status = _lastScore.HasValue ? AudioMatchStatus.Listening + : _matcher != null ? AudioMatchStatus.WarmingUp : resetStatus; + if (reset && resetStatus is AudioMatchStatus.Interrupted or AudioMatchStatus.InvalidAudio) + status = resetStatus; + return new AudioMatchUpdate(status, _lastScore, hits) { Generation = epoch }; + } + + /// Quantize normalized matcher output once for scores, peaks and hit decisions. + internal static int ToPercentage(double score) => (int)Math.Floor(score * 100); + + /// Reject a result if its source changed while the consumer was dispatching it. + public bool IsCurrent(AudioMatchUpdate update) + { + lock (_gate) + return !_disposed && _epoch == update.Generation && ReferenceEquals(_session, _source.State) + && (_lastPacket == 0 || _clock() - _lastPacket <= Stopwatch.Frequency); + } + + /// Script sessions keep a terminal failure; the diagnostic UI may instead recover. + public void CheckHealth() + { + if (!_requireContinuousSession) + return; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!ReferenceEquals(_source.State, _loadedSession)) + _failure ??= "音频源已断开或重新连接,请重新运行脚本。"; + if (_clock() - (_lastPacket == 0 ? _start : _lastPacket) > Stopwatch.Frequency) + _failure ??= "音频输入超过 1 秒没有新数据,请检查输入并重新运行脚本。"; + if (_failure != null) + throw new InvalidOperationException(_failure); + } + } + + public void Dispose() + { + lock (_gate) + { + _disposed = true; + ResetLocked(AudioMatchStatus.Disconnected, null); + } + _source.DataAvailable -= OnDataAvailable; + _source.StateChanged -= OnStateChanged; + } +} + +internal sealed class AudioMatchTrigger +{ + private bool _armed = true; + private double _lastHit = double.NegativeInfinity; + + public bool Observe(int score, double endSeconds, AudioMatchSettings settings) + { + // Rearm only after the signal falls. Cooldown alone would repeatedly report one long sound. + if (score < Math.Max(0, settings.Threshold - 5)) + _armed = true; + if (!_armed || score < settings.Threshold || endSeconds - _lastHit < settings.CooldownSeconds) + return false; + _armed = false; + _lastHit = endSeconds; + return true; + } + + public void Reset() + { + _armed = true; + _lastHit = double.NegativeInfinity; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioMatchTemplate.cs b/src/EasyCon.Capture/Audio/AudioMatchTemplate.cs new file mode 100644 index 00000000..c8c85daf --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioMatchTemplate.cs @@ -0,0 +1,226 @@ +using NAudio.Dsp; +using System.Buffers.Binary; +using System.Numerics; +using Complex = NAudio.Dsp.Complex; + +namespace EasyCon.Capture.Audio; + +/// A spectral reference for diagnostic matching, not a calibrated classifier. +public sealed class AudioMatchTemplate +{ + public const double MinimumSeconds = 0.15; + public const double MaximumSeconds = 10; + + private AudioMatchTemplate(float[][] frames, double durationSeconds) + { + Frames = frames; + DurationSeconds = durationSeconds; + SignalFrames = frames.Count(frame => frame.Any(value => value != 0)); + } + + internal float[][] Frames { get; } + internal int SignalFrames { get; } + public double DurationSeconds { get; } + + /// Returns null for silence or an otherwise featureless reference. + public static AudioMatchTemplate? Create(AudioClip clip, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(clip); + if (clip.DurationSeconds < MinimumSeconds || clip.DurationSeconds > MaximumSeconds) + throw new ArgumentOutOfRangeException(nameof(clip), "A matching reference must last 0.15 to 10 seconds."); + List frames = []; + AudioSpectralFeatures extractor = new(clip.Format); + extractor.Append(clip.Pcm.Span, frames.Add, cancellationToken); + AudioMatchTemplate template = new(frames.ToArray(), clip.DurationSeconds); + return template.SignalFrames >= 3 ? template : null; + } +} + +/// +/// Stateful managed resampling and STFT: 8 kHz, 32 ms Hann windows, 10 ms hops. +/// Combine channel power before normalization, so opposite stereo phases cannot cancel. +/// +internal sealed class AudioSpectralFeatures +{ + internal const int SampleRate = 8000; + internal const int WindowSize = 256; + internal const int HopSize = 80; + private const int FirstBin = 3; + internal const int FeatureCount = (WindowSize / 2) + 1 - FirstBin; + private const int InputBatchFrames = 1024; + private readonly AudioFormat _format; + private readonly WdlResampler _resampler = new(); + private readonly float[] _output; + private readonly float[] _window; + private readonly Complex[] _fft = new Complex[WindowSize]; + private readonly double[] _power = new double[FeatureCount]; + private static readonly float[] _hann = Enumerable.Range(0, WindowSize) + .Select(index => (float)(0.5 - (0.5 * Math.Cos(2 * Math.PI * index / (WindowSize - 1))))).ToArray(); + private int _filled; + private long _inputFrames; + private long _outputFrames; + + public AudioSpectralFeatures(AudioFormat format) + { + AudioClip.GetBlockAlign(format); + _format = format; + _window = new float[WindowSize * format.Channels]; + _output = new float[(InputBatchFrames + WindowSize) * format.Channels]; + _resampler.SetMode(true, 2, false); + _resampler.SetFilterParms(); + _resampler.SetFeedMode(true); + _resampler.SetRates(format.SampleRate, SampleRate); + } + + public void Append(ReadOnlySpan pcm, Action onFrame, CancellationToken cancellationToken) + { + int alignment = AudioClip.GetBlockAlign(_format); + if (pcm.Length == 0 || pcm.Length % alignment != 0) + throw new InvalidDataException("Audio contains incomplete sample frames."); + int sampleBytes = _format.BitsPerSample / 8; + while (!pcm.IsEmpty) + { + cancellationToken.ThrowIfCancellationRequested(); + int frames = Math.Min(InputBatchFrames, pcm.Length / alignment); + int requested = _resampler.ResamplePrepare(frames, _format.Channels, out float[] input, out int offset); + if (requested != frames) + throw new InvalidOperationException("Unexpected resampler input size."); + for (int sample = 0; sample < frames * _format.Channels; sample++) + input[offset + sample] = ReadSample(pcm[(sample * sampleBytes)..]); + _inputFrames += frames; + // Limit output by the cumulative sample clock. Requesting the whole output buffer + // lets WDL step beyond a downsampled packet and lose its integer phase at the boundary. + int wanted = (int)((_inputFrames * SampleRate / _format.SampleRate) - _outputFrames); + int produced = _resampler.ResampleOut(_output, 0, frames, wanted, _format.Channels); + _outputFrames += produced; + for (int frame = 0; frame < produced; frame++) + { + _output.AsSpan(frame * _format.Channels, _format.Channels).CopyTo(_window.AsSpan(_filled * _format.Channels)); + _filled++; + if (_filled != WindowSize) + continue; + cancellationToken.ThrowIfCancellationRequested(); + onFrame(CreateFeature()); + _window.AsSpan(HopSize * _format.Channels).CopyTo(_window); + _filled -= HopSize; + } + pcm = pcm[(frames * alignment)..]; + } + } + + private float ReadSample(ReadOnlySpan sample) + { + double value = _format.Encoding == "float" + ? BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(sample)) + : _format.BitsPerSample switch + { + 8 => (sample[0] - 128) / 128d, + 16 => BinaryPrimitives.ReadInt16LittleEndian(sample) / 32768d, + 24 => ((sample[0] | (sample[1] << 8) | (sample[2] << 16)) << 8 >> 8) / 8388608d, + 32 => BinaryPrimitives.ReadInt32LittleEndian(sample) / 2147483648d, + _ => 0, + }; + if (!double.IsFinite(value)) + throw new InvalidDataException("Audio contains non-finite samples."); + return (float)Math.Clamp(value, -1, 1); + } + + private float[] CreateFeature() + { + Array.Clear(_power); + double energy = 0; + for (int channel = 0; channel < _format.Channels; channel++) + { + double channelMean = 0; + for (int index = 0; index < WindowSize; index++) + channelMean += _window[(index * _format.Channels) + channel]; + channelMean /= WindowSize; + for (int index = 0; index < WindowSize; index++) + { + float value = _window[(index * _format.Channels) + channel] - (float)channelMean; + energy += value * value; + _fft[index] = new Complex { X = value * _hann[index] }; + } + FastFourierTransform.FFT(true, 8, _fft); + for (int bin = FirstBin; bin <= WindowSize / 2; bin++) + _power[bin - FirstBin] += (_fft[bin].X * _fft[bin].X) + (_fft[bin].Y * _fft[bin].Y); + } + float[] feature = new float[FeatureCount]; + double maximum = _power.Max(); + // Gate silence before normalization: numerical noise must not become a loud fingerprint. + if (energy / _window.Length < 1e-8 || maximum < 1e-12) + return feature; + double mean = 0; + for (int bin = 0; bin < FeatureCount; bin++) + { + feature[bin] = (float)Math.Log(1 + (100 * Math.Sqrt(_power[bin] / maximum))); + mean += feature[bin]; + } + mean /= FeatureCount; + double norm = 0; + for (int bin = 0; bin < FeatureCount; bin++) + { + feature[bin] -= (float)mean; + norm += feature[bin] * feature[bin]; + } + if (norm < 1e-8) + Array.Clear(feature); + else + { + float scale = (float)(1 / Math.Sqrt(norm)); + for (int bin = 0; bin < FeatureCount; bin++) + feature[bin] *= scale; + } + return feature; + } +} + +internal readonly record struct AudioMatchScore(double Score, double EndSeconds); + +/// One incremental matcher per continuous capture session; no unbounded audio history. +internal sealed class AudioTemplateMatcher +{ + private readonly AudioMatchTemplate _template; + private readonly AudioSpectralFeatures _extractor; + private readonly float[][] _frames; + private int _write; + private long _count; + + public AudioTemplateMatcher(AudioMatchTemplate template, AudioFormat format) + { + _template = template; + _extractor = new AudioSpectralFeatures(format); + _frames = new float[template.Frames.Length][]; + } + + public void Append(ReadOnlySpan pcm, Action onScore, CancellationToken cancellationToken = default) + { + _extractor.Append(pcm, frame => + { + _frames[_write] = frame; + _write = (_write + 1) % _frames.Length; + _count++; + if (_count < _frames.Length) + return; + double sum = 0; + for (int index = 0; index < _frames.Length; index++) + sum += Dot(_frames[(_write + index) % _frames.Length], _template.Frames[index]); + double score = Math.Clamp(sum / _template.SignalFrames, 0, 1); + double end = (((_count - 1) * AudioSpectralFeatures.HopSize) + AudioSpectralFeatures.WindowSize) + / (double)AudioSpectralFeatures.SampleRate; + onScore(new AudioMatchScore(score, end)); + }, cancellationToken); + } + + private static float Dot(ReadOnlySpan left, ReadOnlySpan right) + { + Vector total = Vector.Zero; + int index = 0; + for (; index <= left.Length - Vector.Count; index += Vector.Count) + total += new Vector(left[index..]) * new Vector(right[index..]); + float sum = Vector.Sum(total); + for (; index < left.Length; index++) + sum += left[index] * right[index]; + return sum; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/AudioRecognition.cs b/src/EasyCon.Capture/Audio/AudioRecognition.cs new file mode 100644 index 00000000..7291c013 --- /dev/null +++ b/src/EasyCon.Capture/Audio/AudioRecognition.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using System.Runtime.ExceptionServices; + +namespace EasyCon.Capture.Audio; + +/// One loaded label: bounded background matching and a cumulative integer 0–100 peak. +public sealed class AudioRecognition : IDisposable +{ + private readonly AudioMatchMonitor _monitor; + private readonly CancellationTokenSource _cancellation; + private readonly Task _worker; + private ExceptionDispatchInfo? _failure; + private int _peak; + private int _disposed; + + public AudioRecognition(IAudioCaptureSource source, AudioMatchTemplate template, CancellationToken token) + : this(source, template, token, Stopwatch.GetTimestamp) + { + } + + internal AudioRecognition(IAudioCaptureSource source, AudioMatchTemplate template, CancellationToken token, Func clock) + { + token.ThrowIfCancellationRequested(); + _monitor = new AudioMatchMonitor(source, template, clock, requireContinuousSession: true); + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + // Always run finally, including when cancellation precedes task scheduling. + _worker = Task.Run(RecognizeAsync); + } + + public int Score + { + get + { + CheckHealth(); + return Volatile.Read(ref _peak); + } + } + + public void CheckHealth() + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + _cancellation.Token.ThrowIfCancellationRequested(); + Volatile.Read(ref _failure)?.Throw(); + _monitor.CheckHealth(); + } + + private async Task RecognizeAsync() + { + CancellationToken token = _cancellation.Token; + try + { + while (true) + { + AudioMatchUpdate update = _monitor.Poll(token); + if (update.Score.HasValue) + Volatile.Write(ref _peak, Math.Max(Volatile.Read(ref _peak), update.Score.Value)); + await Task.Delay(10, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception ex) + { + Volatile.Write(ref _failure, ExceptionDispatchInfo.Capture(ex)); + } + finally + { + _monitor.Dispose(); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _cancellation.Cancel(); + // Wait for cooperative FFT cancellation before allowing a reload to start a new generation. + _worker.GetAwaiter().GetResult(); + _cancellation.Dispose(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/SafeWasapiCapture.cs b/src/EasyCon.Capture/Audio/SafeWasapiCapture.cs new file mode 100644 index 00000000..5ce871d1 --- /dev/null +++ b/src/EasyCon.Capture/Audio/SafeWasapiCapture.cs @@ -0,0 +1,373 @@ +using NAudio.CoreAudioApi; +using NAudio.Wave; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; + +namespace EasyCon.Capture.Audio; + +// A capture owns one session. A reconnect creates a new instance and native client. +internal sealed class SafeWasapiCapture : IWaveIn +{ + private readonly object _lifecycleLock = new(); + private readonly IWasapiCaptureClient _client; + private readonly ManualResetEventSlim _stopSignal = new(false); + private WaveFormat _waveFormat; + private Thread? _captureThread; + private volatile bool _stopRequested; + private bool _recordingStarted; + private bool _resourcesReleased; + private bool _disposed; + + public SafeWasapiCapture(MMDevice device) + : this(new NativeWasapiCaptureClient(device)) + { + } + + internal SafeWasapiCapture(IWasapiCaptureClient client) + { + ArgumentNullException.ThrowIfNull(client); + _client = client; + try + { + _waveFormat = client.MixFormat; + ValidateWaveFormat(_waveFormat); + } + catch + { + ReleaseResources(false, null); + throw; + } + } + + public WaveFormat WaveFormat + { + get + { + lock (_lifecycleLock) + return _waveFormat.AsStandardWaveFormat(); + } + set + { + ArgumentNullException.ThrowIfNull(value); + ValidateWaveFormat(value); + lock (_lifecycleLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_recordingStarted) + throw new InvalidOperationException("The recording format cannot change after starting."); + + _waveFormat = value; + } + } + } + + public event EventHandler? DataAvailable; + public event EventHandler? RecordingStopped; + + public void StartRecording() + { + Exception? startException = null; + lock (_lifecycleLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_recordingStarted) + throw new InvalidOperationException("This capture session has already been started."); + + _recordingStarted = true; + _captureThread = new Thread(CaptureThread) + { + IsBackground = true, + Name = "EasyCon WASAPI capture", + }; + try + { + _captureThread.Start(); + } + catch (Exception ex) + { + _captureThread = null; + startException = ex; + } + } + + if (startException != null) + { + Exception? exception = ReleaseResources(false, startException); + PublishRecordingStopped(exception); + ExceptionDispatchInfo.Capture(startException).Throw(); + } + } + + public void StopRecording() + { + lock (_lifecycleLock) + { + if (_recordingStarted) + RequestStopLocked(); + } + } + + public void Dispose() + { + Thread? captureThread; + lock (_lifecycleLock) + { + _disposed = true; + RequestStopLocked(); + captureThread = _captureThread; + } + + if (captureThread != null) + { + // A DataAvailable/RecordingStopped observer may dispose on this thread. + // Its return lets the thread finish cleanup, without joining itself. + if (captureThread != Thread.CurrentThread) + captureThread.Join(); + } + else + { + ReleaseResources(false, null); + } + } + + private void CaptureThread() + { + Exception? exception = null; + bool initialized = false; + try + { + if (!_stopRequested) + { + _client.Initialize(_waveFormat); + initialized = true; + int bufferFrameCount = _client.BufferFrameCount; + if (bufferFrameCount <= 0) + throw new InvalidOperationException("The audio endpoint returned an empty capture buffer."); + + int pollMilliseconds = (int)Math.Clamp((long)bufferFrameCount * 1000 / _waveFormat.SampleRate / 2, 1, 50); + if (!_stopRequested) + { + _client.Start(); + while (!_stopRequested && !_stopSignal.Wait(pollMilliseconds)) + ReadAvailablePackets(_waveFormat.BlockAlign); + } + } + } + catch (Exception ex) + { + exception = ex; + } + finally + { + // Stop can fail after Windows audio service shutdown, even after a read + // failure was caught. Each cleanup stage must still be attempted. + exception = ReleaseResources(initialized, exception); + PublishRecordingStopped(exception); + lock (_lifecycleLock) + _captureThread = null; + } + } + + private void ReadAvailablePackets(int bytesPerFrame) + { + while (!_stopRequested && _client.GetNextPacketSize() > 0) + { + IntPtr pointer = _client.GetBuffer(out int framesAvailable, out AudioClientBufferFlags flags); + byte[]? packet = null; + Exception? exception = null; + try + { + if (framesAvailable < 0) + throw new InvalidOperationException("The audio endpoint returned a negative frame count."); + + if (framesAvailable > 0) + { + // Preserve every whole frame, including packets larger than the + // nominal endpoint buffer. A Silent packet may have a null pointer. + packet = new byte[checked(framesAvailable * bytesPerFrame)]; + if ((flags & AudioClientBufferFlags.Silent) == 0) + { + if (pointer == IntPtr.Zero) + throw new InvalidOperationException("The audio endpoint returned a null data buffer."); + + Marshal.Copy(pointer, packet, 0, packet.Length); + } + else if (_waveFormat.BitsPerSample == 8) + { + // PCM8 is unsigned: silence is the midpoint, not byte zero. + Array.Fill(packet, (byte)0x80); + } + } + } + catch (Exception ex) + { + exception = ex; + } + + try + { + _client.ReleaseBuffer(framesAvailable); + } + catch (Exception ex) + { + exception = CombineExceptions(exception, ex); + } + + if (exception != null) + ExceptionDispatchInfo.Capture(exception).Throw(); + + if (packet != null) + PublishDataAvailable(packet); + else + break; + } + } + + private Exception? ReleaseResources(bool initialized, Exception? exception) + { + lock (_lifecycleLock) + { + if (_resourcesReleased) + return exception; + + _resourcesReleased = true; + _stopSignal.Dispose(); + if (initialized) + { + try + { + _client.Stop(); + } + catch (Exception ex) + { + exception = CombineExceptions(exception, ex); + } + } + + try + { + _client.Dispose(); + } + catch (Exception ex) + { + exception = CombineExceptions(exception, ex); + } + + return exception; + } + } + + private void RequestStopLocked() + { + _stopRequested = true; + if (!_resourcesReleased) + _stopSignal.Set(); + } + + private void PublishDataAvailable(byte[] packet) + { + EventHandler? handlers = DataAvailable; + if (handlers == null) + return; + + WaveInEventArgs args = new(packet, packet.Length); + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, args); + } + catch + { + // An observer cannot terminate the native capture thread. + } + } + } + + private void PublishRecordingStopped(Exception? exception) + { + EventHandler? handlers = RecordingStopped; + if (handlers == null) + return; + + StoppedEventArgs args = new(exception); + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, args); + } + catch + { + // Cleanup and other observers must survive a faulty stop handler. + } + } + } + + private static Exception CombineExceptions(Exception? first, Exception next) + { + return first == null ? next : new AggregateException(first, next); + } + + private static void ValidateWaveFormat(WaveFormat format) + { + WaveFormat standard = format.AsStandardWaveFormat(); + if (standard.Encoding is not (WaveFormatEncoding.Pcm or WaveFormatEncoding.IeeeFloat) + || standard.Channels <= 0 || standard.SampleRate <= 0 + || standard.BitsPerSample is not (8 or 16 or 24 or 32) + || (standard.Encoding == WaveFormatEncoding.IeeeFloat && standard.BitsPerSample != 32) + || format.BlockAlign != standard.Channels * (standard.BitsPerSample / 8)) + { + throw new NotSupportedException("The audio endpoint returned an unsupported capture format."); + } + } +} + +internal interface IWasapiCaptureClient : IDisposable +{ + WaveFormat MixFormat { get; } + int BufferFrameCount { get; } + void Initialize(WaveFormat format); + void Start(); + void Stop(); + int GetNextPacketSize(); + IntPtr GetBuffer(out int framesAvailable, out AudioClientBufferFlags flags); + void ReleaseBuffer(int framesAvailable); +} + +internal sealed class NativeWasapiCaptureClient : IWasapiCaptureClient +{ + private readonly AudioClient _audioClient; + private AudioCaptureClient? _captureClient; + + public NativeWasapiCaptureClient(MMDevice device) + { + ArgumentNullException.ThrowIfNull(device); + _audioClient = device.AudioClient; + } + + public WaveFormat MixFormat => _audioClient.MixFormat; + public int BufferFrameCount => _audioClient.BufferSize; + + public void Initialize(WaveFormat format) + { + _audioClient.Initialize(AudioClientShareMode.Shared, + AudioClientStreamFlags.AutoConvertPcm | AudioClientStreamFlags.SrcDefaultQuality, + 1_000_000, 0, format, Guid.Empty); + _captureClient = _audioClient.AudioCaptureClient; + } + + public void Start() => _audioClient.Start(); + public void Stop() => _audioClient.Stop(); + public int GetNextPacketSize() => _captureClient!.GetNextPacketSize(); + + public IntPtr GetBuffer(out int framesAvailable, out AudioClientBufferFlags flags) + { + return _captureClient!.GetBuffer(out framesAvailable, out flags); + } + + public void ReleaseBuffer(int framesAvailable) => _captureClient!.ReleaseBuffer(framesAvailable); + + // AudioClient owns and releases its AudioCaptureClient service as well. + public void Dispose() => _audioClient.Dispose(); +} \ No newline at end of file diff --git a/src/EasyCon.Capture/Audio/WasapiAudioCapture.cs b/src/EasyCon.Capture/Audio/WasapiAudioCapture.cs new file mode 100644 index 00000000..530e9705 --- /dev/null +++ b/src/EasyCon.Capture/Audio/WasapiAudioCapture.cs @@ -0,0 +1,528 @@ +using NAudio.CoreAudioApi; +using NAudio.Wave; +using System.Diagnostics; + +namespace EasyCon.Capture.Audio; + +internal sealed record AudioCaptureSession(AudioDeviceInfo Device, IWaveIn Capture, IDisposable? NativeDevice = null); + +public sealed class WasapiAudioCapture : IAudioCaptureSource +{ + [ThreadStatic] + private static WasapiAudioCapture? _callbackSource; + + private readonly object _lifecycleLock = new(); + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly Func _openCapture; + private readonly Queue _pendingStates = new(); + private IWaveIn? _capture; + private IDisposable? _nativeDevice; + private AudioDeviceInfo? _device; + private AudioFormat? _format; + private CancellationToken _captureCancellation; + private long _connectionVersion; + private long _captureVersion; + private bool _connectedAnnounced; + private bool _publishingStates; + private bool _disposed; + private AudioCaptureState _state = new(AudioCaptureStatus.Disconnected); + + public WasapiAudioCapture() : this(OpenCapture, OperatingSystem.IsWindows()) + { + } + + internal WasapiAudioCapture(Func openCapture, bool isSupported = true) + { + _openCapture = openCapture; + IsSupported = isSupported; + } + + public bool IsSupported { get; } + + public AudioCaptureState State + { + get + { + lock (_lifecycleLock) + return _state; + } + } + + public event Action? StateChanged; + + public event Action? LevelChanged; + + public event Action? DataAvailable; + + public IReadOnlyList GetAvailableDevices() + { + if (!IsSupported) + return []; + + try + { + using MMDeviceEnumerator enumerator = new(); + MMDeviceCollection devices = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active); + List result = new(devices.Count); + foreach (MMDevice device in devices) + { + using (device) + result.Add(new AudioDeviceInfo(device.ID, device.FriendlyName)); + } + + return result; + } + catch + { + return []; + } + } + + public bool TryConnect(string deviceId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(deviceId)) + throw new ArgumentException("请选择音频输入设备。", nameof(deviceId)); + + if (ReferenceEquals(_callbackSource, this)) + throw new InvalidOperationException("请在音频数据回调结束后异步启动连接。"); + + try + { + _operationGate.Wait(cancellationToken); + } + catch (OperationCanceledException) + { + return false; + } + + try + { + return TryConnectCore(deviceId, cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + public Task DisconnectAsync() + { + return Task.Run(DisconnectCore); + } + + public Task DisconnectIfConnectingAsync() + { + IWaveIn? capture; + lock (_lifecycleLock) + { + if (_disposed || _state.Status != AudioCaptureStatus.Connecting) + return Task.FromResult(false); + + // Invalidate opening and callbacks immediately; native cleanup may have to wait for open. + _connectionVersion++; + capture = _capture; + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Disconnected)); + } + + return Task.Run(() => + { + _operationGate.Wait(); + try + { + if (capture != null) + StopCurrentCapture(capture); + return true; + } + finally + { + _operationGate.Release(); + } + }); + } + + public void Dispose() + { + lock (_lifecycleLock) + { + if (_disposed) + return; + + _disposed = true; + _connectionVersion++; + _state = new AudioCaptureState(AudioCaptureStatus.Disconnected); + } + + // A concurrent stop may be joining this callback. Never wait for its gate here. + if (ReferenceEquals(_callbackSource, this) || !_operationGate.Wait(0)) + { + _ = Task.Run(() => DisposeResources()); + return; + } + + DisposeResources(gateAcquired: true); + } + + private void DisposeResources(bool gateAcquired = false) + { + if (!gateAcquired) + _operationGate.Wait(); + try + { + StopCurrentCapture(); + } + finally + { + _operationGate.Release(); + } + } + + private bool TryConnectCore(string deviceId, CancellationToken cancellationToken) + { + StopCurrentCapture(); + long version; + lock (_lifecycleLock) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) + return false; + + version = ++_connectionVersion; + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Connecting)); + } + + AudioCaptureSession? session = null; + bool installed = false; + try + { + if (!IsSupported) + throw new NotSupportedException("当前平台不支持 WASAPI 音频采集。"); + + session = _openCapture(deviceId); + AudioFormat format = ToAudioFormat(session.Capture.WaveFormat); + lock (_lifecycleLock) + { + ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + if (version != _connectionVersion) + throw new OperationCanceledException(); + + _device = session.Device; + _format = format; + _connectedAnnounced = false; + _captureCancellation = cancellationToken; + _captureVersion = version; + _nativeDevice = session.NativeDevice; + _capture = session.Capture; + session.Capture.DataAvailable += OnDataAvailable; + session.Capture.RecordingStopped += OnRecordingStopped; + installed = true; + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Connecting, session.Device, format)); + } + + session.Capture.StartRecording(); + return true; + } + catch (Exception ex) + { + if (installed) + StopCurrentCapture(session!.Capture); + else if (session != null) + DisposeCapture(session.Capture, session.NativeDevice); + + lock (_lifecycleLock) + { + if (!_disposed && version == _connectionVersion) + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Failed, Error: $"打开音频输入失败:{ex.Message}")); + } + return false; + } + } + + private void DisconnectCore() + { + _operationGate.Wait(); + try + { + lock (_lifecycleLock) + { + if (_disposed) + return; + _connectionVersion++; + } + + StopCurrentCapture(); + lock (_lifecycleLock) + { + if (!_disposed) + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Disconnected)); + } + } + finally + { + _operationGate.Release(); + } + } + + private void OnDataAvailable(object? sender, WaveInEventArgs args) + { + if (sender is not IWaveIn capture) + return; + + WasapiAudioCapture? previous = _callbackSource; + _callbackSource = this; + try + { + PublishAudioData(capture, args); + } + finally + { + _callbackSource = previous; + } + } + + private void PublishAudioData(IWaveIn capture, WaveInEventArgs args) + { + AudioFormat format; + AudioCaptureState state; + int bytesRecorded = Math.Min(args.BytesRecorded, args.Buffer.Length); + lock (_lifecycleLock) + { + if (!IsCurrentCaptureLocked(capture) || _format == null || bytesRecorded <= 0) + return; + + format = _format; + if (!_connectedAnnounced) + { + _connectedAnnounced = true; + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Connected, _device, format)); + } + state = _state; + } + + byte[] pcm = new byte[bytesRecorded]; + Buffer.BlockCopy(args.Buffer, 0, pcm, 0, bytesRecorded); + AudioLevel level = AudioLevelCalculator.Calculate(pcm, format); + AudioChunk chunk = new(ToNanoseconds(Stopwatch.GetTimestamp()), format, pcm, pcm.Length) { Session = state }; + if (!IsCurrentCapture(capture)) + return; + + PublishDataAvailable(chunk); + if (IsCurrentCapture(capture)) + PublishLevelChanged(level); + } + + private void OnRecordingStopped(object? sender, StoppedEventArgs args) + { + if (sender is not IWaveIn capture) + return; + + lock (_lifecycleLock) + { + if (!IsCurrentCaptureLocked(capture)) + return; + + string message = args.Exception?.Message ?? "音频输入已停止。"; + SetStateLocked(new AudioCaptureState(AudioCaptureStatus.Failed, _device, _format, message)); + } + + // This may run on the capture thread; disposal must not join that same thread. + _ = Task.Run(() => StopCurrentCapture(capture)); + } + + private void StopCurrentCapture(IWaveIn? expectedCapture = null) + { + IWaveIn? capture; + IDisposable? nativeDevice; + lock (_lifecycleLock) + { + if (expectedCapture != null && !ReferenceEquals(expectedCapture, _capture)) + return; + + capture = _capture; + nativeDevice = _nativeDevice; + _capture = null; + _nativeDevice = null; + _device = null; + _format = null; + _connectedAnnounced = false; + + if (capture != null) + { + capture.DataAvailable -= OnDataAvailable; + capture.RecordingStopped -= OnRecordingStopped; + } + } + + DisposeCapture(capture, nativeDevice); + } + + private static void DisposeCapture(IWaveIn? capture, IDisposable? nativeDevice) + { + try + { + capture?.StopRecording(); + } + catch + { + // Cleanup must also succeed after Windows invalidates an endpoint. + } + + try + { + capture?.Dispose(); + } + catch + { + // A failed native cleanup must not prevent releasing the endpoint. + } + + try + { + nativeDevice?.Dispose(); + } + catch + { + // The native endpoint may already have been released by Windows. + } + } + + private bool IsCurrentCapture(IWaveIn capture) + { + lock (_lifecycleLock) + return IsCurrentCaptureLocked(capture); + } + + private bool IsCurrentCaptureLocked(IWaveIn capture) + { + return !_disposed && ReferenceEquals(capture, _capture) + && _captureVersion == _connectionVersion && !_captureCancellation.IsCancellationRequested; + } + + private void SetStateLocked(AudioCaptureState state) + { + _state = state; + _pendingStates.Enqueue(state); + if (_publishingStates) + return; + + _publishingStates = true; + _ = Task.Run(PublishPendingStates); + } + + private void PublishPendingStates() + { + while (true) + { + AudioCaptureState state; + lock (_lifecycleLock) + { + if (!_pendingStates.TryDequeue(out state!)) + { + _publishingStates = false; + return; + } + if (_disposed && state.Status != AudioCaptureStatus.Disconnected) + continue; + } + + Action? handlers = StateChanged; + if (handlers == null) + continue; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(state); + } + catch + { + // Notifications run in order, outside lifecycle gates and the audio callback. + } + } + } + } + + private void PublishDataAvailable(AudioChunk chunk) + { + Action? handlers = DataAvailable; + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(chunk); + } + catch + { + // A consumer cannot be allowed to stop the audio callback thread. + } + } + } + + private void PublishLevelChanged(AudioLevel level) + { + Action? handlers = LevelChanged; + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(level); + } + catch + { + // A consumer cannot be allowed to stop the audio callback thread. + } + } + } + + private static AudioCaptureSession OpenCapture(string deviceId) + { + using MMDeviceEnumerator enumerator = new(); + MMDevice device = enumerator.GetDevice(deviceId); + try + { + return new AudioCaptureSession(new AudioDeviceInfo(device.ID, device.FriendlyName), new SafeWasapiCapture(device), device); + } + catch + { + device.Dispose(); + throw; + } + } + + private static AudioFormat ToAudioFormat(WaveFormat format) + { + string encoding = format.Encoding switch + { + WaveFormatEncoding.IeeeFloat => "float", + WaveFormatEncoding.Pcm => "pcm", + _ => throw new NotSupportedException($"不支持的音频编码:{format.Encoding}。"), + }; + + if (format.Channels <= 0 || format.SampleRate <= 0 || format.BitsPerSample is not (8 or 16 or 24 or 32)) + throw new NotSupportedException("音频设备返回了不支持的采样格式。"); + if (encoding == "float" && format.BitsPerSample != 32) + throw new NotSupportedException("浮点音频必须使用 32 bit 采样。"); + + return new AudioFormat(format.SampleRate, format.Channels, format.BitsPerSample, encoding); + } + + private static long ToNanoseconds(long timestamp) + { + long seconds = timestamp / Stopwatch.Frequency; + long remainder = timestamp % Stopwatch.Frequency; + return checked(seconds * 1_000_000_000L + remainder * 1_000_000_000L / Stopwatch.Frequency); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(WasapiAudioCapture)); + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/CVCapture.cs b/src/EasyCon.Capture/CVCapture.cs index dd4cc54a..835d118c 100644 --- a/src/EasyCon.Capture/CVCapture.cs +++ b/src/EasyCon.Capture/CVCapture.cs @@ -1,9 +1,10 @@ -using OpenCvSharp; +using EasyCon.Capture.mac; +using OpenCvSharp; using System.Diagnostics; namespace EasyCon.Capture; -public class OpenCVCapture(int idx = 0, VideoCaptureAPIs apiRefs = VideoCaptureAPIs.ANY) : IDisposable +public class OpenCVCapture(int idx = 0, VideoCaptureAPIs apiRefs = VideoCaptureAPIs.ANY) : IFrameSource { private readonly VideoCapture videoCapture = new(); @@ -11,12 +12,16 @@ public class OpenCVCapture(int idx = 0, VideoCaptureAPIs apiRefs = VideoCaptureA private VideoCaptureAPIs refs = apiRefs; public bool IsOpened => videoCapture.IsOpened(); + public VideoCaptureAPIs Backend => (VideoCaptureAPIs)(int)videoCapture.Get(VideoCaptureProperties.Backend); public bool Open(int idx, int apiRefs = 0) { deviceId = idx; refs = (VideoCaptureAPIs)apiRefs; - return videoCapture.Open(deviceId, refs); + bool opened = videoCapture.Open(deviceId, refs); + if (!opened && OperatingSystem.IsMacOS() && CaptureSourceCatalog.ResolveBackend(refs) == VideoCaptureAPIs.AVFOUNDATION) + AvFoundationDevices.ThrowIfAccessUnavailable(); + return opened; } public void SetProperties(int x, int y) diff --git a/src/EasyCon.Capture/Capture.cs b/src/EasyCon.Capture/Capture.cs index 9db0ad06..bd03c69a 100644 --- a/src/EasyCon.Capture/Capture.cs +++ b/src/EasyCon.Capture/Capture.cs @@ -1,4 +1,4 @@ -using EasyCon.Capture.win; +using EasyCon.Capture.win; using FlashCap; using OpenCvSharp; using System.Diagnostics; @@ -9,6 +9,10 @@ public sealed class ECCapture { public static IEnumerable<(string name, int index)> GetCaptureCamera() { + if (OperatingSystem.IsMacOS()) + { + return CaptureSourceCatalog.Enumerate(VideoCaptureAPIs.AVFOUNDATION).Select(source => (source.DisplayKey, source.Index)); + } if (OperatingSystem.IsWindows()) { var filters = DirectShow.GetFiltes(DirectShow.DsGuid.CLSID_VideoInputDeviceCategory); diff --git a/src/EasyCon.Capture/CaptureSourceCatalog.cs b/src/EasyCon.Capture/CaptureSourceCatalog.cs new file mode 100644 index 00000000..140dbad9 --- /dev/null +++ b/src/EasyCon.Capture/CaptureSourceCatalog.cs @@ -0,0 +1,83 @@ +#nullable enable +using EasyCon.Capture.mac; +using EasyCon.Capture.win; +using OpenCvSharp; +using System.Security.Cryptography; +using System.Text; + +namespace EasyCon.Capture; + +public sealed record CaptureSourceDescriptor(string Id, string Name, int Index, VideoCaptureAPIs Backend, + bool Persistent, string EnumerationRevision) +{ + public string DisplayKey => $"{Name} [{Backend}/{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Id)))[..8]}]"; +} + +/// 来源与后端一起枚举。不能确认对应关系的后端明确失败,不套用其他后端的索引。 +public static class CaptureSourceCatalog +{ + private static readonly string EnumerationSession = Guid.NewGuid().ToString("N"); + + public static IReadOnlyList GetBackendOptions() + => OperatingSystem.IsMacOS() ? ["ANY", "AVFOUNDATION"] + : OperatingSystem.IsLinux() ? ["ANY", "V4L2"] + : ["ANY", "DSHOW", "MSMF", "DC1394"]; + + public static string NormalizeBackendName(string? name) + => GetBackendOptions().FirstOrDefault(option => string.Equals(option, name, StringComparison.OrdinalIgnoreCase)) ?? "ANY"; + + public static VideoCaptureAPIs ResolveBackend(VideoCaptureAPIs requested) + => requested != VideoCaptureAPIs.ANY ? requested + : OperatingSystem.IsWindows() ? VideoCaptureAPIs.DSHOW + : OperatingSystem.IsLinux() ? VideoCaptureAPIs.V4L2 : VideoCaptureAPIs.AVFOUNDATION; + + public static IReadOnlyList Enumerate(VideoCaptureAPIs backend) + { + backend = ResolveBackend(backend); + if (OperatingSystem.IsWindows() && backend == VideoCaptureAPIs.DSHOW) + { + IReadOnlyList<(string Name, string? DevicePath)> devices = DirectShow.GetDevices(); + return Describe(devices.Select((d, index) => (d.Name, d.DevicePath, index)).ToArray(), backend); + } + if (OperatingSystem.IsMacOS() && backend == VideoCaptureAPIs.AVFOUNDATION) + { + return Describe(AvFoundationDevices.Enumerate(), backend); + } + if (OperatingSystem.IsLinux() && backend == VideoCaptureAPIs.V4L2) + { + List<(string, string?, int)> devices = new(); + if (!Directory.Exists("/sys/class/video4linux")) return []; + foreach (string path in Directory.GetDirectories("/sys/class/video4linux").Order(StringComparer.Ordinal)) + { + string file = Path.GetFileName(path); + if (!int.TryParse(file.AsSpan(5), out int index)) continue; + string name = File.ReadAllText(Path.Combine(path, "name")).Trim(); + string? stable = Directory.Exists("/dev/v4l/by-id") + ? Directory.EnumerateFiles("/dev/v4l/by-id").FirstOrDefault(p => + new FileInfo(p).ResolveLinkTarget(true)?.FullName == $"/dev/video{index}") : null; + devices.Add((name, stable, index)); + } + return Describe(devices, backend); + } + throw new NotSupportedException($"{backend} 尚无可验证的来源枚举;请使用支持的后端,或 CLI 显式指定该后端的临时设备序号"); + } + + internal static IReadOnlyList Describe( + IReadOnlyList<(string Name, string? StableId, int Index)> devices, VideoCaptureAPIs backend) + { + string revision = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + string.Join('\n', devices.Select(d => $"{d.Index}:{d.Name}:{d.StableId}"))))); + return devices.Select(d => new CaptureSourceDescriptor( + string.IsNullOrEmpty(d.StableId) ? $"temporary:{EnumerationSession}:{backend}:{revision}:{d.Index}" : $"{backend}:{d.StableId}", + d.Name, d.Index, backend, !string.IsNullOrEmpty(d.StableId), revision)).ToArray(); + } + + public static CaptureSourceDescriptor Resolve(CaptureSourceDescriptor selection, + IReadOnlyList current) + { + CaptureSourceDescriptor[] matches = current.Where(d => d.Id == selection.Id && d.Backend == selection.Backend).ToArray(); + if (matches.Length != 1 || (!selection.Persistent && matches[0].EnumerationRevision != selection.EnumerationRevision)) + throw new InvalidOperationException("所选视频来源已消失或身份不再确定,请重新选择;不会回退其他来源"); + return matches[0]; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/EasyCon.Capture.csproj b/src/EasyCon.Capture/EasyCon.Capture.csproj index 6660534e..eb7892bd 100644 --- a/src/EasyCon.Capture/EasyCon.Capture.csproj +++ b/src/EasyCon.Capture/EasyCon.Capture.csproj @@ -1,11 +1,16 @@ - + true + + + + + diff --git a/src/EasyCon.Capture/FrameMetadata.cs b/src/EasyCon.Capture/FrameMetadata.cs new file mode 100644 index 00000000..9e10c8cd --- /dev/null +++ b/src/EasyCon.Capture/FrameMetadata.cs @@ -0,0 +1,30 @@ +using OpenCvSharp; +using System.Diagnostics; + +namespace EasyCon.Capture; + +/// 与像素一起发布的帧身份。时间表示宿主收到帧的单调时间,不是硬件曝光时间。 +public sealed record FrameMetadata( + string SourceId, + long SourceEpoch, + long FrameId, + long CapturedAtTicks, + long ClockFrequency, + Size OriginalSize, + Size CanonicalSize, + string NormalizationId, + string PixelFormat = "BGR8") +{ + public static FrameMetadata FromImage(Mat image) => new("image", 0, 0, + Stopwatch.GetTimestamp(), Stopwatch.Frequency, image.Size(), image.Size(), "identity", + image.Type() == MatType.CV_8UC1 ? "Gray8" : image.Type() == MatType.CV_8UC3 ? "BGR8" + : image.Type() == MatType.CV_8UC4 ? "BGRA8" : image.Type().ToString()); +} + +/// 采集端口;通用帧生产者不依赖具体摄像头,也不依赖 Maa。 +public interface IFrameSource : IDisposable +{ + bool IsOpened { get; } + Mat GetMatFrame(); + void SetProperties(int width, int height); +} \ No newline at end of file diff --git a/src/EasyCon.Capture/FrameNormalization.cs b/src/EasyCon.Capture/FrameNormalization.cs new file mode 100644 index 00000000..59c9a6af --- /dev/null +++ b/src/EasyCon.Capture/FrameNormalization.cs @@ -0,0 +1,78 @@ +using OpenCvSharp; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EasyCon.Capture; + +/// 明确的采集变换。非 16:9 输入需要配置裁剪,不隐式拉伸或猜测黑边。 +public sealed record FrameNormalization([property: JsonConverter(typeof(FrameCropJsonConverter))] Rect? Crop = null, int Rotation = 0, + InterpolationFlags Interpolation = InterpolationFlags.Nearest) +{ + public const int Width = 1920; + public const int Height = 1080; + public string Id => FormattableString.Invariant($"bgr8-1080p-v1:{Crop?.X},{Crop?.Y},{Crop?.Width},{Crop?.Height}:{Rotation}:{(int)Interpolation}"); + + public Mat Apply(Mat input) + { + if (input.Empty()) throw new ArgumentException("采集帧为空", nameof(input)); + if (Rotation is not (0 or 90 or 180 or 270)) + throw new ArgumentException("旋转只支持 0/90/180/270 度"); + Rect crop = Crop ?? new Rect(0, 0, input.Width, input.Height); + if (crop.X < 0 || crop.Y < 0 || crop.Width <= 0 || crop.Height <= 0 || + (long)crop.X + crop.Width > input.Width || (long)crop.Y + crop.Height > input.Height) + throw new ArgumentException("采集裁剪范围超出输入画面"); + using Mat region = new(input, crop); + using Mat converted = new(); + Mat bgr = region; + if (input.Type() == MatType.CV_8UC3) { } + else if (input.Type() == MatType.CV_8UC1) { Cv2.CvtColor(region, converted, ColorConversionCodes.GRAY2BGR); bgr = converted; } + else if (input.Type() == MatType.CV_8UC4) { Cv2.CvtColor(region, converted, ColorConversionCodes.BGRA2BGR); bgr = converted; } + else throw new ArgumentException("采集只接受 8 位灰度、BGR 或 BGRA 帧"); + using Mat rotated = new(); + if (Rotation != 0) Cv2.Rotate(bgr, rotated, Rotation switch + { + 90 => RotateFlags.Rotate90Clockwise, + 180 => RotateFlags.Rotate180, + _ => RotateFlags.Rotate90Counterclockwise, + }); + Mat canonical = Rotation == 0 ? bgr : rotated; + if ((long)canonical.Width * Height != (long)canonical.Height * Width) + throw new ArgumentException("采集画面不是 16:9,请明确配置裁剪或旋转"); + if (canonical.Width == Width && canonical.Height == Height) return canonical.Clone(); + Mat result = new(); + try + { + Cv2.Resize(canonical, result, new Size(Width, Height), 0, 0, Interpolation); + return result; + } + catch + { + result.Dispose(); + throw; + } + } +} + +/// OpenCvSharp.Rect 使用字段,配置显式序列化,避免默认 JSON 丢失裁剪坐标。 +public sealed class FrameCropJsonConverter : JsonConverter +{ + public override Rect? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement value = document.RootElement; + int Get(string camel, string pascal) => (value.TryGetProperty(camel, out JsonElement field) ? field : value.GetProperty(pascal)).GetInt32(); + return new Rect(Get("x", "X"), Get("y", "Y"), Get("width", "Width"), Get("height", "Height")); + } + + public override void Write(Utf8JsonWriter writer, Rect? value, JsonSerializerOptions options) + { + if (value == null) { writer.WriteNullValue(); return; } + writer.WriteStartObject(); + writer.WriteNumber("x", value.Value.X); + writer.WriteNumber("y", value.Value.Y); + writer.WriteNumber("width", value.Value.Width); + writer.WriteNumber("height", value.Value.Height); + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/FrameProducer.cs b/src/EasyCon.Capture/FrameProducer.cs index c7977352..9e639e9e 100644 --- a/src/EasyCon.Capture/FrameProducer.cs +++ b/src/EasyCon.Capture/FrameProducer.cs @@ -1,116 +1,146 @@ -using OpenCvSharp; +using OpenCvSharp; using System.Diagnostics; namespace EasyCon.Capture; -/// -/// 单生产者采集循环:后台 Task 持续 Read 最新帧并发布到 FrameStore。 -/// 由 OpenCVCapture 的阻塞 Read 自然控制节奏,无需 Thread.Sleep。 -/// +/// 单生产者。停止超时仍保留任务身份,拒绝重启,迟到的 Read 不再发布。 public sealed class FrameProducer : IDisposable { - private readonly OpenCVCapture _capture; - private readonly FrameStore _store = new(); + private readonly IFrameSource _capture; + private readonly FrameStore _store; + private readonly FrameNormalization _normalization; + private readonly string _sourceId; private readonly object _lifecycleLock = new(); private CancellationTokenSource? _cts; private Task? _loopTask; + private bool _disposed; + private string? _lastError; + private bool _sourceOpened; - public FrameProducer(OpenCVCapture capture) + public FrameProducer(IFrameSource capture, FrameStore? store = null, + FrameNormalization? normalization = null, string sourceId = "camera") { _capture = capture; + _store = store ?? new FrameStore(); + _normalization = normalization ?? new FrameNormalization(); + _sourceId = sourceId; + _sourceOpened = capture.IsOpened; } public FrameStore Store => _store; - - public bool IsOpened => _capture.IsOpened; - - /// 转发采集参数设置(分辨率、FourCC、FPS)。 - public void SetProperties(int width, int height) - { - _capture.SetProperties(width, height); - } + public long FrameCount => _store.FrameCount; + public long SourceEpoch { get; private set; } + public bool IsOpened => Volatile.Read(ref _sourceOpened); + public bool IsStopping => _cts?.IsCancellationRequested == true && _loopTask?.IsCompleted == false; + public string? LastError => Volatile.Read(ref _lastError); + public void SetProperties(int width, int height) => _capture.SetProperties(width, height); public void Start() { lock (_lifecycleLock) { - if (_cts != null) return; + ObjectDisposedException.ThrowIf(_disposed, this); + if (_loopTask?.IsCompleted == false) + { + if (_cts!.IsCancellationRequested) throw new InvalidOperationException("上一采集循环仍在停止中"); + return; + } + _cts?.Dispose(); _cts = new CancellationTokenSource(); - var ct = _cts.Token; - _loopTask = Task.Run(() => Loop(ct)); + CancellationToken token = _cts.Token; + long epoch = SourceEpoch = _store.BeginSource(); + // 相机 Read 是长期阻塞调用;不占用运行、帧通知和 UI 工作所需的线程池。 + _loopTask = Task.Factory.StartNew(() => Loop(token, epoch), CancellationToken.None, + TaskCreationOptions.LongRunning, TaskScheduler.Default); } } - private void Loop(CancellationToken ct) + private void Loop(CancellationToken token, long epoch) { - while (!ct.IsCancellationRequested) + while (!token.IsCancellationRequested) { try { - // 设备未打开(未连接/已断开)时阻塞 Read 不再生效,需主动降频避免空转。 - if (!_capture.IsOpened) + bool opened = _capture.IsOpened; + Volatile.Write(ref _sourceOpened, opened); + if (!opened) { - Thread.Sleep(100); + token.WaitHandle.WaitOne(100); continue; } - - var mat = _capture.GetMatFrame(); - if (mat.Empty()) + using Mat raw = _capture.GetMatFrame(); + long arrival = Stopwatch.GetTimestamp(); + if (token.IsCancellationRequested) break; + if (raw.Empty()) { - mat.Dispose(); - Thread.Sleep(5); // 尚无新帧,短暂让出 CPU + token.WaitHandle.WaitOne(5); continue; } - _store.Publish(mat); + Mat? normalized = _normalization.Apply(raw); + try + { + FrameMetadata metadata = new(_sourceId, epoch, 0, arrival, Stopwatch.Frequency, + raw.Size(), normalized.Size(), _normalization.Id); + lock (_lifecycleLock) + { + if (token.IsCancellationRequested) break; + _store.Publish(normalized, metadata); + normalized = null; + Volatile.Write(ref _lastError, null); + } + } + finally { normalized?.Dispose(); } } catch (Exception ex) { - Debug.WriteLine($"FrameProducer 采集异常: {ex.Message}"); - Thread.Sleep(5); + Volatile.Write(ref _lastError, ex.Message); + Debug.WriteLine($"FrameProducer: {ex.Message}"); + token.WaitHandle.WaitOne(20); } } } - public void Stop() + public bool Stop(TimeSpan? timeout = null) { + Task? loop; lock (_lifecycleLock) { - _cts?.Cancel(); - _cts = null; - } - - var loop = _loopTask; - if (loop != null) - { - try + if (_loopTask?.IsCompleted != false) { - loop.Wait(TimeSpan.FromSeconds(5)); - } - catch (AggregateException) - { - // 采集异常已在 Loop 内部捕获,这里兜底忽略 + _store.ReleaseCurrent(); + return true; } + _cts?.Cancel(); + loop = _loopTask; + _store.ReleaseCurrent(); } - _loopTask = null; - _store.ReleaseCurrent(); + if (loop == null) return true; + try { return loop.Wait(timeout ?? TimeSpan.FromSeconds(5)); } + catch (AggregateException) { return loop.IsCompleted; } } public void Dispose() { - // 先记录循环任务,再 Stop(Stop 会把 _loopTask 置 null)。 - // 若采集循环卡在阻塞 Read 上(设备停滞但 IsOpened 仍为 true), - // Stop 等待超时后循环仍在运行,此时释放底层 VideoCapture 属于 use-after-release, - // 可能崩溃或挂起,故放弃释放(native 句柄交由进程退出兜底)。 - var loop = _loopTask; - Stop(); - - if (loop == null || loop.IsCompleted) + lock (_lifecycleLock) { + if (_disposed) return; + _disposed = true; + } + if (Stop()) + { + Volatile.Write(ref _sourceOpened, false); _capture.Dispose(); + _cts?.Dispose(); } else { - Debug.WriteLine("FrameProducer: 采集循环未在超时内退出,跳过 VideoCapture 释放"); + // 阻塞 Read 的所有者保留到实际退出,不能释放它正在使用的 native 对象。 + _ = _loopTask!.ContinueWith(_ => + { + Volatile.Write(ref _sourceOpened, false); + _capture.Dispose(); + _cts?.Dispose(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } } \ No newline at end of file diff --git a/src/EasyCon.Capture/FrameStore.cs b/src/EasyCon.Capture/FrameStore.cs index 18d997c1..9eb42a75 100644 --- a/src/EasyCon.Capture/FrameStore.cs +++ b/src/EasyCon.Capture/FrameStore.cs @@ -1,32 +1,91 @@ -using OpenCvSharp; +using OpenCvSharp; namespace EasyCon.Capture; /// /// 单生产者最新帧存储。生产者线程 Publish 最新帧,消费方 AcquireLatest 获取租约。 -/// 热路径无锁:Interlocked.Exchange 发布,Interlocked 引用计数。 +/// AcquireLatest 无锁;发布与新帧通知在同一短锁内完成,租约使用原子引用计数。 /// 每个 Mat 恰好 Dispose 一次:引用计数归零的线程负责释放。 /// 引用计数把「当前帧指针」本身计为一次引用,因此仍被 _current 指向的帧不会 /// 因最后一个租约释放而被提前释放,也避免了 -1 终态导致的 AcquireLatest 空转。 /// public sealed class FrameStore { + private static int _activeSlots; + private static int _activeLeases; + public static int ActiveSlots => Volatile.Read(ref _activeSlots); + public static int ActiveLeases => Volatile.Read(ref _activeLeases); internal sealed class FrameSlot { public readonly Mat Mat; + public readonly FrameMetadata Metadata; + private readonly Lazy _png; public int Ref; // = 当前指针引用(0/1) + 活动租约数;归零即释放 - public FrameSlot(Mat mat) => Mat = mat; + public FrameSlot(Mat mat, FrameMetadata metadata) + { + Mat = mat; + Metadata = metadata; + _png = new Lazy(() => mat.ToBytes(".png")); + Interlocked.Increment(ref _activeSlots); + } + public ReadOnlyMemory Png => _png.Value; } private FrameSlot? _current; + private long _frameCount; + private long _sourceEpoch; + private readonly object _publishGate = new(); + private TaskCompletionSource _published = NewSignal(); + + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + public long BeginSource() => Interlocked.Increment(ref _sourceEpoch); + + /// 累计发布帧数(单调递增,供「等待新帧」与慢感知 everyFrames 判定)。 + public long FrameCount => Interlocked.Read(ref _frameCount); + + public void Publish(Mat mat) => Publish(mat, FrameMetadata.FromImage(mat)); - public void Publish(Mat mat) + public void Publish(Mat mat, FrameMetadata metadata) { - var slot = new FrameSlot(mat) { Ref = 1 }; - var old = Interlocked.Exchange(ref _current, slot); + ArgumentNullException.ThrowIfNull(mat); + ArgumentNullException.ThrowIfNull(metadata); + FrameSlot? old; + TaskCompletionSource published; + lock (_publishGate) + { + long id = Interlocked.Increment(ref _frameCount); + FrameSlot slot = new(mat, metadata with { FrameId = id }) { Ref = 1 }; + old = Interlocked.Exchange(ref _current, slot); + published = _published; + _published = NewSignal(); + } + published.TrySetResult(); if (old != null) Drop(old); } + /// 等待真正更新且晚于指定宿主到达时间的帧;不触发采集。 + public async Task WaitForFrameAfterAsync(long frameId, long capturedAtTicks, + CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + Task next; + lock (_publishGate) + { + FrameLease? lease = AcquireLatest(); + if (lease != null) + { + if (lease.Metadata.FrameId > frameId && lease.Metadata.CapturedAtTicks > capturedAtTicks) + return lease; + lease.Dispose(); + } + next = _published.Task; + } + await next.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + /// /// 清空当前帧(停止采集时调用),与 Publish 退役旧帧走同一路径。 /// @@ -39,7 +98,10 @@ public void ReleaseCurrent() private static void Drop(FrameSlot s) { if (Interlocked.Decrement(ref s.Ref) == 0) + { s.Mat.Dispose(); + Interlocked.Decrement(ref _activeSlots); + } } public FrameLease? AcquireLatest() @@ -51,14 +113,21 @@ private static void Drop(FrameSlot s) var r = Volatile.Read(ref slot.Ref); if (r <= 0) continue; // 已被退役/释放,取更新的 if (Interlocked.CompareExchange(ref slot.Ref, r + 1, r) == r) + { + Interlocked.Increment(ref _activeLeases); return new FrameLease(this, slot); + } } } internal void Release(FrameSlot slot) { + Interlocked.Decrement(ref _activeLeases); if (Interlocked.Decrement(ref slot.Ref) == 0) + { slot.Mat.Dispose(); + Interlocked.Decrement(ref _activeSlots); + } } } @@ -80,6 +149,8 @@ internal FrameLease(FrameStore store, FrameStore.FrameSlot slot) /// 共享的最新帧(非克隆)。租约存续期间有效。 public Mat Mat => _slot.Mat; + public FrameMetadata Metadata => _slot.Metadata; + public ReadOnlyMemory EncodedPng => _slot.Png; public void Dispose() { diff --git a/src/EasyCon.Capture/HSVColor.cs b/src/EasyCon.Capture/HSVColor.cs deleted file mode 100644 index f3d86c3f..00000000 --- a/src/EasyCon.Capture/HSVColor.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Drawing; - -namespace EasyCapture; - -public struct HSVColor -{ - public double H { get; private set; } - public double S { get; private set; } - public double V { get; private set; } - - public HSVColor(Color c) - { - var r = c.R / 255.0; - var g = c.G / 255.0; - var b = c.B / 255.0; - var cmax = Math.Max(Math.Max(r, g), b); - var cmin = Math.Min(Math.Min(r, g), b); - var dt = cmax - cmin; - if (dt == 0) - H = 0; - else if (cmax == r) - H = 60 * (((g - b) / dt + 6) % 6); - else if (cmax == g) - H = 60 * ((b - r) / dt + 2); - else - H = 60 * ((r - g) / dt + 4); - if (cmax == 0) - S = 0; - else - S = dt / cmax * 100; - V = cmax * 100; - } - - public static implicit operator HSVColor(Color c) - { - return new HSVColor(c); - } - - public static implicit operator Color(HSVColor hsv) - { - return HSVColorExt.ColorFromHSV(hsv.H, hsv.S, hsv.V); - } -} - -public static class HSVColorExt -{ - public static void ColorToHSV(Color color, out double hue, out double saturation, out double value) - { - int max = Math.Max(color.R, Math.Max(color.G, color.B)); - int min = Math.Min(color.R, Math.Min(color.G, color.B)); - - hue = color.GetHue(); - saturation = (max == 0) ? 0 : 1d - (1d * min / max); - value = max / 255d; - } - - public static Color ColorFromHSV(double hue, double saturation, double value) - { - int hi = Convert.ToInt32(Math.Floor(hue / 60)) % 6; - double f = hue / 60 - Math.Floor(hue / 60); - - value = value * 255; - int v = Convert.ToInt32(value); - int p = Convert.ToInt32(value * (1 - saturation)); - int q = Convert.ToInt32(value * (1 - f * saturation)); - int t = Convert.ToInt32(value * (1 - (1 - f) * saturation)); - - if (hi == 0) - return Color.FromArgb(255, v, t, p); - else if (hi == 1) - return Color.FromArgb(255, q, v, p); - else if (hi == 2) - return Color.FromArgb(255, p, v, t); - else if (hi == 3) - return Color.FromArgb(255, p, q, v); - else if (hi == 4) - return Color.FromArgb(255, t, p, v); - else - return Color.FromArgb(255, v, p, q); - } -} \ No newline at end of file diff --git a/src/EasyCon.Capture/ImgLabel.cs b/src/EasyCon.Capture/ImgLabel.cs index 92c82136..c2443bd8 100644 --- a/src/EasyCon.Capture/ImgLabel.cs +++ b/src/EasyCon.Capture/ImgLabel.cs @@ -53,6 +53,10 @@ public string ImgBase64 internal Rect _round => new(RangeX, RangeY, RangeWidth, RangeHeight); internal Rect _target => new(TargetX, TargetY, TargetWidth, TargetHeight); + /// 缓存替换/失效与外借快照的同步锁。 + [JsonIgnore] + private readonly object _matLock = new(); + private Image _image; public Image GetImage() => _image ??= Base64StringToImage(ImgBase64, searchMethod); @@ -65,27 +69,29 @@ public void SetImage(Image img) } /// - /// 获取缓存的 BGR 目标 Mat。首次调用时从 ImgBase64 解码,后续直接返回缓存。 + /// 获取缓存的 BGR 目标 Mat 快照。缓存只用于避免每次重新解码 base64; + /// 返回值为独立克隆,由调用方 Dispose——缓存本体可能在匹配执行中途 + /// 被并发编辑线程 Invalidate(Dispose),直接外借会踩已释放原生内存。 /// internal Mat GetCachedTargetMat() { - if (_cachedMat is { } cached) - return cached; - byte[] imageBytes = Convert.FromBase64String(ImgBase64); - _cachedMat = imageBytes.ToMat(); // ImreadModes.Color → BGR - return _cachedMat; + lock (_matLock) + { + _cachedMat ??= Convert.FromBase64String(ImgBase64).ToMat(); // ImreadModes.Color → BGR + return _cachedMat.Clone(); + } } /// - /// 获取缓存的 RGBA 目标 Mat(MaskedSqDiffNormed 路径使用)。 + /// 获取缓存的 RGBA 目标 Mat 快照(MaskedSqDiffNormed 路径使用),语义同上。 /// internal Mat GetCachedTargetMatRGBA() { - if (_cachedMatRGBA is { } cached) - return cached; - byte[] imageBytes = Convert.FromBase64String(ImgBase64); - _cachedMatRGBA = Cv2.ImDecode(imageBytes, ImreadModes.Unchanged); - return _cachedMatRGBA; + lock (_matLock) + { + _cachedMatRGBA ??= Cv2.ImDecode(Convert.FromBase64String(ImgBase64), ImreadModes.Unchanged); + return _cachedMatRGBA.Clone(); + } } /// @@ -93,10 +99,16 @@ internal Mat GetCachedTargetMatRGBA() /// internal void InvalidateTargetCache() { - _cachedMat?.Dispose(); - _cachedMat = null; - _cachedMatRGBA?.Dispose(); - _cachedMatRGBA = null; + Mat? bgr, rgba; + lock (_matLock) + { + bgr = _cachedMat; + _cachedMat = null; + rgba = _cachedMatRGBA; + _cachedMatRGBA = null; + } + bgr?.Dispose(); + rgba?.Dispose(); } private static bool IsBase64String(string s) @@ -153,21 +165,14 @@ private static Image Base64StringToImage(string basestr, SearchMethod method) private static string ImageToBase64(Image bmp) { - try - { - var ms = new MemoryStream(); - bmp.Save(ms, ImageFormat.Png); - byte[] arr = new byte[ms.Length]; - ms.Position = 0; - ms.Read(arr, 0, (int)ms.Length); - ms.Close(); - string strbaser64 = Convert.ToBase64String(arr); - return strbaser64; - } - catch (Exception ex) - { - return "err!!" + ex.Message; - } + // 不吞编码失败:把错误哨兵串写进 ImgBase64 会随 .IL 持久化污染标签数据 + var ms = new MemoryStream(); + bmp.Save(ms, ImageFormat.Png); + byte[] arr = new byte[ms.Length]; + ms.Position = 0; + ms.Read(arr, 0, (int)ms.Length); + ms.Close(); + return Convert.ToBase64String(arr); } public bool Valid() diff --git a/src/EasyCon.Capture/ImgLabelX.cs b/src/EasyCon.Capture/ImgLabelX.cs deleted file mode 100644 index 6ef3dd96..00000000 --- a/src/EasyCon.Capture/ImgLabelX.cs +++ /dev/null @@ -1,284 +0,0 @@ -using System.Drawing; -using System.Text; - -namespace EasyCon.Capture; - -/// -/// 图像处理格式定义 -/// -public class ImageLabelX -{ - /// - /// 目标区域坐标和尺寸 - /// - private Rectangle TargetRegion { get; set; } - /// - /// 搜索区域坐标和尺寸 - /// - private Rectangle SearchRegion { get; set; } - - /// - /// 标签数据 - /// - private string TargetData { get; set; } = string.Empty; - - /// - /// 处理方式(根据数据类型自动选择) - /// - private SearchMethod ProcessingMethod { get; set; } = SearchMethod.SqDiffNormed; - - /// - /// 可接受的相似度阈值 (0-1) - /// - private double SimilarityThreshold - { - get => _similarityThreshold; - set => _similarityThreshold = Math.Clamp(value, 0.0, 1.0); - } - private double _similarityThreshold = 0.95; - - /// - /// 检查当前处理方式是否适用于图像数据 - /// - public bool IsImageProcessingMethod => ProcessingMethod.IsImageMethod(); - - /// - /// 检查当前处理方式是否适用于文本数据 - /// - public bool IsTextProcessingMethod => !IsImageProcessingMethod; - - public string LabelName { get; set; } = string.Empty; - - /// - /// 验证标签合理性 - /// - public bool Validate(out string errorMessage) - { - if (TargetRegion.Width <= 0 || TargetRegion.Height <= 0) - { - errorMessage = "目标区域尺寸无效"; - return false; - } - - if (string.IsNullOrWhiteSpace(TargetData)) - { - errorMessage = "目标数据不能为空"; - return false; - } - - // 检查处理方式与数据类型的匹配 - if (IsImageData && !IsImageProcessingMethod) - { - errorMessage = $"处理方式'{ProcessingMethod}'不适用于图像数据"; - return false; - } - - if (!IsImageData && !IsTextProcessingMethod) - { - errorMessage = $"处理方式'{ProcessingMethod}'不适用于文本数据"; - return false; - } - - errorMessage = string.Empty; - return true; - } - /// - /// 二进制文件魔数,用于标识文件格式 - /// - private const uint FILE_MAGIC = 0x494C464D; // "ILFM" in hex (Image Label Format) - /// - /// 文件格式版本 - /// - private const ushort FILE_VERSION = 0x0100; // Version 1.0 - - /// - /// 保存到.ILX文件 - /// - /// 完整文件路径,自动添加.ILX后缀 - public void SaveToFile(string filePath) - { - // 确保文件后缀为.ILX - if (!filePath.EndsWith(".ILX", StringComparison.OrdinalIgnoreCase)) - { - filePath += ".ILX"; - } - if (!Validate(out var msg)) - { - throw new ArgumentException(msg); - } - - using (var fileStream = new FileStream(filePath, FileMode.Create)) - using (var writer = new BinaryWriter(fileStream, Encoding.UTF8)) - { - // 写入文件头 - writer.Write(FILE_MAGIC); // 4字节:魔数 - writer.Write(FILE_VERSION); // 2字节:版本号 - writer.Write((byte)(IsImageData ? 1 : 0)); // 1字节:数据类型标识 (1=图像, 0=文本) - writer.Write((byte)ProcessingMethod); // 1字节:处理方式 - writer.Write(SimilarityThreshold); // 8字节:阈值 - - // 写入区域信息 - TargetRegion.WriteToBinary(writer); // 16字节:x,y,width,height - SearchRegion.WriteToBinary(writer); // 16字节:搜索区域 - - // 写入数据长度和数据 - // 对于图像数据,将Base64解码为原始字节 - // 对于文本数据,直接编码为UTF8 - byte[] dBytes = IsImageData ? Convert.FromBase64String(TargetData) : Encoding.UTF8.GetBytes(TargetData); - writer.Write(dBytes.Length); // 4字节:数据长度 - writer.Write(dBytes); // N字节:数据内容 - - // 写入文件结束标记(可选,用于验证文件完整性) - writer.Write((byte)0xFF); // 1字节:结束符 - } - } - - /// - /// 从.ILX文件加载 - /// - /// 完整文件路径 - public static ImageLabelX LoadFromFile(string filePath) - { - // 检查文件后缀 - if (!filePath.EndsWith(".ILX", StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("仅支持.ILX格式文件", nameof(filePath)); - } - - if (!File.Exists(filePath)) - { - throw new FileNotFoundException("标签文件不存在", filePath); - } - - using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) - using (var reader = new BinaryReader(fileStream, Encoding.UTF8)) - { - // 读取并验证文件头 - uint magic = reader.ReadUInt32(); - if (magic != FILE_MAGIC) - { - throw new InvalidDataException("无效的文件格式:魔数不匹配"); - } - - ushort version = reader.ReadUInt16(); - if (version != FILE_VERSION) - { - throw new InvalidDataException($"不支持的版本号:{version >> 8}.{version & 0xFF}"); - } - - byte dataType = reader.ReadByte(); // 数据类型 - SearchMethod method = (SearchMethod)reader.ReadByte(); // 处理方式 - double threshold = reader.ReadDouble(); // 阈值 - - // 读取区域信息 - var region = RectangleFact.ReadFromBinary(reader); - var searchRegion = RectangleFact.ReadFromBinary(reader); - - // 读取数据长度和数据 - int dataLength = reader.ReadInt32(); - byte[] dataBytes = reader.ReadBytes(dataLength); - - // 验证数据长度 - if (dataBytes.Length != dataLength) - { - throw new EndOfStreamException("文件数据不完整"); - } - - // TODO ExtraData 读取额外参数数据 - var exflag = reader.ReadByte(); - if (exflag == 1) // 有额外参数 - { - var exlen = reader.ReadByte(); - var exdata = reader.ReadBytes(exlen); - } - - // 验证结束标记 - if (fileStream.Position < fileStream.Length) - { - byte endMagic = reader.ReadByte(); - if (endMagic != 0xFF) - { - System.Diagnostics.Debug.WriteLine($"警告:文件结束标记不匹配,但继续处理..."); - } - } - - // 根据数据类型创建字符串 - string targetData; - if (dataType == 1) - { - // 图像数据:将字节编码为Base64 - targetData = Convert.ToBase64String(dataBytes); - } - else - { - // 文本数据:解码为UTF8字符串 - targetData = Encoding.UTF8.GetString(dataBytes); - } - - // 创建并返回对象 - return new ImageLabelX - { - LabelName = Path.GetFileNameWithoutExtension(filePath), - TargetRegion = region, - SearchRegion = searchRegion, - TargetData = targetData, - ProcessingMethod = method, - SimilarityThreshold = threshold - }; - } - } - - /// - /// 检查是否为图像数据(Base64编码) - /// - public bool IsImageData => IsBase64String(TargetData); - - /// - /// 高效检查字符串是否为有效的Base64格式 - /// - private static bool IsBase64String(string s) - { - if (string.IsNullOrWhiteSpace(s)) return false; - if (s.Length % 4 != 0) return false; - - try - { - // 尝试转换验证 - _ = Convert.FromBase64String(s); - return true; - } - catch - { - return false; - } - } -} -/// -/// 表示图像处理的目标区域 -/// -static class RectangleFact -{ - /// - /// 将区域数据写入二进制流 - /// - public static void WriteToBinary(this Rectangle rect, BinaryWriter writer) - { - writer.Write(rect.X); - writer.Write(rect.Y); - writer.Write(rect.Width); - writer.Write(rect.Height); - } - - /// - /// 从二进制流读取区域数据 - /// - public static Rectangle ReadFromBinary(BinaryReader reader) - { - return new Rectangle( - reader.ReadInt32(), - reader.ReadInt32(), - reader.ReadInt32(), - reader.ReadInt32() - ); - } -} \ No newline at end of file diff --git a/src/EasyCon.Capture/LegacyAlgorithmAdapter.cs b/src/EasyCon.Capture/LegacyAlgorithmAdapter.cs new file mode 100644 index 00000000..580b548e --- /dev/null +++ b/src/EasyCon.Capture/LegacyAlgorithmAdapter.cs @@ -0,0 +1,93 @@ +using OpenCvSharp; + +namespace EasyCon.Capture; + +public enum LegacyCompatibilityProfile { LegacyGui, LegacyCli } + +public sealed record RawMatchResult(double Score, Rect Box, SearchMethod Method, string AlgorithmVersion = "easycon-opencv-legacy/1"); + +/// 唯一的旧浮点分数及整数适配。保留旧运算顺序,避免整数边界上的浮点重排。 +public static class LegacyAlgorithmAdapter +{ + public static double ToLegacyFloat(RawMatchResult result) + { + if (!double.IsFinite(result.Score)) throw new InvalidDataException("模板匹配产生 NaN/Infinity"); + return result.Method switch + { + SearchMethod.SqDiffNormed or SearchMethod.MaskedSqDiffNormed => (1 - result.Score) * 100, + SearchMethod.CCorrNormed or SearchMethod.TesserDetect => result.Score * 100, + SearchMethod.CCoeffNormed or SearchMethod.EdgeDetectXY or SearchMethod.EdgeDetectLaplacian => ((result.Score + 1) / 2.0) * 100, + _ => throw new NotSupportedException($"不支持旧评分算法 {result.Method}"), + }; + } + + public static int ToLegacyInteger(RawMatchResult result, LegacyCompatibilityProfile profile) + { + if (!Enum.IsDefined(profile)) throw new InvalidDataException($"未知兼容 profile {profile}"); + double value = ToLegacyFloat(result); + return checked((int)(profile == LegacyCompatibilityProfile.LegacyCli ? Math.Ceiling(value) : Math.Truncate(value))); + } + + public static double TextSimilarity(string actual, string expected) => MatchFacts.StringMatchSimple(actual.Trim(), expected); + + public static bool Compare(int score, string comparison, int threshold) => comparison switch + { + ">" => score > threshold, + ">=" => score >= threshold, + "<" => score < threshold, + "<=" => score <= threshold, + "==" => score == threshold, + "!=" => score != threshold, + _ => throw new InvalidDataException($"未知分数比较符 {comparison}"), + }; +} + +/// 供 Maa CustomRecognition 调用的旧 OpenCV 原始评分;不执行取整、不读取采集源。 +public static class LegacyTemplateMatcher +{ + public static RawMatchResult Match(ImgLabel label, Mat image) + { + Rect roi = new(label.RangeX, label.RangeY, label.RangeWidth, label.RangeHeight); + if (roi.X < 0 || roi.Y < 0 || roi.Width <= 0 || roi.Height <= 0 || + (long)roi.X + roi.Width > image.Width || (long)roi.Y + roi.Height > image.Height) + throw new InvalidDataException($"标签 [{label.name}] ROI 超出输入图像"); + using Mat range = new(image, roi); + using Mat template = Cv2.ImDecode(Convert.FromBase64String(label.ImgBase64), + label.searchMethod == SearchMethod.MaskedSqDiffNormed ? ImreadModes.Unchanged : ImreadModes.Color); + if (template.Empty() || template.Width > roi.Width || template.Height > roi.Height) + throw new InvalidDataException($"标签 [{label.name}] 模板为空或大于 ROI"); + using Mat result = new(); + if (label.searchMethod == SearchMethod.MaskedSqDiffNormed) + { + if (template.Channels() != 4) throw new InvalidDataException("透明匹配需要四通道模板"); + Mat[] channels = Cv2.Split(template); + try + { + if (Cv2.CountNonZero(channels[3]) == 0) throw new InvalidDataException("模板完全透明"); + using Mat color = new(); + Cv2.Merge([channels[0], channels[1], channels[2]], color); + Cv2.MatchTemplate(range, color, result, TemplateMatchModes.SqDiffNormed, channels[3]); + } + finally { foreach (Mat channel in channels) channel.Dispose(); } + } + else if (label.searchMethod is SearchMethod.EdgeDetectXY or SearchMethod.EdgeDetectLaplacian) + { + using Mat sourceEdges = OpenCVSearch.EdgeDetect(range, label.searchMethod); + using Mat templateEdges = OpenCVSearch.EdgeDetect(template, label.searchMethod); + Cv2.MatchTemplate(sourceEdges, templateEdges, result, TemplateMatchModes.CCoeffNormed); + } + else + { + if (label.searchMethod is not (SearchMethod.SqDiffNormed or SearchMethod.CCorrNormed or SearchMethod.CCoeffNormed)) + throw new NotSupportedException($"标签 [{label.name}] 算法 {label.searchMethod} 不支持兼容识别"); + Cv2.MatchTemplate(range, template, result, (TemplateMatchModes)(int)label.searchMethod); + } + Cv2.MinMaxLoc(result, out double min, out double max, out Point minLocation, out Point maxLocation); + bool difference = label.searchMethod is SearchMethod.SqDiffNormed or SearchMethod.MaskedSqDiffNormed; + Point location = difference ? minLocation : maxLocation; + RawMatchResult match = new(difference ? min : max, + new Rect(roi.X + location.X, roi.Y + location.Y, template.Width, template.Height), label.searchMethod); + _ = LegacyAlgorithmAdapter.ToLegacyFloat(match); + return match; + } +} \ No newline at end of file diff --git a/src/EasyCon.Capture/OCRDetect.cs b/src/EasyCon.Capture/OCRDetect.cs deleted file mode 100644 index 9928eaf9..00000000 --- a/src/EasyCon.Capture/OCRDetect.cs +++ /dev/null @@ -1,33 +0,0 @@ -using EasyCon.Capture.Ocr; -using EzTesseract; -using EzTesseract.Enums; - -namespace EasyCon.Capture; - -/// -/// OCR 识别门面 — 提供静态便捷方法,基于 IOcrRecognizer 接口。 -/// 不再支持 per-call 创建/销毁引擎的模式(引擎生命周期由 OcrEngineCache 统一管理)。 -/// -public sealed class OCRDetect -{ - /// - /// 使用 IOcrRecognizer 执行 OCR(引擎由调用方管理生命周期)。 - /// - public static string Recognize(IOcrRecognizer recognizer, byte[] imagePng, out float confidence) - { - var result = recognizer.Recognize(imagePng); - confidence = result.Confidence; - return result.Text; - } - - /// - /// 使用外部传入的 Tesseract Engine 实例直接执行 OCR。 - /// 保留此方法用于需要直接操作 Tesseract 底层引擎的场景。 - /// - public static string TesserDetect(Engine engine, EzTesseract.Pix.Image img, PageSegMode psm, out float confidence) - { - using var page = engine.Process(img, psm); - confidence = page.MeanConfidence; - return page.Text; - } -} \ No newline at end of file diff --git a/src/EasyCon.Capture/Ocr/Onnx/OnnxOcrEngine.cs b/src/EasyCon.Capture/Ocr/Onnx/OnnxOcrEngine.cs index 27c188d5..f9539e75 100644 --- a/src/EasyCon.Capture/Ocr/Onnx/OnnxOcrEngine.cs +++ b/src/EasyCon.Capture/Ocr/Onnx/OnnxOcrEngine.cs @@ -1,11 +1,11 @@ -using EasyCon.Capture.Ocr; +using EasyCon.Capture.Ocr; using OpenCvSharp; using OpenCvSharp.Dnn; namespace EasyCon.Capture.Ocr.Onnx; /// -/// OpenCV DNN 文本检测器 — 基于 PaddleOCR 检测模型(如 PP-OCRv5_mobile_det.onnx)。 +/// OpenCV DNN 文本检测器 — 基于 PaddleOCR 检测模型(部署约定文件名 det.onnx)。 /// 实现 IOcrDetector,从图片中定位文字区域。 /// public sealed class OnnxDetector : IOcrDetector @@ -14,14 +14,16 @@ public sealed class OnnxDetector : IOcrDetector private readonly DetOptions _options; private bool _disposed; - // PaddleOCR 检测模型预处理参数 - private static readonly float[] DetMean = [0.485f, 0.456f, 0.406f]; - private static readonly float[] DetStd = [0.229f, 0.224f, 0.225f]; + // PP-OCRv5 / v6 官方 ONNX 导出实测同参的检测预处理:等价于 (px/127.5 - 0.5),即 (px/255 - 0.25) / 0.5。 + // 实测 ImageNet mean/std 会让 v5 概率图大面积饱和(>50% 像素超阈值),此参数组下文本区稀疏(~8%); + // v6 对预处理不敏感(四种常见参数组检出框一致),沿用同参保持一代配方可互换。 + private static readonly float[] DetMean = [0.25f, 0.25f, 0.25f]; + private static readonly float[] DetStd = [0.5f, 0.5f, 0.5f]; /// /// 从文件路径加载检测模型。 /// - /// ONNX 模型文件路径(如 PP-OCRv5_mobile_det.onnx)。 + /// ONNX 模型文件路径(如 det.onnx)。 /// 检测配置(可选)。 /// DNN 计算后端(默认 DEFAULT)。 /// DNN 目标设备(默认 CPU)。 @@ -155,6 +157,16 @@ private OcrTextBox[] PostprocessDet( int bw = (int)(rect.Width * scaleX); int bh = (int)(rect.Height * scaleY); + // unclip-lite:DB 无完整 Vatti 外扩,按比例外扩边界。 + // 实测(1080p 游戏截图像素字)垂直外扩需 ~h*30%、水平 ~w*1.5% 才不切字, + // 否则 rec 对紧框输出空/乱码。 + int ex = Math.Max(6, (int)(bw * 0.015f)); + int ey = Math.Max(6, (int)(bh * 0.30f)); + x -= ex; + y -= ey; + bw += ex * 2; + bh += ey * 2; + // 裁剪到原图范围 x = Math.Clamp(x, 0, origW); y = Math.Clamp(y, 0, origH); @@ -214,7 +226,7 @@ public void Dispose() } /// -/// OpenCV DNN 文本识别器 — 基于 PaddleOCR 识别模型(如 PP-OCRv5_mobile_rec.onnx)。 +/// OpenCV DNN 文本识别器 — 基于 PaddleOCR 识别模型(部署约定文件名 <lang>_rec.onnx)。 /// 实现 IOcrRecognizer,从已裁剪的文字行图片中识别文本。 /// public sealed class OnnxRecognizer : IOcrRecognizer @@ -229,7 +241,7 @@ public sealed class OnnxRecognizer : IOcrRecognizer /// /// 从文件路径加载识别模型和字符集。 /// - /// ONNX 模型文件路径(如 PP-OCRv5_mobile_rec.onnx)。 + /// ONNX 模型文件路径(如 <lang>_rec.onnx)。 /// 字符集文件路径(每行一个字符,首行为空白符)。 /// 识别配置(可选)。 /// DNN 计算后端(默认 DEFAULT)。 @@ -270,36 +282,34 @@ public OcrRecognizeResult Recognize(byte[] image) } /// - /// 识别预处理:BGR→RGB、高度缩放到 32(保持宽高比)、宽度填充到 8 的倍数。 + /// 识别预处理:高度缩放到 32(保持宽高比)、宽度填充到 8 的倍数。 + /// 注意:PaddleOCR rec 官方预处理不转 RGB(保持 BGR),与 det 不同。 /// private static Mat PreprocessRec(Mat src) { - using var rgb = new Mat(); - Cv2.CvtColor(src, rgb, ColorConversionCodes.BGR2RGB); - - float ratio = (float)RecImageHeight / rgb.Height; - int newW = (int)(rgb.Width * ratio); - Cv2.Resize(rgb, rgb, new Size(newW, RecImageHeight), 0, 0, InterpolationFlags.Linear); + float ratio = (float)RecImageHeight / src.Height; + int newW = (int)(src.Width * ratio); + Cv2.Resize(src, src, new Size(newW, RecImageHeight), 0, 0, InterpolationFlags.Linear); // 宽度填充到 8 的倍数(最小 8) int padW = ((newW + 7) / 8) * 8 - newW; if (padW > 0) - Cv2.CopyMakeBorder(rgb, rgb, 0, 0, 0, padW, BorderTypes.Constant, Scalar.Black); + Cv2.CopyMakeBorder(src, src, 0, 0, 0, padW, BorderTypes.Constant, Scalar.Black); - return rgb.Clone(); + return src.Clone(); } /// - /// 创建识别模型输入 blob:归一化到 [-1, 1] 范围。 - /// 公式:(pixel/255 - 0.5) / 0.5 = pixel/127.5 - 1.0 - /// BlobFromImage: scale = 1/127.5, mean = (1, 1, 1), swapRB = false(已是 RGB) + /// 创建识别模型输入 blob:与 det 同族的归一化(px/255 - 0.25,不除 std)。 + /// 实测 PaddleOCR 经典的 (px/127.5 - 1) 会让 v5 导出输出乱码,此参数组出正确文本。 + /// BlobFromImage: scale = 1/255, mean = (0.25, 0.25, 0.25), swapRB = false(保持 BGR) /// private static Mat CreateRecBlob(Mat rgb) { return Cv2.Dnn.BlobFromImage(rgb, - 1.0 / 127.5, + 1.0 / 255.0, new Size(rgb.Width, rgb.Height), - new Scalar(1.0, 1.0, 1.0), + new Scalar(0.25, 0.25, 0.25), swapRB: false, crop: false); } @@ -313,8 +323,18 @@ private OcrRecognizeResult CtcDecode(Mat output) if (output.Dims == 3) { - T = output.Size(1); - numClasses = output.Size(2); + if (output.Size(0) == 1) + { + // [1, T, C] + T = output.Size(1); + numClasses = output.Size(2); + } + else + { + // [T, 1, C](部分导出的形状) + T = output.Size(0); + numClasses = output.Size(2); + } } else if (output.Dims >= 4) { @@ -360,12 +380,12 @@ private OcrRecognizeResult CtcDecode(Mat output) if (indices.Count == 0) return new OcrRecognizeResult(string.Empty, 0f); - // 查字符集 + // 查字符集(PaddleOCR 惯例:末位类别是显式空格,越界按空格回退) var chars = new char[indices.Count]; for (int i = 0; i < indices.Count; i++) { int idx = indices[i]; - chars[i] = idx < _charset.Length ? _charset[idx][0] : '?'; + chars[i] = idx < _charset.Length ? _charset[idx][0] : ' '; } float confidence = validCount > 0 ? totalConf / validCount : 0f; @@ -482,14 +502,15 @@ public sealed class OnnxEngineFactory : IOcrEngineFactory private readonly Target _target; /// - /// 创建 ONNX 引擎工厂。 + /// 创建 ONNX 引擎工厂。代码不绑定任何模型代际,按部署约定解析(模型与字典同代配对由部署保证): + /// 检测 (约定 "det.onnx");识别 <lang>_rec.onnx;字符集 <lang>_dict.txt。 /// /// 模型文件目录。 - /// 检测模型文件名(默认 "PP-OCRv5_mobile_det.onnx")。 + /// 检测模型文件名(部署约定 "det.onnx")。 /// GPU 后端类型(默认 CPU)。 public OnnxEngineFactory( string modelDirectory, - string detModelName = "PP-OCRv5_mobile_det.onnx", + string detModelName = "det.onnx", GpuBackend backend = GpuBackend.Cpu) { _modelDirectory = modelDirectory ?? throw new ArgumentNullException(nameof(modelDirectory)); @@ -504,13 +525,6 @@ public IOcrRecognizer CreateRecognizer(string lang, string dataPath, string engi var recModel = Path.Combine(modelDir, $"{lang}_rec.onnx"); var charset = Path.Combine(modelDir, $"{lang}_dict.txt"); - if (!File.Exists(recModel)) - { - // 回退:尝试不带语言前缀的通用模型名 - recModel = Path.Combine(modelDir, "PP-OCRv5_mobile_rec.onnx"); - charset = Path.Combine(modelDir, "ppocr_keys_v5.txt"); - } - return new OnnxRecognizer(recModel, charset, backend: _backend, target: _target); } @@ -522,12 +536,6 @@ public IOcrRecognizer CreateRecognizer(string lang, string dataPath, string engi var recPath = Path.Combine(modelDir, $"{lang}_rec.onnx"); var charsetPath = Path.Combine(modelDir, $"{lang}_dict.txt"); - if (!File.Exists(recPath)) - { - recPath = Path.Combine(modelDir, "PP-OCRv5_mobile_rec.onnx"); - charsetPath = Path.Combine(modelDir, "ppocr_keys_v5.txt"); - } - return new OnnxOcrEngine( new OnnxDetector(detPath, backend: _backend, target: _target), new OnnxRecognizer(recPath, charsetPath, backend: _backend, target: _target)); diff --git a/src/EasyCon.Capture/OcrEngineCache.cs b/src/EasyCon.Capture/OcrEngineCache.cs index c42dcee1..43205ec7 100644 --- a/src/EasyCon.Capture/OcrEngineCache.cs +++ b/src/EasyCon.Capture/OcrEngineCache.cs @@ -27,6 +27,9 @@ public OcrEngineCache(IOcrEngineFactory? factory = null) /// 最近一次 OCR 调用的置信度 (0~100) public int LastConfidence { get; set; } + /// 最近一次 Init 失败的原因(成功初始化后清空)。排障锚点,勿吞。 + public string? LastError { get; private set; } + /// /// 默认 tessdata / 模型目录路径。 /// 当 GetOrInit 触发自动初始化时使用此路径。 @@ -53,10 +56,14 @@ public bool Init(string lang, string dataPath, string engineMode, string psmode) { var engine = _factory.CreateRecognizer(lang, dataPath, engineMode, psmode); _engines[lang] = new CachedEntry(engine, dataPath, engineMode, psmode); + LastError = null; return true; } - catch + catch (Exception ex) { + // 保留原因供 GetOrInit 抛出明确异常:这里若静默返回 false, + // 调用方拿到的将是误导性的 KeyNotFoundException + LastError = ex.Message; return false; } } @@ -75,12 +82,29 @@ public bool Init(string lang, string dataPath, string engineMode, string psmode) /// 获取或自动初始化识别器:缓存命中直接返回,未命中则用默认参数自动创建并缓存。 /// 默认参数:DefaultDataPath、DEFAULT 引擎模式、SINGLE_LINE 页面分割。 /// + /// 引擎初始化失败(原因见消息与 )。 public IOcrRecognizer GetOrInit(string lang) { if (_engines.TryGetValue(lang, out var cached)) return cached.Engine; + return GetOrInit(lang, DefaultDataPath, "DEFAULT", "SINGLE_LINE"); + } + + /// + /// 获取或按显式参数初始化识别器;参数与缓存一致时跳过重建。 + /// + /// 引擎初始化失败(原因见消息与 )。 + public IOcrRecognizer GetOrInit(string lang, string dataPath, string engineMode, string psmode) + { + if (_engines.TryGetValue(lang, out var cached) + && cached.DataPath == dataPath + && cached.EngineMode == engineMode + && cached.Psmode == psmode) + return cached.Engine; - Init(lang, DefaultDataPath, "DEFAULT", "SINGLE_LINE"); + if (!Init(lang, dataPath, engineMode, psmode)) + throw new InvalidOperationException( + $"OCR 引擎初始化失败 (lang={lang}, dataPath={dataPath}, engine={engineMode}, psm={psmode}): {LastError}"); return _engines[lang].Engine; } diff --git a/src/EasyCon.Capture/Search.cs b/src/EasyCon.Capture/Search.cs index b824f43d..05ec47dc 100644 --- a/src/EasyCon.Capture/Search.cs +++ b/src/EasyCon.Capture/Search.cs @@ -24,20 +24,26 @@ public static IEnumerable GetEnableSearchMethods() ]; } + /// + /// OCR 引擎统一经 管理(与 Core 侧 EngineCacheOcrService + /// 同一条引擎生命周期路径):按 lang+参数幂等初始化,失败时抛带原因的异常。 + /// 识别器非线程安全,串行化访问。 + /// + private static readonly OcrEngineCache OcrEngines = new(new TesseractEngineFactory()); + public static string FindOCR(string text, Mat srcBmp, out double matchDegree, string dataPath) { var imageBytes = srcBmp.ToBytes(".png"); - var factory = new TesseractEngineFactory(); - using var recognizer = factory.CreateRecognizer("chi_sim", dataPath, "DEFAULT", "SINGLE_LINE"); - var result = recognizer.Recognize(imageBytes); - var resultTxt = result.Text.Trim(); - Debug.WriteLine($"识别到的文本:{resultTxt}, 匹配度:{result.Confidence}"); - Debug.WriteLine($"对比原始文本:{text},对比对象:{resultTxt}"); - // 计算编辑距离 - matchDegree = MatchFacts.StringMatchSimple(resultTxt, text); - // 置信度*编辑距离为最终相似度 - matchDegree *= result.Confidence; - return resultTxt; + lock (OcrEngines) + { + var recognizer = OcrEngines.GetOrInit("chi_sim", dataPath, "DEFAULT", "SINGLE_LINE"); + var result = recognizer.Recognize(imageBytes); + var resultTxt = result.Text.Trim(); + + // 计算编辑距离;置信度*编辑距离为最终相似度 + matchDegree = MatchFacts.StringMatchSimple(resultTxt, text) * result.Confidence; + return resultTxt; + } } public static Point FindPic(Mat big, Mat small, SearchMethod method, out double matchDegree) @@ -82,7 +88,6 @@ public static List Search(this ImgLabel self, Mat ss, out double md, stri try { - Console.Error.WriteLine($"[Search] ss size: {ss.Width}x{ss.Height}, channels={ss.Channels()}, type={ss.Type()}, roi=({self._round.X},{self._round.Y},{self._round.Width},{self._round.Height})"); using var range = new Mat(ss, self._round); List result = new(); @@ -95,19 +100,22 @@ public static List Search(this ImgLabel self, Mat ss, out double md, stri { if (self.searchMethod == SearchMethod.MaskedSqDiffNormed) { - var targetRGBA = self.GetCachedTargetMatRGBA(); + using var targetRGBA = self.GetCachedTargetMatRGBA(); if (targetRGBA.Channels() != 4) throw new Exception("Masked matching requires RGBA image"); Cv2.Split(targetRGBA, out var channels); using var bgr = new Mat(); - Cv2.Merge([channels[0], channels[1], channels[2]], bgr); + using var ch0 = channels[0]; + using var ch1 = channels[1]; + using var ch2 = channels[2]; using var mask = channels[3]; + Cv2.Merge([ch0, ch1, ch2], bgr); var pt = MatchFacts.MatchTemplateMasked(range, bgr, mask, out md); result = [new Point(pt.X, pt.Y)]; } else { - var target = self.GetCachedTargetMat(); + using var target = self.GetCachedTargetMat(); result = [ECSearch.FindPic(range, target, self.searchMethod, out md)]; } } diff --git a/src/EasyCon.Capture/SearchMethod.cs b/src/EasyCon.Capture/SearchMethod.cs index 614aab47..aaa25f87 100644 --- a/src/EasyCon.Capture/SearchMethod.cs +++ b/src/EasyCon.Capture/SearchMethod.cs @@ -1,5 +1,7 @@ using System.ComponentModel; +namespace EasyCon.Capture; + public enum SearchMethod { [Description("平方差匹配")] diff --git a/src/EasyCon.Capture/mac/AvFoundationDevices.cs b/src/EasyCon.Capture/mac/AvFoundationDevices.cs new file mode 100644 index 00000000..6a6747b4 --- /dev/null +++ b/src/EasyCon.Capture/mac/AvFoundationDevices.cs @@ -0,0 +1,100 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace EasyCon.Capture.mac; + +/// 使用与锁定的 OpenCV AVFoundation 后端一致的设备集合及排序,不打开摄像头。 +[SupportedOSPlatform("macos")] +internal static class AvFoundationDevices +{ + private const string ObjC = "/usr/lib/libobjc.A.dylib"; + private static readonly nint VideoMediaType; + private static readonly nint MuxedMediaType; + + static AvFoundationDevices() + { + // 保持框架加载到进程退出;枚举可先于 OpenCV 的首次使用发生。 + nint framework = NativeLibrary.Load("/System/Library/Frameworks/AVFoundation.framework/AVFoundation"); + VideoMediaType = Marshal.ReadIntPtr(NativeLibrary.GetExport(framework, "AVMediaTypeVideo")); + MuxedMediaType = Marshal.ReadIntPtr(NativeLibrary.GetExport(framework, "AVMediaTypeMuxed")); + } + + public static IReadOnlyList<(string Name, string? StableId, int Index)> Enumerate() + { + nint pool = Send(GetClass("NSAutoreleasePool"), Selector("new")); + try + { + nint deviceClass = GetClass("AVCaptureDevice"); + nint video = SendPointer(deviceClass, Selector("devicesWithMediaType:"), VideoMediaType); + nint muxed = SendPointer(deviceClass, Selector("devicesWithMediaType:"), MuxedMediaType); + nint devices = SendPointer(video, Selector("arrayByAddingObjectsFromArray:"), muxed); + + // OpenCvSharp5 5.0.0.20261011 的 OpenCV 提交 40738fb:video + muxed, + // 随后使用 NSString compare: 按 uniqueID 升序排列。不能按名称或系统返回顺序分配索引。 + nint key = SendString(GetClass("NSString"), Selector("stringWithUTF8String:"), "uniqueID"); + nint sort = SendSort(GetClass("NSSortDescriptor"), Selector("sortDescriptorWithKey:ascending:"), key, true); + nint descriptors = SendPointer(GetClass("NSArray"), Selector("arrayWithObject:"), sort); + nint ordered = SendPointer(devices, Selector("sortedArrayUsingDescriptors:"), descriptors); + int count = checked((int)SendCount(ordered, Selector("count"))); + List<(string, string?, int)> result = new(count); + for (int index = 0; index < count; index++) + { + nint device = SendIndex(ordered, Selector("objectAtIndex:"), (nuint)index); + string name = ReadString(Send(device, Selector("localizedName"))) ?? $"摄像头 {index}"; + string? id = ReadString(Send(device, Selector("uniqueID"))); + result.Add((name, id, index)); + } + return result; + } + finally + { + SendVoid(pool, Selector("drain")); + } + } + + public static void ThrowIfAccessUnavailable() + { + nint status = SendPointer(GetClass("AVCaptureDevice"), Selector("authorizationStatusForMediaType:"), VideoMediaType); + if (status == 0) + throw new UnauthorizedAccessException("尚未获得摄像头权限,请在 macOS 授权弹窗中允许 EasyCon 访问摄像头后重新连接"); + if (status is 1 or 2) + throw new UnauthorizedAccessException("摄像头访问被 macOS 拒绝或限制,请在系统设置 → 隐私与安全性 → 摄像头中允许 EasyCon(CLI 请允许对应终端)"); + } + + private static string? ReadString(nint value) + => value == 0 ? null : Marshal.PtrToStringUTF8(Send(value, Selector("UTF8String"))); + + private static nint GetClass(string name) + { + nint value = GetNativeClass(name); + return value != 0 ? value : throw new InvalidOperationException($"macOS 系统框架未提供 {name},无法枚举视频来源"); + } + + // objc_msgSend 必须使用每个方法的确切签名,尤其不能把 arm64 的固定参数当作可变参数传递。 + [DllImport(ObjC, EntryPoint = "objc_getClass")] + private static extern nint GetNativeClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "sel_registerName")] + private static extern nint Selector([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendPointer(nint receiver, nint selector, nint value); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendIndex(nint receiver, nint selector, nuint index); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendString(nint receiver, nint selector, [MarshalAs(UnmanagedType.LPUTF8Str)] string value); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendSort(nint receiver, nint selector, nint key, [MarshalAs(UnmanagedType.I1)] bool ascending); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nuint SendCount(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoid(nint receiver, nint selector); +} \ No newline at end of file diff --git a/src/EasyCon.Capture/win/DirectShow.cs b/src/EasyCon.Capture/win/DirectShow.cs index d93c03d4..066a7b0b 100644 --- a/src/EasyCon.Capture/win/DirectShow.cs +++ b/src/EasyCon.Capture/win/DirectShow.cs @@ -29,6 +29,32 @@ public static ImmutableArray GetFiltes(Guid category) return result.ToImmutableArray(); } + public static IReadOnlyList<(string Name, string? DevicePath)> GetDevices() + { + List<(string, string?)> result = new(); + EnumMonikers(DsGuid.CLSID_VideoInputDeviceCategory, (moniker, prop) => + { + object name = null; + prop.Read("FriendlyName", ref name, 0); + string? identity = null; + try + { + object path = null; + prop.Read("DevicePath", ref path, 0); + identity = path as string; + } + catch (Exception ex) when (ex is COMException or IOException) { } + if (string.IsNullOrWhiteSpace(identity)) + { + try { moniker.GetDisplayName(null, null, out identity); } + catch (Exception ex) when (ex is COMException or IOException) { } + } + result.Add(((string)name, identity)); + return false; + }); + return result; + } + /// モニカを列挙する。 /// モニカとはCOMオブジェクトを識別する別名のこと。 private static void EnumMonikers(Guid category, Func func) diff --git a/src/EasyCon.Core/Assist/WS_Message.cs b/src/EasyCon.Core/Assist/WS_Message.cs deleted file mode 100644 index 7e98b279..00000000 --- a/src/EasyCon.Core/Assist/WS_Message.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace EasyCon.Core.Assist; - -public enum WS_Role -{ - Server, - Client, - Other, -} -public enum WS_Event -{ - LogReq, - LogRes, - Notifiy, - CommandReq, - CommandRes -} - -public class Role -{ - public WS_Role role { get; set; } - public String token { get; set; } - - public Role(WS_Role role, string t) - { - this.role = role; - token = t; - } -} - -public class WS_Message -{ - public Role ws_Role { get; set; } - public WS_Event ws_Event { get; set; } - public String data { get; set; } -} - -public class LogResponse : WS_Message -{ - public LogResponse(String token, String log) - { - ws_Role = new Role(WS_Role.Client, token); - ws_Event = WS_Event.LogRes; - data = log; - } -} - -public class Notification : WS_Message -{ - public Notification(String token, String msg) - { - ws_Role = new Role(WS_Role.Client, token); - ws_Event = WS_Event.Notifiy; - data = msg; - } -} - -public class CommandReq : WS_Message -{ - public CommandReq(String token, String msg) - { - ws_Role = new Role(WS_Role.Client, ""); - ws_Event = WS_Event.CommandReq; - data = msg; - } -} - -//public class Ack : WS_Message -//{ -// Ack(String token, String msg) -// { -// ws_Role = new Role(WS_Role.Client, token); -// ws_Event = WS_Event.Notifiy; -// data = msg; -// } -//} \ No newline at end of file diff --git a/src/EasyCon.Core/Assist/WebSocket.cs b/src/EasyCon.Core/Assist/WebSocket.cs deleted file mode 100644 index 2eb5c6d5..00000000 --- a/src/EasyCon.Core/Assist/WebSocket.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Diagnostics; -using System.Net.WebSockets; -using System.Text; -using System.Text.Json; - -namespace EasyCon.Core.Assist; - -public sealed class AssistClient -{ - private readonly ClientWebSocket ws = new(); - private readonly Uri _uri = new("ws://106.52.245.228:43963"); - - public AssistClient() - { - ws.ConnectAsync(_uri, default); - //var bytes = new byte[1024]; - //var result = await ws.ReceiveAsync(bytes, default); - //string res = Encoding.UTF8.GetString(bytes, 0, result.Count); - //await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closed", default); - } - - public bool IsConnect() => ws.State == WebSocketState.Open; - - public async void SendMsg(WS_Message ws_Message) - { - var json = JsonSerializer.Serialize(ws_Message); - Debug.WriteLine(json); - var buf = Encoding.UTF8.GetBytes(json); - await ws.SendAsync(new ArraySegment(buf), WebSocketMessageType.Text, true, CancellationToken.None); - } -} \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/AudioLabelService.cs b/src/EasyCon.Core/Capabilities/AudioLabelService.cs new file mode 100644 index 00000000..8d7e59f6 --- /dev/null +++ b/src/EasyCon.Core/Capabilities/AudioLabelService.cs @@ -0,0 +1,89 @@ +using EasyCon.Capture.Audio; + +namespace EasyCon.Core.Capabilities; + +/// Desktop adapter borrowing the GUI's game-audio source and resolving script-local labels. +public sealed class AudioLabelService(IAudioCaptureSource source, string? scriptDirectory) : IAudioService +{ + public IAudioSession BeginRun(CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(scriptDirectory)) + throw new InvalidOperationException("请先保存脚本;声音标签从脚本目录的 audiolabel 文件夹加载。"); + return new Session(source, Path.GetFullPath(scriptDirectory), token); + } + + private sealed class Session(IAudioCaptureSource source, string directory, CancellationToken runToken) : IAudioSession + { + // The script thread owns the dictionary; each recognition owns its background consumer. + private readonly Dictionary _loaded = new(StringComparer.Ordinal); + private bool _disposed; + + public void Load(string name, CancellationToken token) + { + CheckHealth(token); + if (!AudioLabelStore.IsValidName(name)) + throw new ArgumentException("声音标签名称无效,不能包含路径或扩展目录。", nameof(name)); + if (_loaded.ContainsKey(name)) + throw new InvalidOperationException($"声音标签“{name}”已加载;请先 AUDIO_UNLOAD,再 AUDIO_LOAD 开始新一轮。"); + string path = Path.Combine(directory, "audiolabel", name + ".audiolabel", "label.json"); + try + { + AudioLabel label = AudioLabelStore.Load(path); + if (!string.Equals(label.Metadata.Name, name, StringComparison.Ordinal)) + throw new InvalidDataException("目录名称与标签元数据中的名称不一致。"); + AudioMatchTemplate template = AudioMatchTemplate.Create(label.Clip, token) + ?? throw new InvalidDataException("标签没有足够的声音特征。"); + token.ThrowIfCancellationRequested(); + _loaded.Add(name, new AudioRecognition(source, template, runToken)); + } + catch (Exception ex) when (ex is IOException or ArgumentException or System.Text.Json.JsonException) + { + throw new InvalidOperationException($"声音标签“{name}”无法加载:{path};{ex.Message}", ex); + } + } + + public int Score(string labelName, CancellationToken token) + { + CheckHealth(token); + return GetLoaded(labelName).Score; + } + + public void Unload(string labelName, CancellationToken token) + { + CheckHealth(token); + AudioRecognition recognition = GetLoaded(labelName); + _loaded.Remove(labelName); + recognition.Dispose(); + } + + private AudioRecognition GetLoaded(string name) + => _loaded.TryGetValue(name, out AudioRecognition? recognition) ? recognition + : throw new InvalidOperationException($"声音标签“{name}”尚未加载或已卸载;请先 AUDIO_LOAD。"); + + public void CheckHealth(CancellationToken token) + { + ObjectDisposedException.ThrowIf(_disposed, this); + runToken.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); + foreach ((string name, AudioRecognition recognition) in _loaded) + { + try { recognition.CheckHealth(); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"声音标签“{name}”后台识别失败:{ex.Message}", ex); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (AudioRecognition recognition in _loaded.Values) + recognition.Dispose(); + _loaded.Clear(); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/CapabilityAdapters.cs b/src/EasyCon.Core/Capabilities/CapabilityAdapters.cs index d6bb9439..18ca52da 100644 --- a/src/EasyCon.Core/Capabilities/CapabilityAdapters.cs +++ b/src/EasyCon.Core/Capabilities/CapabilityAdapters.cs @@ -1,5 +1,8 @@ +using EasyCon.Capture; +using EasyCon.Core.Services; using EasyCon.Script; using EasyScript; +using OpenCvSharp; namespace EasyCon.Core.Capabilities; @@ -22,11 +25,15 @@ public void ClickStick(GamePadKey key, byte x, byte y, int duration, Cancellatio } /// → 适配。 -public sealed class ConsoleIoAdapter(IIoAdapter io) : IConsoleIo +public sealed class ConsoleIoAdapter(IIoAdapter io, IAlertService? alertService = null) : IConsoleIo { public void Print(string message, bool newline = true) => io.Print(message, newline); - public void Alert(string message) => io.Alert(message); + public void Alert(string message) + { + io.Alert(message); + alertService?.Dispatch(message); + } } /// ARG/APP 宿主环境缺省实现。 @@ -37,10 +44,35 @@ public sealed class HostEnvironment(string[] args, string appDir) : IHostEnviron public string AppDir { get; } = appDir; } -/// → 适配(P1 过渡件)。 +/// +/// → 适配(P1 过渡件)。 +/// 旧委托以「采集卡检查异常」等哨兵字符串传达失败,本适配器按端口契约归一为 null +/// (否则哨兵会被下游当成 Base64 图像,报出无从排查的解码错误);帧序号不可知,恒为 null。 +/// public sealed class DelegateCaptureSource(FrameDelegate frame) : ICaptureSource { - public string? CaptureFrame(int x, int y, int width, int height) => frame(x, y, width, height); + public string? CaptureFrame(int x, int y, int width, int height) + { + var result = frame(x, y, width, height); + return result is null or FrameDelegateFactory.NoFrameError or FrameDelegateFactory.RoiError + ? null + : result; + } +} + +/// +/// (最新帧 + 单调帧号)→ 适配。 +/// 相比委托适配多出 :编排图的「等待新帧」与慢感知 everyFrames 依赖它。 +/// +public sealed class FrameStoreCaptureSource(FrameStore store) : ICaptureSource +{ + public long? FrameIndex => store.FrameCount; + + public string? CaptureFrame(int x, int y, int width, int height) + { + using var lease = store.AcquireLatest(); + return FrameDelegateFactory.CropToBase64(lease?.Mat, x, y, width, height); + } } /// @@ -56,6 +88,35 @@ public int MatchLabel(string labelName) => roi?.Invoke(imageBase64, x, y, width, height); } +/// +/// 桌面参考视觉实现(__ROI__ 洞的能力缺省回落,同 先例): +/// OpenCV 解码 + 裁剪——与采集路径共享同一 +/// 裁剪核(采集形态即本核按坐标裁帧)。MatchLabel 无匹配器语义(抛错)与适配器一致。 +/// +public sealed class DesktopVisionService : IVisionService +{ + public static readonly DesktopVisionService Instance = new(); + + DesktopVisionService() + { + } + + public string? Crop(string imageBase64, int x, int y, int width, int height) + { + try + { + using var mat = Cv2.ImDecode(Convert.FromBase64String(imageBase64), ImreadModes.Color); + return FrameDelegateFactory.CropToBase64(mat, x, y, width, height); + } + catch + { + return null; + } + } + + public int MatchLabel(string labelName) => throw new Exception("图像标签匹配器未初始化"); +} + /// /// 旧 OCR 委托(OcrDelegate/OcrInitDelegate/ocrConf)→ 桥(P1 过渡件)。 /// Recognize 忽略 image(旧 OcrDelegate 自带采集,坐标即全图区域);P3 OCR 洞改走 IOcrService 后退役。 diff --git a/src/EasyCon.Core/Capabilities/CapabilitySet.cs b/src/EasyCon.Core/Capabilities/CapabilitySet.cs index e15bac80..9b1a7cdd 100644 --- a/src/EasyCon.Core/Capabilities/CapabilitySet.cs +++ b/src/EasyCon.Core/Capabilities/CapabilitySet.cs @@ -30,4 +30,7 @@ public sealed class CapabilitySet /// ONNX 通用推理(P5)。 public IInference? Inference { get; set; } + + /// Per-run game-audio matching; absence is an error, never a negative detection. + public IAudioService? Audio { get; set; } } \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/DnnInference.cs b/src/EasyCon.Core/Capabilities/DnnInference.cs index e30355fb..4bac17b8 100644 --- a/src/EasyCon.Core/Capabilities/DnnInference.cs +++ b/src/EasyCon.Core/Capabilities/DnnInference.cs @@ -4,13 +4,23 @@ namespace EasyCon.Core.Capabilities; /// -/// OpenCvSharp.Dnn 通用 ONNX 推理 (NET_LOAD/NET_RUN 实验函数的宿主后端,P5)。 +/// OpenCvSharp.Dnn 通用 ONNX 推理 (NET 族句柄函数的宿主后端)。 /// 实验面约定:输入/输出为一维 float(NCHW 展平,输入以 1×1×1×N blob 送入, /// 仅适配向量输入模型;卷积模型须自行展平并接受形状不匹配的失败语义)。 /// 模型为外部资产,不入库。Load/Run 失败返回 -1 / null,不抛异常。 /// public sealed class DnnInference : IInference { + static DnnInference() + { + // macOS 必需缓解(OpenCvSharp5 20261011 仍复现):OpenCV 线程池 worker 退出时 + // 偶发 objc over-release SEGV(AutoreleasePoolPage,DNN 负载下高概率)—— + // 单线程消除 worker 退出路径,DNN 推理改在调用线程执行。仅 macOS; + // Windows/Linux 保持默认并行。上游修复后可撤销。 + if (OperatingSystem.IsMacOS()) + Cv2.SetNumThreads(0); + } + readonly Dictionary _nets = new(); readonly object _gate = new(); int _nextSession; @@ -39,36 +49,147 @@ public int Load(string modelPath) } } - public float[]? Run(int session, float[] input) + public void Unload(int session) + { + lock (_gate) + { + if (_nets.Remove(session, out var net)) + net.Dispose(); + } + } + + // ---- 输出形状跟踪(NET_ARGMAX / NET_ROWS 按行解码)---- + + /// + public int[]? LastOutputShape { get; private set; } + + static int[] ShapeOf(Mat m) + { + var dims = m.Dims; + var shape = new int[dims]; + for (int i = 0; i < dims; i++) + shape[i] = m.Size(i); + return shape; + } + + // ---- 句柄协议(NET_IMAGE / NET_RUNH / NET_SCALE / NET_FREE):大数组驻留宿主侧 ---- + + readonly Dictionary _tensors = new(); + int _nextTensor; + + /// + public int HoldTensor(float[] data, int[] shape) + { + if (data.Length == 0 || shape.Length == 0) + return -1; + long total = 1; + foreach (var dim in shape) + { + if (dim <= 0) return -1; + total *= dim; + } + if (total != data.Length) + return -1; + + lock (_gate) + { + if (_disposed) return -1; + int handle = ++_nextTensor; + _tensors[handle] = (data, shape); + return handle; + } + } + + /// + public void FreeTensor(int handle) + { + lock (_gate) + _tensors.Remove(handle); + } + + /// + public float[]? RunHeld(int session, int tensorHandle) { Net? net; + float[] data; + int[] shape; lock (_gate) { - if (_disposed || !_nets.TryGetValue(session, out net)) + if (_disposed + || !_nets.TryGetValue(session, out net) + || !_tensors.TryGetValue(tensorHandle, out var tensor)) return null; + data = tensor.Data; + shape = tensor.Shape; } try { - // 1×N 向量输入:从托管数组复制进 Mat,避免固定输入数组 - using var blob = new Mat(1, input.Length, MatType.CV_32FC1); - MarshalCopy(input, blob); + using var blob = Mat.FromPixelData(shape, MatType.CV_32FC1, data); net.SetInput(blob); using var output = net.Forward(); + LastOutputShape = ShapeOf(output); return Flatten(output); } catch { + LastOutputShape = null; return null; } } - public void Unload(int session) + /// 按 (scale, offset) 逐元素变换张量,返回新句柄(原张量不变)。 + public int TransformTensor(int handle, float scale, float offset) { lock (_gate) { - if (_nets.Remove(session, out var net)) - net.Dispose(); + if (_disposed || !_tensors.TryGetValue(handle, out var tensor)) + return -1; + var result = new float[tensor.Data.Length]; + for (int i = 0; i < tensor.Data.Length; i++) + result[i] = tensor.Data[i] * scale + offset; + int newHandle = ++_nextTensor; + _tensors[newHandle] = (result, tensor.Shape); + return newHandle; + } + } + + /// + /// 逐通道归一化 (x - mean[c]) / std[c]。通道平面布局按 CHW(N=1):plane = 总长/通道数, + /// c = (i / plane) % channels。std 含 0 或长度不匹配 → -1(优雅失败,不产 NaN)。 + /// + public int Normalize(int handle, float[] mean, float[] std) + { + lock (_gate) + { + if (_disposed || !_tensors.TryGetValue(handle, out var tensor) + || mean.Length == 0 || std.Length == 0) + return -1; + + int channels = tensor.Shape.Length >= 4 ? tensor.Shape[1] + : tensor.Shape.Length == 3 ? tensor.Shape[0] + : 1; + if (tensor.Data.Length % channels != 0) + return -1; + if (mean.Length != 1 && mean.Length != channels) + return -1; + if (std.Length != 1 && std.Length != channels) + return -1; + for (int c = 0; c < std.Length; c++) + if (std[c] == 0) + return -1; + + int plane = tensor.Data.Length / channels; + var result = new float[tensor.Data.Length]; + for (int i = 0; i < tensor.Data.Length; i++) + { + int c = mean.Length == 1 ? 0 : (i / plane) % channels; + int s = std.Length == 1 ? 0 : (i / plane) % channels; + result[i] = (tensor.Data[i] - mean[c]) / std[s]; + } + int newHandle = ++_nextTensor; + _tensors[newHandle] = (result, tensor.Shape); + return newHandle; } } diff --git a/src/EasyCon.Core/Capabilities/EngineCacheOcrService.cs b/src/EasyCon.Core/Capabilities/EngineCacheOcrService.cs index ec3d96c9..38da23cd 100644 --- a/src/EasyCon.Core/Capabilities/EngineCacheOcrService.cs +++ b/src/EasyCon.Core/Capabilities/EngineCacheOcrService.cs @@ -51,8 +51,10 @@ public string Recognize(ImageRef image, OcrQuery query) Cache.LastConfidence = (int)(result.Confidence * 100); return result.Text; } - catch + catch (Exception ex) { + // 静默返回空串会让脚本 OCR「成功但永远识别不到」;至少把原因送进诊断通道 + Logging.CoreLog.Error($"OCR 识别失败 (backend={Backend}, lang={query.Language}): {ex.Message}"); return ""; } } diff --git a/src/EasyCon.Core/Capabilities/IAudioService.cs b/src/EasyCon.Core/Capabilities/IAudioService.cs new file mode 100644 index 00000000..7821143c --- /dev/null +++ b/src/EasyCon.Core/Capabilities/IAudioService.cs @@ -0,0 +1,18 @@ +namespace EasyCon.Core.Capabilities; + +/// Factory for isolated script runs. Only explicitly loaded labels subscribe to audio. +public interface IAudioService +{ + IAudioSession BeginRun(CancellationToken token); +} + +/// Owned by the execution bridge, never by the shared capture device. +public interface IAudioSession : IDisposable +{ + void Load(string labelName, CancellationToken token); + /// Cumulative highest completed integer score, 0–100, rounded down; initially zero. Reading never consumes it. + int Score(string labelName, CancellationToken token); + void Unload(string labelName, CancellationToken token); + /// Surface background failures even when a script finishes without reading again. + void CheckHealth(CancellationToken token); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/ICaptureSource.cs b/src/EasyCon.Core/Capabilities/ICaptureSource.cs index 56750756..17721ee9 100644 --- a/src/EasyCon.Core/Capabilities/ICaptureSource.cs +++ b/src/EasyCon.Core/Capabilities/ICaptureSource.cs @@ -5,6 +5,19 @@ namespace EasyCon.Core.Capabilities; /// public interface ICaptureSource { - /// 截取区域帧,返回 Base64 PNG;负宽高按宿主约定取整帧。不可用返回 null。 + /// + /// 截取区域帧,返回 Base64 PNG;不可用返回 null。 + /// ROI 语义:负值=整帧(脚本 ABI 哨兵,仅脚本通路使用); + /// 0 尺寸 ROI 收敛为空,返回 null。新代码取整帧请用 。 + /// string? CaptureFrame(int x, int y, int width, int height); + + /// 整帧语义的显式入口:调用方无需知道负数哨兵约定。 + string? CaptureFullFrame() => CaptureFrame(-1, -1, -1, -1); + + /// + /// 最近发布帧的单调序号(每发布一帧 +1);源不提供序号时为 null。 + /// 仅服务 PC 侧的「等待新帧」与慢感知 everyFrames 判定,不进脚本语义、不上 MCU。 + /// + long? FrameIndex => null; } \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/IConsoleIo.cs b/src/EasyCon.Core/Capabilities/IConsoleIo.cs index 8ac56ab0..df8c498e 100644 --- a/src/EasyCon.Core/Capabilities/IConsoleIo.cs +++ b/src/EasyCon.Core/Capabilities/IConsoleIo.cs @@ -1,8 +1,8 @@ namespace EasyCon.Core.Capabilities; /// -/// 控制台输出能力(PRINT/ALERT)。 -/// stdin 在桌面参考语义中走进程 Console(EcxHost.ReadLine),不经本接口。 +/// 控制台输入输出能力(PRINT/ALERT/FREAD stdin/BEEP)。 +/// ReadLine/Beep 提供默认实现(回落进程 Console);GUI 宿主可覆写以接入终端控件。 /// public interface IConsoleIo { @@ -11,4 +11,10 @@ public interface IConsoleIo /// 发送警告(ALERT syscall 落点)。 void Alert(string message); + + /// 读取一行 stdin(FREAD 行读落点);EOF 返回空串。 + virtual string ReadLine() => Console.ReadLine() ?? ""; + + /// 蜂鸣(BEEP syscall 落点);GUI 宿主可覆写为音频后端。 + virtual void Beep(int frequency, int durationMs) => Console.Beep(frequency, durationMs); } \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/IInference.cs b/src/EasyCon.Core/Capabilities/IInference.cs index 2c70d21c..7860ff46 100644 --- a/src/EasyCon.Core/Capabilities/IInference.cs +++ b/src/EasyCon.Core/Capabilities/IInference.cs @@ -1,7 +1,7 @@ namespace EasyCon.Core.Capabilities; /// -/// ONNX 通用推理服务(P5 落地 NET_LOAD/NET_RUN 实验函数的宿主后端,经 EzCv.Dnn 多后端执行)。 +/// ONNX 通用推理服务(NET 族句柄函数的宿主后端,经 EzCv.Dnn 多后端执行)。 /// 模型文件为外部资产,不入库。 /// public interface IInference : IDisposable @@ -9,9 +9,37 @@ public interface IInference : IDisposable /// 加载 ONNX 模型,返回会话句柄;失败返回 -1。 int Load(string modelPath); - /// 执行推理:输入/输出为扁平 float 张量;失败返回 null。 - float[]? Run(int session, float[] input); + /// + /// 以显式形状持有输入张量(如 [1,3,320,320]),返回张量句柄;失败返回 -1。 + /// 大数组驻留宿主侧,脚本只见句柄(原生边界纯标量协议)。 + /// + int HoldTensor(float[] data, int[] shape); + + /// 释放张量句柄;句柄无效时静默。 + void FreeTensor(int handle); + + /// + /// 以持有的张量为输入执行推理;成功返回输出扁平数组(失败 null)。 + /// 与 的差异仅在输入来源(句柄 vs 数组)。 + /// + float[]? RunHeld(int session, int tensorHandle); + + /// 按 (scale, offset) 逐元素变换张量(t2 = t·scale + offset),返回新句柄;失败 -1。 + int TransformTensor(int handle, float scale, float offset); + + /// + /// 逐通道归一化:result[i] = (x[i] - mean[c]) / std[c]。通道号 c 由持有张量形状推出 + /// (rank≥4 取 shape[1],rank=3 取 shape[0],rank<3 退化为全局仿射); + /// mean/std 长度须为 1 或等于通道数。不可变风格,返回新句柄;失败 -1。 + /// + int Normalize(int handle, float[] mean, float[] std); /// 卸载会话(句柄失效)。 void Unload(int session); + + /// + /// 最近一次 Run/RunHeld 的输出形状(如 [1,T,C]);无输出历史时为 null。 + /// 供 NET_ARGMAX/NET_ROWS 做按行解码。 + /// + int[]? LastOutputShape { get; } } \ No newline at end of file diff --git a/src/EasyCon.Core/Capabilities/IOcrService.cs b/src/EasyCon.Core/Capabilities/IOcrService.cs index 6737ecde..b2b0f76b 100644 --- a/src/EasyCon.Core/Capabilities/IOcrService.cs +++ b/src/EasyCon.Core/Capabilities/IOcrService.cs @@ -6,7 +6,7 @@ namespace EasyCon.Core.Capabilities; /// public interface IOcrService : IDisposable { - /// 后端标识("tesseract" | "paddle-onnx" | "delegate")。 + /// 后端标识("tesseract" | "paddle-onnx" | "maa" | "delegate")。 string Backend { get; } /// 按具名配置初始化(幂等,重复调用以已初始化引擎为准);失败返回 false。 diff --git a/src/EasyCon.Core/Capabilities/IVisionService.cs b/src/EasyCon.Core/Capabilities/IVisionService.cs index 4039a5ca..a66bf9a2 100644 --- a/src/EasyCon.Core/Capabilities/IVisionService.cs +++ b/src/EasyCon.Core/Capabilities/IVisionService.cs @@ -3,7 +3,7 @@ namespace EasyCon.Core.Capabilities; /// 图像处理/标签匹配能力(EzCv 实现)。 public interface IVisionService { - /// 标签匹配:返回匹配到的标签索引,未找到返回 -1。 + /// 标签匹配:返回当前兼容 profile 的旧整数评分;未找到标签须报告错误。 int MatchLabel(string labelName); /// ROI 裁剪(Base64 PNG 入出);不支持返回 null。 diff --git a/src/EasyCon.Core/Capabilities/PaddleOnnxOcr.cs b/src/EasyCon.Core/Capabilities/PaddleOnnxOcr.cs index 19bd060e..45ea20fc 100644 --- a/src/EasyCon.Core/Capabilities/PaddleOnnxOcr.cs +++ b/src/EasyCon.Core/Capabilities/PaddleOnnxOcr.cs @@ -6,9 +6,8 @@ namespace EasyCon.Core.Capabilities; /// /// PaddleOCR det+rec ONNX 后端 (OpenCvSharp.Dnn 推理,D1 决策)。 -/// 模型为外部资产不入库,命名沿用 : -/// 检测 PP-OCRv5_mobile_det.onnx;识别 <lang>_rec.onnx(缺省回落 PP-OCRv5_mobile_rec.onnx), -/// 字符集 <lang>_dict.txt(缺省回落 ppocr_keys_v5.txt)。 +/// 模型为外部资产不入库;代码不绑定模型代际,命名沿用 部署约定: +/// 检测 det.onnx;识别 <lang>_rec.onnx;字符集 <lang>_dict.txt(模型与字典同代配对由部署保证)。 /// public sealed class PaddleOnnxOcr : EngineCacheOcrService { diff --git a/src/EasyCon.Core/Config/AlertConfig.cs b/src/EasyCon.Core/Config/AlertConfig.cs index 4a45b96a..a3a79e67 100644 --- a/src/EasyCon.Core/Config/AlertConfig.cs +++ b/src/EasyCon.Core/Config/AlertConfig.cs @@ -5,12 +5,23 @@ namespace EasyCon.Core.Config; public class AlertConfig { + public int schema_version { get; set; } public int timeout { get; set; } = 10; public List alerts { get; set; } = []; + + [JsonIgnore] + public string load_error { get; set; } = ""; } public class AlertItem { + public const string WebhookProvider = "webhook"; + public const string QqProvider = "qq"; + public const string QqDefaultName = "qq bot"; + + public string id { get; set; } = ""; + public string provider { get; set; } = WebhookProvider; + [JsonRequired] public string name { get; set; } = ""; @@ -20,10 +31,8 @@ public class AlertItem [DefaultValue("GET")] public string method { get; set; } = "GET"; - [JsonRequired] public string url { get; set; } = ""; - [JsonRequired] public string token { get; set; } = ""; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -34,4 +43,33 @@ public class AlertItem [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? variables { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public QQNotificationSettings? qq { get; set; } + + [JsonIgnore] + public bool IsQq => string.Equals(provider, QqProvider, StringComparison.OrdinalIgnoreCase); + + public static AlertItem CreateQq() => new() + { + id = Guid.NewGuid().ToString("N"), + provider = QqProvider, + name = QqDefaultName, + qq = new QQNotificationSettings(), + }; + + public AlertItem Clone() => new() + { + id = id, + provider = provider, + name = name, + enable = enable, + method = method, + url = url, + token = token, + body = body, + headers = headers == null ? null : new Dictionary(headers), + variables = variables == null ? null : new Dictionary(variables), + qq = qq?.Clone(), + }; } \ No newline at end of file diff --git a/src/EasyCon.Core/Config/AlertDispatcher.cs b/src/EasyCon.Core/Config/AlertDispatcher.cs index dd9e1e92..ed6ff3bb 100644 --- a/src/EasyCon.Core/Config/AlertDispatcher.cs +++ b/src/EasyCon.Core/Config/AlertDispatcher.cs @@ -1,44 +1,120 @@ +using EasyCon.Core.Notifications; using System.Text; using System.Text.Json; using System.Web; namespace EasyCon.Core.Config; -public class AlertDispatcher +public class AlertDispatcher : IDisposable { - private readonly List _items; + private readonly object _sync = new(); + private List _items = []; private readonly HttpClient _http; + private readonly bool _ownsHttp; + private readonly Func _qqFactory; + private readonly Dictionary _qqChannels = []; + private bool _disposed; + + public Func? ImageProvider { get; set; } public event EventHandler OnResult; - public AlertDispatcher(AlertConfig config) + public AlertDispatcher(AlertConfig config, HttpClient? http = null, + Func? qqFactory = null) { - _http = new HttpClient { Timeout = TimeSpan.FromSeconds(config.timeout) }; - _items = config.alerts; + _ownsHttp = http == null; + _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(config.timeout) }; + _qqFactory = qqFactory ?? (settings => new QQNotificationService(settings: settings, persistSettings: false)); + UpdateConfiguration(config); } - public async Task DispatchAsync(string content, string title = "伊机控消息") + public void UpdateConfiguration(AlertConfig config) { - var enabled = _items.Where(a => a.enable).ToList(); - if (enabled.Count == 0) return; - - var tasks = enabled.Select(item => SendAsync(item, content, title)); - var results = await Task.WhenAll(tasks); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _items = [.. config.alerts.Select(item => item.Clone())]; + HashSet activeIds = []; + foreach (AlertItem item in _items.Where(item => item.IsQq)) + { + if (item.id.Length == 0 || !activeIds.Add(item.id)) + throw new InvalidOperationException("QQ 推送配置缺少唯一标识。"); + QQNotificationSettings settings = item.qq ?? new(); + settings.enabled = item.enable; + if (_qqChannels.TryGetValue(item.id, out QqChannel? existing)) + { + QQNotificationSettings previous = existing.Service.Settings; + if (previous.app_id == settings.app_id && previous.secret == settings.secret) + { + existing.Name = item.name; + existing.Service.Update(next => + { + next.enabled = settings.enabled; + next.attach_image = settings.attach_image; + next.user_enabled = settings.user_enabled; + next.group_enabled = settings.group_enabled; + next.user_openid = settings.user_openid; + next.group_openid = settings.group_openid; + }, save: false); + continue; + } + existing.Service.Dispose(); + } + QqChannel channel = new(item.name, _qqFactory(settings)); + channel.Service.Result += result => OnResult?.Invoke(this, $"[{channel.Name}] {result}"); + channel.Service.Error += result => OnResult?.Invoke(this, $"[{channel.Name}] {result}"); + _qqChannels[item.id] = channel; + } + foreach (string id in _qqChannels.Keys.Where(id => !activeIds.Contains(id)).ToArray()) + { + _qqChannels[id].Service.Dispose(); + _qqChannels.Remove(id); + } + } + } - foreach (var result in results) + public async Task DispatchAsync(string content, string title = "伊机控消息", CancellationToken cancellationToken = default, + byte[]? image = null) + { + cancellationToken.ThrowIfCancellationRequested(); + List> tasks = []; + lock (_sync) { - OnResult?.Invoke(this, result); + if (_disposed) + return; + foreach (AlertItem item in _items.Where(item => item.enable)) + { + if (item.IsQq) + { + QQNotificationService service = _qqChannels[item.id].Service; + service.ImageProvider = ImageProvider; + tasks.Add(WaitForQqAsync(service.DispatchAsync(content, title, image, cancellationToken))); + } + else + { + tasks.Add(SendAsync(item, content, title, cancellationToken)); + } + } } + string[] results = await Task.WhenAll(tasks).ConfigureAwait(false); + foreach (string result in results.Where(result => result.Length > 0)) + OnResult?.Invoke(this, result); } - private async Task SendAsync(AlertItem item, string content, string title) + private static async Task WaitForQqAsync(Task dispatch) + { + await dispatch.ConfigureAwait(false); + return ""; + } + + private async Task SendAsync(AlertItem item, string content, string title, CancellationToken cancellationToken) { try { var url = ReplaceVariables(item.url, item.token, item.variables, content, title); var method = item.method.Equals("POST", StringComparison.OrdinalIgnoreCase) ? HttpMethod.Post : HttpMethod.Get; - var request = new HttpRequestMessage(method, url); + using var request = new HttpRequestMessage(method, url); if (item.headers != null) { @@ -68,14 +144,18 @@ private async Task SendAsync(AlertItem item, string content, string titl request.Content = new StringContent(body, Encoding.UTF8, contentType); } - var response = await _http.SendAsync(request); - var respBody = await response.Content.ReadAsStringAsync(); + using var response = await _http.SendAsync(request, cancellationToken); + var respBody = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) return $"[{item.name}] 推送成功"; else return $"[{item.name}] 推送失败: HTTP {(int)response.StatusCode} - {respBody}"; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return $"[{item.name}] 推送失败: {ex.Message}"; @@ -100,4 +180,25 @@ private static string ReplaceVariables(string template, string token, Dictionary return result; } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + foreach (QqChannel channel in _qqChannels.Values) + channel.Service.Dispose(); + _qqChannels.Clear(); + if (_ownsHttp) + _http.Dispose(); + } + } + + private sealed class QqChannel(string name, QQNotificationService service) + { + public string Name { get; set; } = name; + public QQNotificationService Service { get; } = service; + } } \ No newline at end of file diff --git a/src/EasyCon.Core/Config/AppPaths.cs b/src/EasyCon.Core/Config/AppPaths.cs index dfa14497..a3cc11ad 100644 --- a/src/EasyCon.Core/Config/AppPaths.cs +++ b/src/EasyCon.Core/Config/AppPaths.cs @@ -9,6 +9,7 @@ public static class AppPaths public static string ConfigFile => Path.Combine(ConfigDir, "config.json"); public static string AlertConfig => Path.Combine(ConfigDir, "alert.json"); + public static string QqNotificationConfig => Path.Combine(ConfigDir, "qq-notifications.json"); public static string KeyMappingConfig => Path.Combine(ConfigDir, "keymapping.json"); public static string ModelsConfig => Path.Combine(ConfigDir, "models.json"); public static string McpConfig => Path.Combine(ConfigDir, "mcp.json"); @@ -23,14 +24,31 @@ public static class AppPaths private static string InitSubDir(string parent, string name) { var dir = Path.Combine(parent, name); - Directory.CreateDirectory(dir); + try + { + Directory.CreateDirectory(dir); + } + catch + { + // 目录创建失败时回退 %TEMP%,避免静态构造抛异常导致启动即崩 + dir = Path.Combine(Path.GetTempPath(), _appName, name); + Directory.CreateDirectory(dir); + } return dir; } private static string InitDir(Environment.SpecialFolder folder) { var dir = Path.Combine(Environment.GetFolderPath(folder), _appName); - Directory.CreateDirectory(dir); + try + { + Directory.CreateDirectory(dir); + } + catch + { + dir = Path.Combine(Path.GetTempPath(), _appName); + Directory.CreateDirectory(dir); + } return dir; } } \ No newline at end of file diff --git a/src/EasyCon.Core/Config/ConfigManager.Alert.cs b/src/EasyCon.Core/Config/ConfigManager.Alert.cs new file mode 100644 index 00000000..bb24f8d5 --- /dev/null +++ b/src/EasyCon.Core/Config/ConfigManager.Alert.cs @@ -0,0 +1,283 @@ +#nullable enable + +using System.Text.Json; + +namespace EasyCon.Core.Config; + +public static partial class ConfigManager +{ + private static readonly object _alertLock = new(); + private static readonly Dictionary<(string Path, string Id), QQNotificationSettings> _qqSessions = []; + private static readonly Dictionary _alertMigrationIds = []; + private static readonly Dictionary _alertSaveErrors = []; + + public static event Action? AlertConfigChanged; + + public static AlertConfig CreateDefaultAlert() => new() + { + schema_version = 1, + alerts = + [ + new AlertItem + { + name = "PushPlus", + url = "https://www.pushplus.plus/send/{{token}}?content={{content}}&title={{title}}", + }, + new AlertItem + { + name = "Bark", + url = "https://api.day.app/{{token}}/{{title}}/{{content}}?group={{group}}&icon={{icon}}", + variables = new() { ["group"] = "伊机控", ["icon"] = "https://avatars.githubusercontent.com/u/107608104?s=48&v=4" }, + }, + new AlertItem + { + name = "自定义Webhook", + method = "POST", + url = "https://example.com/webhook", + headers = new() { ["Authorization"] = "Bearer {{token}}", ["Content-Type"] = "application/json" }, + body = "{\"msg\":\"{{content}}\"}", + variables = new() { ["chat_id"] = "" }, + }, + AlertItem.CreateQq(), + ], + }; + + public static AlertConfig LoadAlert(string? path = null, string? legacyQqPath = null) + { + path = Path.GetFullPath(path ?? AppPaths.AlertConfig); + lock (_alertLock) + { + AlertConfig config; + string? source; + try + { + source = File.Exists(path) ? File.ReadAllText(path) : null; + config = source == null ? CreateDefaultAlert() + : JsonSerializer.Deserialize(source, _jsonReadOptions) + ?? throw new JsonException("推送配置不能为空。"); + NormalizeAlertConfiguration(config); + } + catch (Exception ex) + { + return new AlertConfig { schema_version = 1, load_error = ReportAlertLoadFailure(path, ex) }; + } + _alertSaveErrors.Remove(path); + bool needsMigration = source == null || config.schema_version < 1; + if (config.schema_version < 1 && !config.alerts.Any(item => item.IsQq)) + config.alerts.Add(AlertItem.CreateQq()); + string[] originalIds = [.. config.alerts.Select(item => item.id)]; + // 写入失败后的重试沿用同一批标识,避免运行中的通道被当作删除。 + if (_alertMigrationIds.TryGetValue(path, out (string? Source, string[] Ids) migration) + && migration.Source == source && migration.Ids.Length == config.alerts.Count) + { + for (int i = 0; i < config.alerts.Count; i++) + config.alerts[i].id = migration.Ids[i]; + } + _ = EnsureAlertIds(config); + bool needsSave = needsMigration || !originalIds.SequenceEqual(config.alerts.Select(item => item.id)); + if (needsSave) + { + config.schema_version = 1; + _alertMigrationIds[path] = (source, [.. config.alerts.Select(item => item.id)]); + } + if (needsMigration) + { + AlertItem? item = config.alerts.FirstOrDefault(item => item.IsQq); + string legacyPath = legacyQqPath ?? (path == AppPaths.AlertConfig ? AppPaths.QqNotificationConfig : ""); + if (item != null && legacyPath.Length > 0) + { + try + { + // 一次读取成功后才导入,不能把读取失败返回的默认值当作迁移完成。 + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(legacyPath)); + QQNotificationSettings imported = document.RootElement.Deserialize(_jsonReadOptions) + ?? throw new JsonException("旧 QQ 配置不能为空。"); + NormalizeQqSettings(imported); + item.qq = imported; + item.enable = document.RootElement.TryGetProperty("enabled", out JsonElement enabled) + && enabled.ValueKind == JsonValueKind.True; + } + catch (FileNotFoundException) + { + // 没有旧配置时无需导入。 + } + catch (DirectoryNotFoundException) + { + // 没有旧配置目录时无需导入。 + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + config.load_error = "旧 QQ 配置读取失败,已暂停迁移和保存,请修复旧文件后重新打开设置:" + ex.Message; + _alertSaveErrors[path] = config.load_error; + ConfigErrorReported?.Invoke(legacyPath, config.load_error); + needsSave = false; + } + } + } + foreach (AlertItem item in config.alerts.Where(item => item.IsQq)) + { + if (item.name == "QQ 图片通知") + item.name = AlertItem.QqDefaultName; + QQNotificationSettings settings = item.qq ??= new(); + if (_qqSessions.TryGetValue((path, item.id), out QQNotificationSettings? session) + && session.secret.Length > 0 && session.app_id == settings.app_id + && session.protected_secret == settings.protected_secret) + { + settings.secret = session.secret; + settings.verified = session.verified; + } + else if (settings.remember_secret && settings.protected_secret.Length > 0) + { + try + { + settings.secret = QQNotificationSecretProtector.Unprotect(settings.protected_secret); + } + catch + { + settings.load_error = "保存的 QQ AppSecret 无法解密,请重新填写。"; + } + } + settings.enabled = item.enable = item.enable && settings.IsReady(); + } + if (needsSave) + { + // 先恢复会话密钥和启用状态,再发布迁移后的运行时配置。 + try + { + SaveAlert(config, path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + config.load_error = "推送配置迁移未能保存,已保留内存配置,请稍后重新保存:" + ex.Message; + } + } + return config; + } + } + + private static void NormalizeAlertConfiguration(AlertConfig config) + { + if (config.alerts == null || config.alerts.Any(item => item == null)) + throw new JsonException("推送列表及其配置项不能为空。"); + foreach (AlertItem item in config.alerts) + { + item.provider ??= AlertItem.WebhookProvider; + item.name ??= item.IsQq ? AlertItem.QqDefaultName : ""; + item.method ??= "GET"; + item.url ??= ""; + item.token ??= ""; + item.body ??= ""; + if (item.headers?.Values.Any(value => value == null) == true + || item.variables?.Values.Any(value => value == null) == true) + throw new JsonException("推送配置的请求头和变量值不能为空。"); + if (item.qq != null) + NormalizeQqSettings(item.qq); + } + } + + private static void NormalizeQqSettings(QQNotificationSettings settings) + { + settings.app_id ??= ""; + settings.secret ??= ""; + settings.protected_secret ??= ""; + settings.user_openid ??= ""; + settings.group_openid ??= ""; + settings.load_error ??= ""; + } + + private static string ReportAlertLoadFailure(string path, Exception error) + { + string message; + if (error is JsonException) + { + string backup = $"{path}.corrupt-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}"; + try + { + File.Copy(path, backup); + message = $"推送配置解析失败,已备份到 {backup},原文件未修改:{error.Message}"; + _alertSaveErrors.Remove(path); + } + catch (Exception backupError) + { + message = $"推送配置解析失败,无法创建备份,已暂停保存,请修复文件后重新打开设置:{backupError.Message}"; + _alertSaveErrors[path] = message; + } + } + else + { + message = $"推送配置读取失败,原文件未修改,请排除文件占用或权限问题后重新打开设置:{error.Message}"; + _alertSaveErrors[path] = message; + } + ConfigErrorReported?.Invoke(path, message); + return message; + } + + public static void SaveAlert(AlertConfig config, string? path = null) + { + path = Path.GetFullPath(path ?? AppPaths.AlertConfig); + AlertConfig snapshot = new() { schema_version = 1, timeout = config.timeout, alerts = [.. config.alerts.Select(item => item.Clone())] }; + lock (_alertLock) + { + if (_alertSaveErrors.TryGetValue(path, out string? loadError)) + throw new InvalidOperationException(loadError); + NormalizeAlertConfiguration(snapshot); + HashSet ids = EnsureAlertIds(snapshot); + foreach (AlertItem item in snapshot.alerts) + { + if (!item.IsQq) + continue; + QQNotificationSettings settings = item.qq ??= new(); + settings.enabled = item.enable; + settings.PrepareSecretForSave(); + } + string temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(snapshot, _jsonOptions)); + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + foreach (var key in _qqSessions.Keys.Where(key => key.Path == path && !ids.Contains(key.Id)).ToArray()) + _qqSessions.Remove(key); + foreach (AlertItem item in snapshot.alerts.Where(item => item.IsQq)) + _qqSessions[(path, item.id)] = item.qq!.Clone(); + _alertMigrationIds.Remove(path); + } + AlertConfigChanged?.Invoke(snapshot); + } + + private static HashSet EnsureAlertIds(AlertConfig config) + { + HashSet ids = []; + foreach (AlertItem item in config.alerts) + { + if (string.IsNullOrWhiteSpace(item.id) || !ids.Add(item.id)) + { + item.id = Guid.NewGuid().ToString("N"); + ids.Add(item.id); + } + } + return ids; + } + + public static QQNotificationSettings LoadQqNotification() => + LoadAlert().alerts.FirstOrDefault(item => item.IsQq)?.qq ?? new QQNotificationSettings(); + + public static void SaveQqNotification(QQNotificationSettings settings) + { + AlertConfig config = LoadAlert(); + AlertItem? item = config.alerts.FirstOrDefault(item => item.IsQq); + if (item == null) + { + item = AlertItem.CreateQq(); + config.alerts.Add(item); + } + item.qq = settings.Clone(); + item.enable = settings.enabled; + SaveAlert(config); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Config/ConfigManager.cs b/src/EasyCon.Core/Config/ConfigManager.cs index f48aee78..accf2352 100644 --- a/src/EasyCon.Core/Config/ConfigManager.cs +++ b/src/EasyCon.Core/Config/ConfigManager.cs @@ -5,18 +5,32 @@ namespace EasyCon.Core.Config; -public static class ConfigManager +public static partial class ConfigManager { /// /// models.json 配置保存后触发,用于订阅方(如 AI Agent)刷新内存中的模型列表。 + /// 在保存调用的线程上同步触发;涉及 UI 的订阅方必须自行封送。 /// public static event Action? ModelsConfigChanged; /// /// mcp.json 配置保存后触发,用于订阅方(如 McpManager)差量重连 MCP 服务器。 + /// 在保存调用的线程上同步触发;涉及 UI 的订阅方必须自行封送。 /// public static event Action? McpConfigChanged; + /// + /// keymapping.json 配置保存后触发,用于订阅方(如 ControllerService)热应用新的按键映射。 + /// 在保存调用的线程上同步触发;订阅方操作非线程安全对象(如 SDL binder)时须自行封送。 + /// + public static event Action? KeyMappingChanged; + + /// + /// 配置文件损坏或默认配置写入失败时触发,参数为 (文件路径, 描述)。 + /// GUI 启动时应订阅并把消息写入日志,向用户暴露"配置已被重置"的事实。 + /// + public static event Action? ConfigErrorReported; + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true, @@ -42,19 +56,7 @@ public static KeyMappingConfig LoadKeyMapping() public static void SaveKeyMapping(KeyMappingConfig keyMapping) { Save(AppPaths.KeyMappingConfig, keyMapping); - } - - public static AlertConfig LoadAlert() - { - var path = AppPaths.AlertConfig; - if (!File.Exists(path)) - GenerateDefaultAlert(path); - return Load(path, _jsonReadOptions); - } - - public static void SaveAlert(AlertConfig config) - { - Save(AppPaths.AlertConfig, config); + KeyMappingChanged?.Invoke(); } public static ModelsConfig LoadModelsConfig() @@ -85,6 +87,11 @@ public static void SaveMcpConfig(McpConfig config) McpConfigChanged?.Invoke(); } + // ── 测试入口:绕过 AppPaths 静态路径,注入临时目录 ── + internal static T LoadFrom(string path, JsonSerializerOptions? options = null) where T : new() => Load(path, options); + internal static void SaveTo(string path, T data) => Save(path, data); + internal static void WriteDefaultTo(string path, string json) => WriteDefault(path, json); + private static T Load(string path, JsonSerializerOptions? options = null) where T : new() { if (!File.Exists(path)) @@ -93,63 +100,57 @@ public static void SaveMcpConfig(McpConfig config) { return JsonSerializer.Deserialize(File.ReadAllText(path), options) ?? new T(); } - catch + catch (Exception ex) { + // 损坏文件先备份再返回默认值:否则用户打开配置窗口点保存, + // 会用空白默认值静默覆盖掉原有数据(API Key、供应商等)。 + var backup = $"{path}.corrupt-{DateTime.Now:yyyyMMdd-HHmmss}"; + try + { + File.Copy(path, backup, overwrite: true); + } + catch + { + // 备份失败不阻断加载,消息里如实说明 + backup = path; + } + ConfigErrorReported?.Invoke(path, $"配置解析失败,已备份到 {backup} 并改用默认配置: {ex.Message}"); return new T(); } } + /// 按路径加锁:并发保存同一配置时串行化共享的 .tmp 临时文件与 Move 替换。 + static readonly System.Collections.Concurrent.ConcurrentDictionary SaveLocks = new(); + private static void Save(string path, T data) { - File.WriteAllText(path, JsonSerializer.Serialize(data, _jsonOptions)); + // 原子写:先写临时文件再替换,进程崩溃/断电不会留下截断的配置。 + // 后台线程(如 ControllerService)与 UI 线程可能并发保存同一配置, + // 无锁时会在同一个 .tmp 上交错,后完成的 Move 抛 FileNotFoundException。 + lock (SaveLocks.GetOrAdd(path, _ => new object())) + { + var tmp = $"{path}.tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(data, _jsonOptions)); + File.Move(tmp, path, overwrite: true); + } } - private static void GenerateDefaultAlert(string path) - { - var json = """ -{ - "timeout": 10, - "alerts": [ - { - "name": "PushPlus", - "enable": false, - "url": "https://www.pushplus.plus/send/{{token}}?content={{content}}&title={{title}}", - "token": "" - }, + /// 原子写入默认配置;失败经 上报而非抛出。 + private static void WriteDefault(string path, string json) { - "name": "Bark", - "enable": false, - "url": "https://api.day.app/{{token}}/{{title}}/{{content}}?group={{group}}&icon={{icon}}", - "token": "", - "variables": { - "group": "伊机控", - "icon": "https://avatars.githubusercontent.com/u/107608104?s=48&v=4" - } - }, - { - "name": "自定义Webhook", - "enable": false, - "method": "POST", - "url": "https://example.com/webhook", - "token": "", - "headers": { - "Authorization": "Bearer {{token}}", - "Content-Type": "application/json" - }, - "body": "{\"msg\":\"{{content}}\"}", - "variables": { - "chat_id": "" - } - } - ] -} -"""; - File.WriteAllText(path, json); + try + { + var tmp = $"{path}.tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, path, overwrite: true); + } + catch (Exception ex) + { + ConfigErrorReported?.Invoke(path, $"默认配置写入失败: {ex.Message}"); + } } - private static void GenerateDefaultModels(string path) - { - var json = """ + internal static string DefaultModelsJson { get; } = """ { "models": { "providers": { @@ -157,7 +158,7 @@ private static void GenerateDefaultModels(string path) "name": "面壁智能", "homePage": "https://modelbest.cn", "baseUrl": "https://api.modelbest.cn/v1", - "apiKey": "sk-pQ8L2zF3XmR5kY9wV4jB7hN1tC6vM0xG3aD5sH2bJ9lK4cZ8", + "apiKey": "sk-REPLACE_WITH_YOUR_KEY", "api": "openai-completions", "models": [ { @@ -176,7 +177,10 @@ private static void GenerateDefaultModels(string path) } } """; - File.WriteAllText(path, json); + + private static void GenerateDefaultModels(string path) + { + WriteDefault(path, DefaultModelsJson); } private static void GenerateDefaultMcp(string path) @@ -197,6 +201,6 @@ private static void GenerateDefaultMcp(string path) } } """; - File.WriteAllText(path, json); + WriteDefault(path, json); } } \ No newline at end of file diff --git a/src/EasyCon.Core/Config/ConfigState.cs b/src/EasyCon.Core/Config/ConfigState.cs index 4aa6a6c3..e5fba442 100644 --- a/src/EasyCon.Core/Config/ConfigState.cs +++ b/src/EasyCon.Core/Config/ConfigState.cs @@ -5,6 +5,10 @@ public record ConfigState public const string DefaultWelcomeText = "欢迎使用 easycon"; public string CaptureType { get; set; } = "ANY"; + [System.Text.Json.Serialization.JsonPropertyName("controller_profile")] + public ControllerProfile? ControllerProfile { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("capture_profile")] + public CaptureProfile? CaptureProfile { get; set; } public bool ShowControllerHelp { get; set; } = true; public bool EnableAutoCompletion { get; set; } = false; public bool AutoRunAfterFlash { get; set; } = false; @@ -24,4 +28,10 @@ public record ConfigState public double EditorFontSize { get; set; } = 14; public bool HighResolutionTiming { get; set; } = false; public string WelcomeText { get; set; } = DefaultWelcomeText; + + /// 界面语言(文化代码,如 zh_CN / en_US)。空值 = 默认 zh_CN。 + public string LanguageCode { get; set; } = ""; + + /// 工作树排序偏好。字符串值保持稳定,不依赖 Avalonia.Core 中的枚举序号。 + public string FileTreeSortMode { get; set; } = "name-asc"; } \ No newline at end of file diff --git a/src/EasyCon.Core/Config/HostProfiles.cs b/src/EasyCon.Core/Config/HostProfiles.cs new file mode 100644 index 00000000..53b4018f --- /dev/null +++ b/src/EasyCon.Core/Config/HostProfiles.cs @@ -0,0 +1,97 @@ +#nullable enable +using EasyCon.Capture; +using EasyDevice.Connection; +using OpenCvSharp; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EasyCon.Core.Config; + +public sealed record ControllerProfile(string? Endpoint = null, string Transport = "Serial") +{ + public static ControllerProfile FromEndpoint(string? endpoint) => new(endpoint, + endpoint == null ? nameof(ConnectionTransport.Serial) : ConnectionEndpoint.Parse(endpoint).Transport.ToString()); + + public ControllerProfile Validate() + { + if (!Enum.GetNames().Contains(Transport, StringComparer.Ordinal)) + throw new InvalidDataException($"不支持的手柄传输方式 {Transport}"); + if (Endpoint != null) + { + ConnectionEndpoint target; + try { target = ConnectionEndpoint.Parse(Endpoint); } + catch (ArgumentException ex) { throw new InvalidDataException(ex.Message, ex); } + if (target.Transport.ToString() != Transport) + throw new InvalidDataException("手柄地址与 profile 传输方式不一致"); + } + return this; + } +} +public sealed record CaptureProfile(CaptureSourceDescriptor? Source = null, int? Index = null, + string Backend = "ANY", FrameNormalization? Normalization = null, string? DisplayKey = null) +{ + public CaptureProfile Validate() + { + VideoCaptureAPIs backend = HostProfiles.ParseCaptureBackend(Backend); + if (Index < 0) throw new InvalidDataException("采集索引不能为负数"); + if (Source is { } source) + { + if (string.IsNullOrWhiteSpace(source.Id) || source.Index < 0 || + !Enum.IsDefined(source.Backend) || source.Backend == VideoCaptureAPIs.ANY) + throw new InvalidDataException("采集来源描述无效"); + if (CaptureSourceCatalog.ResolveBackend(backend) != source.Backend) + throw new InvalidDataException("采集来源的后端与 profile 后端矛盾"); + // 两者都是保存时的枚举快照;连接前通过稳定 ID 重新解析出的新索引允许不同。 + if (Index is { } index && index != source.Index) + throw new InvalidDataException("采集来源 ID 的缓存索引与 profile 索引矛盾"); + } + return this; + } +} + +/// GUI/CLI 共用的独立选择。覆盖一侧不会重新构造另一侧配置。 +public sealed record HostProfiles +{ + [JsonPropertyName("controller_profile")] + public ControllerProfile? Controller { get; init; } + [JsonPropertyName("capture_profile")] + public CaptureProfile? Capture { get; init; } + + public static HostProfiles Load(string path) => JsonSerializer.Deserialize(File.ReadAllText(path), + new JsonSerializerOptions(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }) + ?.Validate() ?? throw new InvalidDataException("设备配置为空"); + + public HostProfiles WithOverrides(string? port = null, int? captureIndex = null, string? backend = null) + { + Validate(); + if (captureIndex < 0) throw new InvalidDataException("采集索引不能为负数"); + bool backendChanged = backend != null && CaptureSourceCatalog.ResolveBackend(ParseCaptureBackend(backend)) != + CaptureSourceCatalog.ResolveBackend(ParseCaptureBackend(Capture?.Backend ?? "ANY")); + HostProfiles result = this with + { + Controller = port == null ? Controller : ControllerProfile.FromEndpoint(port), + Capture = captureIndex == null && backend == null ? Capture : (Capture ?? new()) with + { + Source = captureIndex != null || backendChanged ? null : Capture?.Source, + Index = captureIndex ?? (backendChanged ? null : Capture?.Index ?? Capture?.Source?.Index), + Backend = backend ?? Capture?.Backend ?? "ANY", + DisplayKey = captureIndex != null || backendChanged ? null : Capture?.DisplayKey, + }, + }; + return result.Validate(); + } + + public HostProfiles Validate() + { + Controller?.Validate(); + Capture?.Validate(); + return this; + } + + public static VideoCaptureAPIs ParseCaptureBackend(string? name) + => name != null && Enum.GetNames().Contains(name, StringComparer.OrdinalIgnoreCase) && + Enum.TryParse(name, true, out VideoCaptureAPIs backend) + ? backend : throw new InvalidDataException($"未知采集后端 {name}"); + + public VideoCaptureAPIs CaptureBackend() => ParseCaptureBackend(Capture?.Backend ?? "ANY"); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Config/KeyMappingConfig.cs b/src/EasyCon.Core/Config/KeyMappingConfig.cs index eacdd79c..4484770a 100644 --- a/src/EasyCon.Core/Config/KeyMappingConfig.cs +++ b/src/EasyCon.Core/Config/KeyMappingConfig.cs @@ -2,6 +2,11 @@ namespace EasyCon.Core.Config; public record KeyMappingConfig { + public const int CurrentSchemaVersion = 2; + + /// 持久化格式版本;0 = WinForms 虚拟键(VK)时代的旧文件。 + public int SchemaVersion { get; set; } + public int A { get; set; } = 76; // Keys.L public int B { get; set; } = 75; // Keys.K public int X { get; set; } = 73; // Keys.I diff --git a/src/EasyCon.Core/Config/QQNotificationSettings.cs b/src/EasyCon.Core/Config/QQNotificationSettings.cs new file mode 100644 index 00000000..6f166831 --- /dev/null +++ b/src/EasyCon.Core/Config/QQNotificationSettings.cs @@ -0,0 +1,234 @@ +#nullable enable + +using System.Text.Json.Serialization; + +namespace EasyCon.Core.Config; + +/// +/// QQ 开放平台机器人通知配置。AppSecret 只在内存中保留,持久化时由 +/// 写入 protected_secret。 +/// +public sealed class QQNotificationSettings +{ + public string app_id { get; set; } = ""; + + [JsonIgnore] + public string secret { get; set; } = ""; + + public string protected_secret { get; set; } = ""; + public bool remember_secret { get; set; } + public string user_openid { get; set; } = ""; + public string group_openid { get; set; } = ""; + public bool user_enabled { get; set; } = true; + public bool group_enabled { get; set; } + [JsonIgnore] + public bool enabled { get; set; } + + [JsonIgnore] + public string load_error { get; set; } = ""; + + /// 默认附带当前视频画面;没有画面时由宿主提供 Logo 作为回退。 + public bool attach_image { get; set; } = true; + + [JsonIgnore] + public bool verified { get; set; } + + public QQNotificationSettings Clone() + { + return new QQNotificationSettings + { + app_id = app_id, + secret = secret, + protected_secret = protected_secret, + remember_secret = remember_secret, + user_openid = user_openid, + group_openid = group_openid, + user_enabled = user_enabled, + group_enabled = group_enabled, + enabled = enabled, + attach_image = attach_image, + verified = verified, + load_error = load_error, + }; + } + + internal void PrepareSecretForSave() + { + // 无法解密且未重新填写时,保留原密文,避免保存其它推送项时丢失它。 + if (secret.Length > 0) + { + if (!OperatingSystem.IsWindows()) + remember_secret = false; + protected_secret = remember_secret ? QQNotificationSecretProtector.Protect(secret) : ""; + } + else if (!remember_secret) + { + protected_secret = ""; + } + } + + public IReadOnlyList Targets() + { + List result = []; + if (user_enabled) + { + if (string.IsNullOrWhiteSpace(user_openid)) + throw new InvalidOperationException("请先绑定勾选的 QQ 私聊接收方。"); + result.Add(new QQNotificationTarget(QQNotificationTargetKind.User, user_openid.Trim())); + } + + if (group_enabled) + { + if (string.IsNullOrWhiteSpace(group_openid)) + throw new InvalidOperationException("请先绑定勾选的 QQ 群聊接收方。"); + result.Add(new QQNotificationTarget(QQNotificationTargetKind.Group, group_openid.Trim())); + } + + if (result.Count == 0) + throw new InvalidOperationException("请至少勾选一个 QQ 接收方。"); + return result; + } + + public bool IsReady() + { + try + { + return !string.IsNullOrWhiteSpace(app_id) + && !string.IsNullOrWhiteSpace(secret) + && Targets().Count > 0; + } + catch (InvalidOperationException) + { + return false; + } + } +} + +public enum QQNotificationTargetKind +{ + User, + Group, +} + +public sealed record QQNotificationTarget(QQNotificationTargetKind Kind, string OpenId); + +/// Windows DPAPI 包装。非 Windows 环境不保存 AppSecret。 +public static class QQNotificationSecretProtector +{ + public static string Protect(string secret) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("当前平台不支持 Windows DPAPI。"); + + return Convert.ToBase64String(ProtectedData.Protect(secret)); + } + + public static string Unprotect(string value) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("当前平台不支持 Windows DPAPI。"); + + return ProtectedData.Unprotect(Convert.FromBase64String(value)); + } + + private static class ProtectedData + { + private const uint CryptProtectUiForbidden = 0x1; + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct DataBlob + { + public int cbData; + public nint pbData; + } + + [System.Runtime.InteropServices.DllImport("crypt32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern bool CryptProtectData( + ref DataBlob dataIn, + string? description, + nint entropy, + string? reserved, + nint prompt, + uint flags, + out DataBlob dataOut); + + [System.Runtime.InteropServices.DllImport("crypt32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern bool CryptUnprotectData( + ref DataBlob dataIn, + nint description, + nint entropy, + string? reserved, + nint prompt, + uint flags, + out DataBlob dataOut); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + private static extern nint LocalAlloc(uint flags, nuint bytes); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + private static extern nint LocalFree(nint memory); + + private const uint LmemFixed = 0x0000; + + public static byte[] Protect(string value) + { + byte[] input = System.Text.Encoding.UTF8.GetBytes(value); + nint inputPtr = LocalAlloc(LmemFixed, (nuint)input.Length); + if (inputPtr == 0) + throw new InvalidOperationException("无法分配 DPAPI 输入缓冲区。"); + + try + { + System.Runtime.InteropServices.Marshal.Copy(input, 0, inputPtr, input.Length); + DataBlob inputBlob = new() { cbData = input.Length, pbData = inputPtr }; + if (!CryptProtectData(ref inputBlob, "EasyCon QQ notification", 0, null, 0, CryptProtectUiForbidden, out DataBlob outputBlob)) + throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error()); + + try + { + byte[] output = new byte[outputBlob.cbData]; + System.Runtime.InteropServices.Marshal.Copy(outputBlob.pbData, output, 0, output.Length); + return output; + } + finally + { + LocalFree(outputBlob.pbData); + } + } + finally + { + LocalFree(inputPtr); + } + } + + public static string Unprotect(byte[] encrypted) + { + nint inputPtr = LocalAlloc(LmemFixed, (nuint)encrypted.Length); + if (inputPtr == 0) + throw new InvalidOperationException("无法分配 DPAPI 输入缓冲区。"); + + try + { + System.Runtime.InteropServices.Marshal.Copy(encrypted, 0, inputPtr, encrypted.Length); + DataBlob inputBlob = new() { cbData = encrypted.Length, pbData = inputPtr }; + if (!CryptUnprotectData(ref inputBlob, 0, 0, null, 0, CryptProtectUiForbidden, out DataBlob outputBlob)) + throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error()); + + try + { + byte[] output = new byte[outputBlob.cbData]; + System.Runtime.InteropServices.Marshal.Copy(outputBlob.pbData, output, 0, output.Length); + return System.Text.Encoding.UTF8.GetString(output); + } + finally + { + LocalFree(outputBlob.pbData); + } + } + finally + { + LocalFree(inputPtr); + } + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/EasyCon.Core.csproj b/src/EasyCon.Core/EasyCon.Core.csproj index 1ce8cb30..2d50e09e 100644 --- a/src/EasyCon.Core/EasyCon.Core.csproj +++ b/src/EasyCon.Core/EasyCon.Core.csproj @@ -1,14 +1,30 @@ - + true warnings + + + + + + + + + + + + + + + diff --git a/src/EasyCon.Core/Flow/FlowDeviceBridge.cs b/src/EasyCon.Core/Flow/FlowDeviceBridge.cs new file mode 100644 index 00000000..2ec6c153 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowDeviceBridge.cs @@ -0,0 +1,42 @@ +using EasyCon.Capture; +using EasyCon.Core.Capabilities; +using EasyCon.Core.Maa; + +namespace EasyCon.Core.Flow; + +/// +/// Flow 服务的设备桥:GUI 宿主把监控页已持有的采集源/单片机实例经委托接入, +/// 使画布 HTTP 后端、agent 的 flow_* 工具与 GUI 面板操作同一实例 +/// (一块采集卡/串口只开一次,连接状态两边实时一致)。 +/// 独立宿主(CLI serve)不传桥(null), 退回自持实例的原行为。 +/// +public class FlowDeviceBridge +{ + /// 真实宿主的唯一控制器;旧测试桥可省略。 + public IControllerSession? Controller { get; init; } + public FrameHub? Frames { get; init; } + public Func>? GetVideoSources { get; init; } + /// 视频源当前是否已连接(读宿主侧实时状态)。 + public required Func IsVideoConnected { get; init; } + + /// 按枚举索引连接视频源;返回错误消息(null = 成功)。API 后端由宿主配置决定(桥忽略 api 参数)。 + public required Func ConnectVideo { get; init; } + + /// 断开视频源(同步等待采集循环退出,可阻塞数秒;勿在 UI 线程调用)。 + public required Action DisconnectVideo { get; init; } + + /// 当前帧存储(未连接为 null);运行上下文据此包装 FrameStoreCaptureSource,保留帧号语义(等帧/慢感知依赖)。 + public required Func GetFrameStore { get; init; } + + /// 单片机当前是否已连接(读宿主侧实时状态;mock 连接不走桥)。 + public required Func IsMcuConnected { get; init; } + + /// 连接真实串口;返回错误消息(null = 成功)。 + public required Func ConnectMcu { get; init; } + + /// 断开单片机。 + public required Action DisconnectMcu { get; init; } + + /// 构造手柄输入适配器(桥连接成功后调用;应现建不缓存——设备重连后旧适配器失效)。 + public required Func GetPad { get; init; } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowExecutor.cs b/src/EasyCon.Core/Flow/FlowExecutor.cs new file mode 100644 index 00000000..78a7951c --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowExecutor.cs @@ -0,0 +1,170 @@ +using EasyCon.Core.Capabilities; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace EasyCon.Core.Flow; + +/// +/// Flow 执行宿主上下文:执行器所需的设备/能力原料(宿主装配原则同 ScriptHostContext)。 +/// 端口一律可为 null,语义为「该能力不可用」——相关节点报错而不是静默降级。 +/// +public sealed class FlowHostContext +{ + /// 实时采集源(null = 视频源未连接,感知节点报错)。 + public ICaptureSource? Capture { get; init; } + + /// 图像处理 / 标签匹配(vision.* 节点通路;null = 未装配)。 + public IVisionService? Vision { get; init; } + + /// 宿主 OCR 服务(ocr.* 节点通路;null = 未装配)。 + public IOcrService? Ocr { get; init; } + + /// ONNX 通用推理(script.run 内 NET_* 族通路;null = 未装配)。 + public IInference? Inference { get; init; } + + /// 手柄输入(pad.* 节点通路;null = 单片机未连接,执行报错)。 + public IPadInput? Pad { get; init; } + + /// __APP__ 基准目录(script.run 相对路径与文件解析基准)。 + public string AppDir { get; init; } = AppDomain.CurrentDomain.BaseDirectory; +} + +/// 单节点的执行记录(计时 + 最后输出)。 +public sealed class FlowNodeRecord +{ + public string NodeId { get; init; } = ""; + public string Type { get; init; } = ""; + public int ExecCount { get; set; } + + /// 其中命中 slow 复用(本步未真正执行,直接沿用上次输出)的次数。 + public int ReusedCount { get; set; } + + public double LastMs { get; set; } + public double TotalMs { get; set; } + public Dictionary LastOutputs { get; set; } = new(); +} + +/// 一次运行的汇总报告。 +public sealed class FlowRunReport +{ + public bool Completed { get; set; } + public bool WatchdogTriggered { get; set; } + public string? Error { get; set; } + public string? ErrorNodeId { get; set; } + public int Steps { get; set; } + public double TotalMs { get; set; } + public Dictionary Records { get; } = new(); + + /// 节点级事件流(供服务轮询/前端高亮;线程安全)。 + public ConcurrentQueue Events { get; } = new(); + + public FlowNodeRecord Record(string nodeId, string type) => + Records.TryGetValue(nodeId, out var r) + ? r + : Records[nodeId] = new FlowNodeRecord { NodeId = nodeId, Type = type }; +} + +/// +/// Flow 图执行引擎:从 start 沿 exec 边推进;节点语义与数据入边求值全部落在 。 +/// 看门狗:图级 maxSteps + timeoutSec;支持外部停止令牌;每节点计时入报告。 +/// +public sealed class FlowExecutor(FlowGraph graph, FlowHostContext context) +{ + /// exec 步数上限(防图作者漏配看门狗)。 + private const int HardStepLimit = 100_000; + + public FlowRunReport Run(CancellationToken token) + { + var report = new FlowRunReport(); + var runtime = new FlowNodeRuntime(graph, context); + var sw = Stopwatch.StartNew(); + + var next = FindStart(); + + while (true) + { + token.ThrowIfCancellationRequested(); + + if (report.Steps >= Math.Min(graph.MaxSteps, HardStepLimit)) + { + report.WatchdogTriggered = true; + report.Events.Enqueue("watchdog maxSteps"); + break; + } + if (sw.Elapsed.TotalSeconds >= graph.TimeoutSec) + { + report.WatchdogTriggered = true; + report.Events.Enqueue("watchdog timeout"); + break; + } + + report.Steps++; + var record = report.Record(next.Id, next.Type); + var nodeSw = Stopwatch.StartNew(); + try + { + FlowNodeResult result = runtime.Execute(next, token); + record.ExecCount++; + if (result.Reused) + record.ReusedCount++; + record.LastMs = nodeSw.ElapsedMilliseconds; + record.TotalMs += record.LastMs; + record.LastOutputs = result.Outputs; + report.Events.Enqueue(result.Reused + ? $"nodeReuse {next.Id} {next.Type} {record.LastMs}ms" + : $"nodeEnd {next.Id} {next.Type} {record.LastMs}ms"); + + FlowExecEdge? edge = SelectEdge(next, result.Port); + if (edge == null) + { + report.Completed = true; // 走到尽头视为正常结束 + break; + } + next = graph.Node(edge.To)!; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + record.LastMs = nodeSw.ElapsedMilliseconds; + record.TotalMs += record.LastMs; + report.Error = ex.Message; + report.ErrorNodeId = next.Id; + report.Events.Enqueue($"error {next.Id} {ex.Message}"); + break; + } + } + + report.TotalMs = sw.ElapsedMilliseconds; + return report; + } + + /// + /// 选择下一条 exec 边:节点给出出口端口时优先精确匹配该端口,其次才是无标签边。 + /// (顺序反了会让 compare 的 .true/.false 分支被同名无标签边抢走。) + /// + private FlowExecEdge? SelectEdge(FlowNode node, string? port) + { + var outgoing = graph.Exec.Where(e => e.FromNode == node.Id).ToList(); + if (port != null) + { + var exact = outgoing.FirstOrDefault(e => e.FromPort == port); + if (exact != null) + return exact; + } + return outgoing.FirstOrDefault(e => e.FromPort == null); + } + + private FlowNode FindStart() + { + var start = graph.Nodes.FirstOrDefault(n => n.Type == "start"); + if (start != null) return start; + + // 无显式 start:取唯一无 exec 入边的节点 + var targets = graph.Exec.Select(e => e.To).ToHashSet(); + var roots = graph.Nodes.Where(n => !targets.Contains(n.Id)).ToList(); + if (roots.Count == 1) return roots[0]; + if (roots.Count == 0 && graph.Nodes.Count > 0) + return graph.Nodes[0]; // 纯自环等全环图:从第一个节点进入 + throw new FlowParseException( + $"图中未定义 start 节点,且无 exec 入边的节点不唯一({string.Join(", ", roots.Select(r => r.Id))}),无法确定起点"); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowGraph.cs b/src/EasyCon.Core/Flow/FlowGraph.cs new file mode 100644 index 00000000..205f47b0 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowGraph.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EasyCon.Core.Flow; + +/// +/// Flow 编排图模型:节点 + exec 边(控制流)+ 数据入边(端口引用)。 +/// 序列化为 *.flow.json(普通 JSON)。图只在 PC 执行;烧录 MCU 仍走纯 ECS 脚本。 +/// +public sealed class FlowGraph +{ + public int Version { get; set; } = 1; + + public string Name { get; set; } = ""; + + /// 图级看门狗:节点执行步数上限。 + public int MaxSteps { get; set; } = 3000; + + /// 图级看门狗:总时长上限(秒)。 + public int TimeoutSec { get; set; } = 1800; + + public List Nodes { get; set; } = []; + + /// exec 边:from 可为 "nodeId" 或 "nodeId.outPort"(条件出口)。 + public List Exec { get; set; } = []; + + /// 按 id 查节点。 + public FlowNode? Node(string id) => Nodes.FirstOrDefault(n => n.Id == id); + + /// + /// 解析 flow.json;结构非法抛 (带可读原因)。 + /// + public static FlowGraph Parse(string json) + { + try + { + var graph = JsonSerializer.Deserialize(json, JsonOpts) + ?? throw new FlowParseException("flow.json 内容为空"); + graph.Validate(); + return graph; + } + catch (JsonException ex) + { + throw new FlowParseException($"flow.json 解析失败: {ex.Message}", ex); + } + catch (FlowParseException) + { + throw; + } + catch (Exception ex) + { + throw new FlowParseException($"flow.json 结构不合法: {ex.Message}", ex); + } + } + + public static FlowGraph LoadFile(string path) => Parse(File.ReadAllText(path)); + + private void Validate() + { + var dup = Nodes.GroupBy(n => n.Id).FirstOrDefault(g => g.Count() > 1); + if (dup != null) + throw new FlowParseException($"节点 id 重复: {dup.Key}"); + if (Nodes.Count == 0) + throw new FlowParseException("图中没有任何节点"); + var starts = Nodes.Where(n => n.Type == "start").ToList(); + if (starts.Count > 1) + throw new FlowParseException( + $"start 节点只能有一个,当前 {starts.Count} 个: {string.Join(", ", starts.Select(n => n.Id))}"); + foreach (var e in Exec) + { + if (e.FromNode != "start" && Node(e.FromNode) == null) + throw new FlowParseException($"exec 边引用了不存在的节点: {e.FromNode}"); + if (Node(e.To) == null) + throw new FlowParseException($"exec 边引用了不存在的节点: {e.To}"); + } + } + + internal static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new FlowExecEdgeConverter() }, + }; +} + +/// 单个节点:类型 + 参数 + 数据入边 + 慢感知策略。 +public sealed class FlowNode +{ + public string Id { get; set; } = ""; + + /// 节点类型(如 capture.frame / ocr.text / compare / script.run / pad.sequence / wait / start / end)。 + public string Type { get; set; } = ""; + + /// 节点参数(各类型自有 schema,原始 JSON)。 + public Dictionary Params { get; set; } = new(); + + /// + /// 数据入边:端口名 → 值。值为 {"node": "id", "port": "name"} 引用, + /// 或原始字面量(string/int/double/bool,直接作为端口值)。 + /// + public Dictionary Inputs { get; set; } = new(); + + /// + /// 慢感知策略:命中时直接复用本节点上次输出,不重复执行(见 )。 + /// null = 每次都执行(默认)。 + /// + public FlowSlowPolicy? Slow { get; set; } +} + +/// +/// 慢感知策略(跨类型节点属性,写在节点上而不是 params 里):把「多贵」与「多新」解耦—— +/// 感知节点(OCR/视觉)不必每步都跑,由帧号/时间/内容变化决定复用还是重算。 +/// +/// 多个条件同时给出时取**最保守**语义:任一条判定「该重算」就重算; +/// 全部判定「可复用」且上次有缓存时才复用。全空对象 = 永不复用(等于不写 slow)。 +/// +public sealed class FlowSlowPolicy +{ + /// 帧号推进不足 N 帧即复用(需采集源提供 FrameIndex)。 + public int? EveryFrames { get; set; } + + /// true = 感知输入指纹未变则复用(指纹取 image 入边,缺省取当前帧)。 + public bool? OnChange { get; set; } + + /// 距上次执行不足 N 毫秒则复用(墙体时钟)。 + public int? IntervalMs { get; set; } +} + +/// exec 边。From 可能带出口后缀("nodeId.true")。 +public sealed class FlowExecEdge +{ + public string From { get; set; } = ""; + public string To { get; set; } = ""; + + [JsonIgnore] + public string FromNode => From.Contains('.') ? From[..From.IndexOf('.')] : From; + + [JsonIgnore] + public string? FromPort => From.Contains('.') ? From[(From.IndexOf('.') + 1)..] : null; + + public FlowExecEdge() { } + + public FlowExecEdge(string from, string to) { From = from; To = to; } +} + +/// flow.json 结构错误。 +public sealed class FlowParseException(string message, Exception? inner = null) + : Exception(message, inner) +{ +} + +/// +/// exec 边的 JSON 形态:["a","b"] / ["chk.true","hit"](数组简写) +/// 或 {"from":"chk.true","to":"hit"}(对象完整形式)。 +/// +public sealed class FlowExecEdgeConverter : JsonConverter +{ + public override FlowExecEdge Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartArray) + { + var parts = new List(2); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + parts.Add(reader.TokenType == JsonTokenType.String ? reader.GetString() : null); + if (parts.Count is < 1 or > 2 || parts[0] == null) + throw new FlowParseException("exec 边数组形式应为 [from, to]"); + return new FlowExecEdge(parts[0]!, parts.Count == 2 ? parts[1] ?? "" : ""); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + if (root.TryGetProperty("from", out var from)) + return new FlowExecEdge(from.GetString() ?? "", + root.TryGetProperty("to", out var to) ? to.GetString() ?? "" : ""); + throw new FlowParseException("exec 边对象形式缺少 from"); + } + + public override void Write(Utf8JsonWriter writer, FlowExecEdge value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteStringValue(value.From); + if (value.To.Length > 0) + writer.WriteStringValue(value.To); + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowNodeCatalog.cs b/src/EasyCon.Core/Flow/FlowNodeCatalog.cs new file mode 100644 index 00000000..4971a399 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowNodeCatalog.cs @@ -0,0 +1,237 @@ +using EasyCon.Script; +using EasyScript; +using System.Text.Json; + +namespace EasyCon.Core.Flow; + +/// 节点端口(exec 控制流或数据流)。 +/// 端口名(数据入边的键;exec 端口仅作语义标注)。 +/// exec-in | exec-out | data-in | data-out。 +/// 数据类型:exec | image | text | number | bool | any。 +/// 中文说明(前端 tooltip)。 +public sealed record FlowPortSpec(string Name, string Kind, string Type, string Description); + +/// 节点参数说明(前端据此生成表单控件)。 +public sealed record FlowParamSpec( + string Name, + string Type, + string Description, + JsonElement? Default = null, + IReadOnlyList? Options = null); + +/// 节点类型说明。Layer 决定试跑许可与前端默认分组。 +public sealed record FlowNodeSpec( + string Type, + string Layer, + string Summary, + IReadOnlyList Ports, + IReadOnlyList Params); + +/// +/// 编排节点目录——**前端画布渲染与后端执行共用的单一事实源**。 +/// +/// +/// 新增节点必须同时登记到本目录与 ; +/// FlowNodeCatalogTests 会遍历目录逐个执行,任何「目录里有、运行时没有」的节点都会被抓出来。 +/// +/// +/// layer 语义:flow(控制流)/ sense(感知)/ decision(决策)/ actuation(驱动设备,试跑禁止)。 +/// +/// +public static class FlowNodeCatalog +{ + /// 层名常量。 + public const string LayerFlow = "flow"; + public const string LayerSense = "sense"; + public const string LayerDecision = "decision"; + public const string LayerActuation = "actuation"; + + private static FlowPortSpec ExecIn() => new("in", "exec-in", "exec", "控制流入口"); + + private static FlowPortSpec ExecOut(string name = "out", string description = "控制流出口") => + new(name, "exec-out", "exec", description); + + private static FlowParamSpec P(string name, string type, string description, object? @default = null, + IReadOnlyList? options = null) => + new(name, type, description, + @default is null ? null : JsonSerializer.SerializeToElement(@default, FlowGraph.JsonOpts), + options); + + /// 全部已实现节点(顺序即前端分组内展示顺序)。 + public static IReadOnlyList Nodes { get; } = + [ + new(Layer: LayerFlow, Type: "start", Summary: "起点(图执行的入口)", + Ports: [ExecOut()], Params: []), + + new(Layer: LayerFlow, Type: "end", Summary: "终点(正常结束)", + Ports: [ExecIn()], Params: []), + + new(Layer: LayerFlow, Type: "wait", Summary: "延时", + Ports: [ExecIn(), ExecOut()], + Params: [P("ms", "int", "延时毫秒(上限 60000,可被停止令牌打断)", 100)]), + + new(Layer: LayerSense, Type: "capture.frame", Summary: "抓取采集卡当前帧", + Ports: + [ + ExecIn(), ExecOut(), + new("image", "data-out", "image", "Base64 PNG 画面"), + new("frameIndex", "data-out", "number", "帧号(采集源不提供时为 null)"), + new("x", "data-in", "number", "ROI 左边界(缺省整帧)"), + new("y", "data-in", "number", "ROI 上边界(缺省整帧)"), + new("w", "data-in", "number", "ROI 宽(缺省整帧)"), + new("h", "data-in", "number", "ROI 高(缺省整帧)"), + ], + Params: + [ + P("waitForNew", "bool", "等待采集源发布新帧后再抓(需帧号;超时按当前帧继续)", false), + P("timeoutMs", "int", "等待新帧上限毫秒", 2000), + ]), + + new(Layer: LayerSense, Type: "image.file", Summary: "读取单张图片文件", + Ports: [ExecIn(), ExecOut(), new("image", "data-out", "image", "Base64 PNG 画面")], + Params: [P("path", "path", "图片路径(相对路径按 __APP__ 解析)")]), + + new(Layer: LayerSense, Type: "ocr.text", Summary: "OCR 识别文字(整图或 ROI)", + Ports: + [ + ExecIn(), ExecOut(), + new("image", "data-in", "image", "输入画面"), + new("x", "data-in", "number", "ROI 左边界"), + new("y", "data-in", "number", "ROI 上边界"), + new("w", "data-in", "number", "ROI 宽(0 = 到边界)"), + new("h", "data-in", "number", "ROI 高(0 = 到边界)"), + new("text", "data-out", "text", "识别文本"), + new("conf", "data-out", "number", "置信度(0-100)"), + ], + Params: + [ + P("lang", "string", "语言/模型选择(tesseract 语言包名或 PP-OCR 模型前缀)", "eng"), + P("x", "int", "ROI 左边界", 0), + P("y", "int", "ROI 上边界", 0), + P("w", "int", "ROI 宽(0 = 到边界)", 0), + P("h", "int", "ROI 高(0 = 到边界)", 0), + ]), + + new(Layer: LayerDecision, Type: "compare", Summary: "比较两个值,true/false 双出口", + Ports: + [ + ExecIn(), + ExecOut("true", "条件成立"), + ExecOut("false", "条件不成立"), + new("a", "data-in", "any", "左值"), + new("b", "data-in", "any", "右值"), + ], + Params: [P("op", "enum", "比较运算符", "==", ["==", "!=", ">", "<", ">=", "<="])]), + + new(Layer: LayerDecision, Type: "text.contains", Summary: "文本判定(包含/前缀/后缀/相等/正则),双出口", + Ports: + [ + ExecIn(), + ExecOut("true", "命中"), + ExecOut("false", "未命中"), + new("text", "data-in", "text", "待判定文本(常接 ocr.text 的 text)"), + new("pattern", "data-in", "text", "匹配内容"), + new("hit", "data-out", "number", "命中标记(1/0)"), + ], + Params: + [ + P("mode", "enum", "判定方式", "contains", ["contains", "startsWith", "endsWith", "equals", "regex"]), + P("pattern", "string", "匹配内容(与 pattern 入边二选一,入边优先)", ""), + P("ignoreCase", "bool", "忽略大小写", true), + ]), + + new(Layer: LayerDecision, Type: "state.step", Summary: "运行内命名计数器(循环/重试),达到 max 走 done", + Ports: + [ + ExecIn(), + ExecOut("out", "未达上限"), + ExecOut("done", "已达上限"), + new("value", "data-in", "number", "增量 / 设定值"), + new("value", "data-out", "number", "计数后的当前值"), + new("name", "data-out", "text", "计数器名"), + ], + Params: + [ + P("name", "string", "计数器名(同一次运行内共享)", "step"), + P("op", "enum", "操作", "inc", ["inc", "set", "reset"]), + P("value", "int", "增量(inc)或设定值(set)", 1), + P("max", "int", "达到该值走 done 出口(0 = 永不触发)", 0), + ]), + + new(Layer: LayerSense, Type: "vision.changed", Summary: "与上次观察到的画面比较,双出口", + Ports: + [ + ExecIn(), + ExecOut("true", "画面已变化"), + ExecOut("false", "画面未变(含首次建立基线)"), + new("image", "data-in", "image", "输入画面(缺省取当前帧)"), + new("changed", "data-out", "number", "变化标记(1/0)"), + ], + Params: []), + + new(Layer: LayerActuation, Type: "script.run", Summary: "执行 ECS 脚本(内联多行或 .ecs 文件;ARG 传参,PRINT 回传)", + Ports: + [ + ExecIn(), ExecOut(), + new("arg1", "data-in", "text", "ARG(1)"), + new("arg2", "data-in", "text", "ARG(2)"), + new("arg3", "data-in", "text", "ARG(3)"), + new("ok", "data-out", "number", "执行完成标记(1)"), + new("logs", "data-out", "text", "PRINT 输出(\\n 连接)"), + ], + Params: + [ + P("script", "text", "内联 ECS 脚本(多行文本,非空时优先于 file;无脚本目录上下文,不支持模块导入)"), + P("file", "path", "脚本路径(相对路径按 __APP__ 解析;与 script 二选一)"), + ]), + + new(Layer: LayerActuation, Type: "pad.key", Summary: "按键", + Ports: [ExecIn(), ExecOut()], + Params: + [ + P("key", "enum", "键名(A/B/X/Y/L/R/ZL/ZR/PLUS/MINUS/HOME/CAPTURE/TOP/DOWN/LEFT/RIGHT/LS/RS)", "A"), + P("durationMs", "int", "按住毫秒", 100), + P("times", "int", "重复次数", 1), + P("intervalMs", "int", "重复间隔毫秒", 100), + ]), + + new(Layer: LayerActuation, Type: "pad.sequence", Summary: "按键序列宏(\"A,100; ↓,200\")", + Ports: [ExecIn(), ExecOut()], + Params: [P("seq", "string", "序列:键名,毫秒 以 ; 分隔;方向键可写 ↑↓←→")]), + ]; + + /// 节点级 slow 策略的字段说明(所有节点通用,写在节点上而非 params 里)。 + public static IReadOnlyList SlowFields { get; } = + [ + P("everyFrames", "int", "帧号推进不足 N 帧即复用上次输出(需采集源提供帧号)"), + P("onChange", "bool", "感知输入未变化即复用(指纹取 image 入边,缺省取当前帧)"), + P("intervalMs", "int", "距上次执行不足 N 毫秒即复用"), + ]; + + /// 手柄键名(前端下拉选项来源)。 + public static IReadOnlyList KeyNames { get; } = + [.. Enum.GetNames().Where(n => n != nameof(GamePadKey.None))]; + + /// OCR 后端选项(服务侧装配开关的取值)。 + public static IReadOnlyList OcrBackends { get; } = ["none", "tesseract", "ppocr", "maa"]; + + /// 按类型查目录项。 + public static FlowNodeSpec? Find(string type) => Nodes.FirstOrDefault(n => n.Type == type); + + /// 该类型是否允许单节点试跑(actuation 层会驱动设备,禁止)。 + public static bool IsTrialAllowed(string type) => + Find(type)?.Layer != LayerActuation; + + /// 目录 JSON(/api/nodes 与 MCP flow_device_query(kind=nodes) 共用)。 + public static string Json() => JsonSerializer.Serialize(Payload(), FlowGraph.JsonOpts); + + /// 目录载荷(结构化形式,便于测试与复用)。 + public static object Payload() => new + { + version = 1, + nodes = Nodes, + slow = SlowFields, + keys = KeyNames, + ocrBackends = OcrBackends, + }; +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowNodeRuntime.cs b/src/EasyCon.Core/Flow/FlowNodeRuntime.cs new file mode 100644 index 00000000..e9972d21 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowNodeRuntime.cs @@ -0,0 +1,589 @@ +using EasyCon.Core.Capabilities; +using EasyCon.Core.Hosting; +using EasyCon.Core.Logging; +using EasyCon.Core.Script; +using EasyCon.Script; +using EasyScript; +using OpenCvSharp; +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace EasyCon.Core.Flow; + +/// 单节点执行结果:exec 出口端口(null = 单出口)、本次输出、是否命中 slow 复用。 +public readonly record struct FlowNodeResult(string? Port, Dictionary Outputs, bool Reused); + +/// +/// 节点运行时:节点语义(含慢感知复用与等待新帧)+ 数据入边解析(惰性求值 + 当次运行 memo)。 +/// +/// +/// (整图执行)与「单节点试跑」(服务层 / 画布右键执行此节点)共用本类—— +/// 同一节点类型在两条路径上只有一处实现,试跑结果才可信。 +/// +/// +/// 数据入边语义:端口值可为字面量或 {"node","port"} 引用; +/// 引用在首次被读取时惰性求值(递归执行上游节点),本次运行内不重复求值; +/// exec 边上的节点每次执行都会刷新自身输出,因此「数据来自 exec 路径上游」恒为最新帧。 +/// 数据边成环会被拒绝,而不是递归到栈溢出。 +/// +/// +public sealed class FlowNodeRuntime(FlowGraph graph, FlowHostContext context) +{ + /// waitForNew 未给 timeoutMs 时的等待上限;超时取当前帧继续(不阻塞整图)。 + private const int DefaultWaitNewFrameMs = 2000; + + private readonly Dictionary> _outputs = []; + private readonly HashSet _memo = []; + private readonly HashSet _inFlight = []; + private readonly Dictionary _slow = []; + private readonly Dictionary _counters = []; + private readonly Dictionary _lastFingerprint = []; + + /// 已求值节点的最近输出(nodeId → port → 值)。 + public IReadOnlyDictionary> Outputs => _outputs; + + /// + /// 节点执行许可:返回 null 表允许,返回字符串为拒绝原因。 + /// 单节点试跑用它兜住「经数据入边间接执行 actuation 节点」的越权路径—— + /// 只校验目标节点的 layer 不够,惰性求值会把上游节点一并执行。 + /// + public Func? ExecuteGuard { get; init; } + + /// 执行节点,返回 exec 出口端口与输出(slow 命中时直接返回上次输出,不重复执行)。 + public FlowNodeResult Execute(FlowNode node, CancellationToken token) + { + if (ExecuteGuard?.Invoke(node) is { } denied) + throw new InvalidOperationException(denied); + + if (ShouldReuse(node, token)) + { + SlowCacheEntry cached = _slow[node.Id]; + _outputs[node.Id] = cached.Outputs; + return new FlowNodeResult(cached.Port, cached.Outputs, true); + } + + var outputs = new Dictionary(); + if (!_inFlight.Add(node.Id)) + throw new InvalidOperationException($"数据入边存在环,节点被重复求值: {node.Id}"); + + string? port; + try + { + port = ExecuteCore(node, outputs, token); + } + finally + { + _inFlight.Remove(node.Id); + } + + _outputs[node.Id] = outputs; + _memo.Add(node.Id); + if (node.Slow != null) + { + // 指纹只在配了 onChange 时算:否则每步都会白抓一帧 + var fingerprint = node.Slow.OnChange == true ? SenseFingerprint(node, token) : null; + _slow[node.Id] = new SlowCacheEntry( + port, outputs, context.Capture?.FrameIndex, Environment.TickCount64, fingerprint); + } + return new FlowNodeResult(port, outputs, false); + } + + /// + /// slow 判定:任一已配置条件判定「必须重算」就重算;只有全部条件都判「可复用」且存在上次缓存时才复用。 + /// 判定不了的情形(源不给帧号)按「必须重算」处理——慢感知只是省开销,不得改变结果的新鲜度语义。 + /// + private bool ShouldReuse(FlowNode node, CancellationToken token) + { + if (node.Slow is not { } slow || !_slow.TryGetValue(node.Id, out SlowCacheEntry? prev)) + return false; // 无上次输出 → 必须执行 + + var configured = false; + var mustRun = false; + + if (slow.IntervalMs is int ms && ms > 0) + { + configured = true; + mustRun |= Environment.TickCount64 - prev.StampMs >= ms; + } + + if (slow.EveryFrames is int every && every > 0) + { + configured = true; + var now = context.Capture?.FrameIndex; + mustRun |= now is null || prev.FrameIndex is null || now - prev.FrameIndex >= every; + } + + if (slow.OnChange == true) + { + configured = true; + mustRun |= !string.Equals(SenseFingerprint(node, token), prev.Fingerprint, StringComparison.Ordinal); + } + + return configured && !mustRun; + } + + /// + /// 感知输入指纹:显式 image 入边优先(内容即输入),否则取当前帧的编码字节。 + /// 采集帧为定长编码时同一画面指纹稳定,可作为 onChange 判据。 + /// + private string? SenseFingerprint(FlowNode node, CancellationToken token) + { + if (node.Inputs.ContainsKey("image")) + return Input(node, "image", token) as string; + return context.Capture?.CaptureFullFrame(); + } + + /// 按端口读取数据入边(字面量或引用);引用触发上游惰性求值。 + public object? Input(FlowNode node, string port, CancellationToken token) + { + if (!node.Inputs.TryGetValue(port, out var el)) + return null; + + // 引用形式 {"node": "id", "port": "name"} + if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("node", out var nEl)) + { + var refId = nEl.GetString() ?? ""; + var refPort = el.TryGetProperty("port", out var pEl) ? pEl.GetString() ?? "" : ""; + var refNode = graph.Node(refId) + ?? throw new InvalidOperationException($"数据入边引用了不存在的节点: {refId}"); + + if (!_memo.Contains(refId)) + { + // 惰性求值结果必须回填 _outputs,否则纯数据边(上游不在 exec 路径上)永远取不到值 + FlowNodeResult upstream = Execute(refNode, token); + _outputs[refId] = upstream.Outputs; + } + return _outputs.TryGetValue(refId, out var o) && o.TryGetValue(refPort, out var v) ? v : null; + } + + return Literal(el); + } + + private static object? Literal(JsonElement el) => el.ValueKind switch + { + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt32(out var i) ? i : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => el.GetRawText(), + }; + + private int IntInput(FlowNode node, string port, CancellationToken token, int fallback) => + Input(node, port, token) switch + { + int i => i, + double d => (int)d, + string s when int.TryParse(s, out var v) => v, + _ => fallback, + }; + + private string? StrInput(FlowNode node, string port, CancellationToken token) => + Input(node, port, token) switch + { + string s => s, + null => null, + var v => v.ToString(), + }; + + /// 整数入边优先,缺省回落节点参数。 + private int IntInputOrParam(FlowNode node, string port, CancellationToken token) => + IntInput(node, port, token, IntParam(node, port)); + + private string? StrParam(FlowNode node, string name) => + node.Params.TryGetValue(name, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; + + private static int IntParam(FlowNode node, string name, int fallback = 0) => + node.Params.TryGetValue(name, out var el) + && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : fallback; + + /// 执行节点本体,返回 exec 出口端口名(null = 单出口)。 + private string? ExecuteCore(FlowNode node, Dictionary outputs, CancellationToken token) + { + switch (node.Type) + { + case "start": + return null; + + case "capture.frame": + { + var src = context.Capture + ?? throw new InvalidOperationException("视频源未连接,capture.frame 不可用"); + if (BoolParam(node, "waitForNew")) + WaitForNewFrame(node, src, token); + int x = IntInput(node, "x", token, -1); + int y = IntInput(node, "y", token, -1); + int w = IntInput(node, "w", token, -1); + int h = IntInput(node, "h", token, -1); + outputs["image"] = src.CaptureFrame(x, y, w, h) + ?? throw new InvalidOperationException("采集源未返回画面"); + outputs["frameIndex"] = src.FrameIndex; + return null; + } + + case "image.file": + { + var path = StrParam(node, "path"); + if (string.IsNullOrWhiteSpace(path)) + throw new InvalidOperationException("image.file 缺少 path 参数"); + var full = Path.IsPathRooted(path) ? path : Path.Combine(context.AppDir, path); + using var m = Cv2.ImRead(full, ImreadModes.Color); + if (m.Empty()) throw new InvalidOperationException($"图片解码失败: {full}"); + outputs["image"] = Convert.ToBase64String(m.ToBytes(".png")); + return null; + } + + case "ocr.text": + { + var ocr = context.Ocr + ?? throw new InvalidOperationException("OCR 服务未装配"); + var image = StrInput(node, "image", token) + ?? throw new InvalidOperationException("ocr.text 缺少 image 输入"); + OcrQuery query = new() + { + Language = StrParam(node, "lang"), + X = IntInputOrParam(node, "x", token), + Y = IntInputOrParam(node, "y", token), + Width = IntInputOrParam(node, "w", token), + Height = IntInputOrParam(node, "h", token), + }; + string text = ocr is Maa.MaaOcrService maa ? maa.Recognize(ImageRef.FromBase64(image), query, token) + : ocr.Recognize(ImageRef.FromBase64(image), query); + outputs["text"] = text; + outputs["conf"] = ocr.LastConfidence; + return null; + } + + case "compare": + return CompareValues( + Input(node, "a", token), + Input(node, "b", token), + StrParam(node, "op") ?? "=="); + + case "text.contains": + return TextContains(node, outputs, token); + + case "state.step": + return StateStep(node, outputs, token); + + case "vision.changed": + return VisionChanged(node, outputs, token); + + case "script.run": + return RunScript(node, outputs, token); + + case "pad.key": + { + var pad = RequirePad(); + var key = ParseKey(StrParam(node, "key") ?? throw new InvalidOperationException("pad.key 缺少 key")); + var duration = IntParam(node, "durationMs", 100); + var times = IntParam(node, "times", 1); + var interval = IntParam(node, "intervalMs", 100); + for (var i = 0; i < times; i++) + { + token.ThrowIfCancellationRequested(); + pad.ClickButtons(key, Math.Clamp(duration, 1, 10_000), token); + if (i < times - 1 && interval > 0) + token.WaitHandle.WaitOne(Math.Clamp(interval, 1, 10_000)); + } + return null; + } + + case "pad.sequence": + { + var pad = RequirePad(); + var seq = StrParam(node, "seq") ?? ""; + foreach (var step in seq.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + token.ThrowIfCancellationRequested(); + var parts = step.Split(',', StringSplitOptions.TrimEntries); + var key = ParseKey(NormalizeKeyName(parts[0])); + var dur = parts.Length > 1 && int.TryParse(parts[1], out var d) ? Math.Clamp(d, 1, 10_000) : 100; + pad.ClickButtons(key, dur, token); + token.WaitHandle.WaitOne(30); + } + return null; + } + + case "wait": + { + var ms = IntParam(node, "ms", 0); + if (ms > 0) + token.WaitHandle.WaitOne(Math.Clamp(ms, 1, MaxWaitMs)); + return null; + } + + case "end": + case "stop": + return null; + + default: + throw new InvalidOperationException($"未知节点类型: {node.Type}"); + } + } + + /// + /// script.run:能力集必须经唯一装配点组装;本次运行的能力(采集/OCR/推理/手柄)以 + /// **借用**方式传入(所有权仍属整图运行),否则节点租约释放会拆掉后续节点还在用的服务。 + /// 脚本来源二选一:script 内联多行文本(优先,无脚本目录上下文)或 file 脚本路径。 + /// + private string? RunScript(FlowNode node, Dictionary outputs, CancellationToken token) + { + var inline = StrParam(node, "script"); + var file = StrParam(node, "file"); + if (string.IsNullOrWhiteSpace(inline) && string.IsNullOrWhiteSpace(file)) + throw new InvalidOperationException("script.run 需要 script(内联脚本)或 file(脚本路径)参数之一"); + + // 现编档:不落盘缓存(交互路径约定,与图形界面即写即跑的脚本一致) + var compile = ScriptCompileProfiles.Interactive([]); + var scriptArgs = new[] { "flow" } + .Concat(new[] { "arg1", "arg2", "arg3" }.Select(p => StrInput(node, p, token) ?? "")) + .ToArray(); + + var lines = new List(); + var engine = new EasyScriptEngine(); + var session = string.IsNullOrWhiteSpace(inline) + ? LoadScriptFile(engine, file!, compile) + : engine.FromSource(inline, new ScriptHostOptions { Compile = compile }); + using Maa.MaaOcrService? invocationOcr = (context.Ocr as Maa.MaaOcrService)?.CreateInvocation(token); + using var lease = ScriptHostAssembler.Assemble(new ScriptHostContext + { + Console = new FlowConsole(lines), + CaptureSource = context.Capture, + VisionService = context.Vision, + PadInput = context.Pad, + Ocr = invocationOcr ?? context.Ocr, + CancellationToken = token, + Inference = context.Inference, + BorrowResources = true, + Args = scriptArgs, + AppDir = context.AppDir, + }); + session.Run(token, lease.Capabilities); + + outputs["ok"] = 1; + outputs["logs"] = string.Join("\n", lines); + return null; + } + + /// 解析并加载 file 模式的脚本(相对路径按 __APP__ 基准,保留同目录 lib/ 导入上下文)。 + private IScriptSession LoadScriptFile(EasyScriptEngine engine, string file, CompileOptions compile) + { + var full = Path.IsPathRooted(file) ? file : Path.Combine(context.AppDir, file); + if (!File.Exists(full)) + throw new InvalidOperationException($"脚本文件不存在: {full}"); + return engine.LoadFile(full, new ScriptHostOptions { Compile = compile }); + } + + private IPadInput RequirePad() => context.Pad + ?? throw new InvalidOperationException("单片机未连接,pad.* 节点不可用"); + + /// regex 匹配上限:图作者写坏正则(灾难性回溯)不得挂死整图。 + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(200); + + /// 文本判定(决策层):contains / startsWith / endsWith / equals / regex,双出口。 + private string TextContains(FlowNode node, Dictionary outputs, CancellationToken token) + { + var text = StrInput(node, "text", token) ?? ""; + var pattern = StrInput(node, "pattern", token) ?? StrParam(node, "pattern") ?? ""; + var mode = (StrParam(node, "mode") ?? "contains").ToLowerInvariant(); + var comparison = BoolParam(node, "ignoreCase", true) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + bool hit; + switch (mode) + { + case "contains": + hit = text.Contains(pattern, comparison); + break; + case "startswith": + hit = text.StartsWith(pattern, comparison); + break; + case "endswith": + hit = text.EndsWith(pattern, comparison); + break; + case "equals": + hit = text.Equals(pattern, comparison); + break; + case "regex": + try + { + hit = Regex.IsMatch(text, pattern, + BoolParam(node, "ignoreCase", true) ? RegexOptions.IgnoreCase : RegexOptions.None, + RegexTimeout); + } + catch (RegexMatchTimeoutException) + { + CoreLog.Warn($"text.contains 正则匹配超时(>{RegexTimeout.TotalMilliseconds}ms): {pattern}"); + hit = false; + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"text.contains 正则不合法: {ex.Message}"); + } + break; + default: + throw new InvalidOperationException($"text.contains 未知 mode: {mode}"); + } + + outputs["hit"] = hit ? 1 : 0; + outputs["text"] = text; + return hit ? "true" : "false"; + } + + /// + /// 运行内命名计数器(决策层):inc/set/reset,达到 max 时走 done 出口。 + /// 状态随本次运行存亡(PC 侧执行状态,不进字节码、不落盘)。 + /// + private string StateStep(FlowNode node, Dictionary outputs, CancellationToken token) + { + var name = StrParam(node, "name") ?? "step"; + var op = (StrParam(node, "op") ?? "inc").ToLowerInvariant(); + var amount = IntInputOrParam(node, "value", token); + var max = IntParam(node, "max", 0); + + _counters.TryGetValue(name, out var current); + current = op switch + { + "set" => amount, + "inc" => current + amount, + "reset" => 0, + _ => throw new InvalidOperationException($"state.step 未知 op: {op}(set | inc | reset)"), + }; + + _counters[name] = current; + outputs["value"] = current; + outputs["name"] = name; + return max > 0 && current >= max ? "done" : "out"; + } + + /// + /// 画面变化检测(感知层):与本次运行内上一次观察到的画面比较,双出口。 + /// 首次执行只建立基线(changed = false)——「等待画面变化」的语义下,第一帧不算变化。 + /// 注意:本节点自带状态,不要给它配 slow(复用会跳过基线更新)。 + /// + private string VisionChanged(FlowNode node, Dictionary outputs, CancellationToken token) + { + var fingerprint = SenseFingerprint(node, token) + ?? throw new InvalidOperationException("vision.changed 需要 image 输入或已连接的视频源"); + + _lastFingerprint.TryGetValue(node.Id, out var previous); + var changed = previous != null && !string.Equals(previous, fingerprint, StringComparison.Ordinal); + _lastFingerprint[node.Id] = fingerprint; + + outputs["changed"] = changed ? 1 : 0; + return changed ? "true" : "false"; + } + + /// + /// 等待采集源发布新帧(帧号推进);源不提供帧号时无法判定,直接取当前帧。 + /// 等待可被停止令牌打断,并在 timeoutMs(缺省 2s)后按当前帧继续——图不会因视频源停帧而挂死。 + /// + private void WaitForNewFrame(FlowNode node, ICaptureSource src, CancellationToken token) + { + if (src.FrameIndex is not { } baseline) + return; + + var timeoutMs = Math.Max(1, IntParam(node, "timeoutMs", DefaultWaitNewFrameMs)); + var sw = Stopwatch.StartNew(); + while (true) + { + token.ThrowIfCancellationRequested(); + var now = src.FrameIndex; + if (now is null || now > baseline) + return; + if (sw.ElapsedMilliseconds >= timeoutMs) + { + CoreLog.Warn($"capture.frame waitForNew 等待新帧超时({timeoutMs}ms),改用当前帧"); + return; + } + token.WaitHandle.WaitOne(5); + } + } + + private static bool BoolParam(FlowNode node, string name, bool fallback = false) => + node.Params.TryGetValue(name, out var el) + && el.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(el.GetString(), out var v) && v, + _ => fallback, + }; + + /// slow 复用缓存:上次输出 + 判定基准(帧号/时刻/输入指纹)。 + private sealed record SlowCacheEntry( + string? Port, Dictionary Outputs, long? FrameIndex, long StampMs, string? Fingerprint); + + private static GamePadKey ParseKey(string name) + { + var normalized = NormalizeKeyName(name); + if (!Enum.TryParse(normalized, ignoreCase: true, out GamePadKey key) || key == GamePadKey.None) + throw new InvalidOperationException($"未知按键名: {name}"); + return key; + } + + private static string NormalizeKeyName(string name) => name.Trim() switch + { + "↑" or "UP" => "TOP", + "↓" or "DOWN" => "DOWN", + "←" or "LEFT" => "LEFT", + "→" or "RIGHT" => "RIGHT", + var k => k, + }; + + private static string CompareValues(object? a, object? b, string op) + { + // 数值优先(int/double/可解析字符串),否则字符串序比较 + var da = ToDouble(a); + var db = ToDouble(b); + int cmp = da.HasValue && db.HasValue + ? da.Value.CompareTo(db.Value) + : string.CompareOrdinal(a?.ToString() ?? "", b?.ToString() ?? ""); + + return op switch + { + "==" => cmp == 0 ? "true" : "false", + "!=" => cmp != 0 ? "true" : "false", + ">" => cmp > 0 ? "true" : "false", + "<" => cmp < 0 ? "true" : "false", + ">=" => cmp >= 0 ? "true" : "false", + "<=" => cmp <= 0 ? "true" : "false", + _ => throw new InvalidOperationException($"未知比较符: {op}"), + }; + } + + private static double? ToDouble(object? v) => v switch + { + int i => i, + double d => d, + string s when double.TryParse(s, out var d) => d, + _ => null, + }; + + /// wait 单次上限(毫秒),防呆。 + private const int MaxWaitMs = 60_000; + + /// 脚本 PRINT/ALERT 捕获(与整图运行同一实现)。 + private sealed class FlowConsole(List lines) : IConsoleIo + { + public void Print(string message, bool newline = true) + { + lines.Add(message); + if (lines.Count > 500) + lines.RemoveAt(0); + } + + public void Alert(string message) => lines.Add("[ALERT] " + message); + + public string ReadLine() => ""; + + public bool TryReadLine(out string line) + { + line = ""; + return false; + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowRequirements.cs b/src/EasyCon.Core/Flow/FlowRequirements.cs new file mode 100644 index 00000000..3f2f5415 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowRequirements.cs @@ -0,0 +1,33 @@ +#nullable enable +using EasyCon.Core.Hosting; +using EasyCon.Core.Script; +using System.Text.Json; + +namespace EasyCon.Core.Flow; + +/// 能静态判断的输入需求先检查;动态调用仍由运行端口登记并验证。 +public static class FlowRequirements +{ + public static bool RequiresInput(FlowGraph graph, string appDir) + { + foreach (FlowNode node in graph.Nodes) + { + if (node.Type.StartsWith("pad.", StringComparison.Ordinal)) return true; + if (node.Type != "script.run") continue; + string? Text(string name) => node.Params.TryGetValue(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + try + { + EasyScriptEngine engine = new(); + ScriptHostOptions options = new() { Compile = ScriptCompileProfiles.Interactive([]) }; + string? inline = Text("script"); + string? file = Text("file"); + IScriptSession? session = !string.IsNullOrWhiteSpace(inline) ? engine.FromSource(inline, options) + : !string.IsNullOrWhiteSpace(file) ? engine.LoadFile(Path.IsPathRooted(file) ? file : Path.Combine(appDir, file), options) : null; + if (session?.Info.KeyAction == true) return true; + } + catch (IOException) { } // 保留运行时的原节点错误,而非用缺设备掩盖坏脚本。 + } + return false; + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowServiceHttp.cs b/src/EasyCon.Core/Flow/FlowServiceHttp.cs new file mode 100644 index 00000000..a41be594 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowServiceHttp.cs @@ -0,0 +1,289 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace EasyCon.Core.Flow; + +/// +/// Flow 服务 HTTP 宿主(loopback HttpListener):节点目录、设备管理、编排图运行。 +/// 前端(Python 画布)与外部 agent 同源访问;无鉴权(仅绑定 127.0.0.1)。 +/// +public sealed class FlowServiceHttp : IAsyncDisposable +{ + private readonly FlowServiceState _state; + private readonly int _port; + private HttpListener? _listener; + private CancellationTokenSource? _cts; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + /// 监听端口(0 = 失败静默由调用方处理;建议 19391)。 + public FlowServiceHttp(FlowServiceState state, int port) + { + _state = state; + _port = port; + } + + public string Url => $"http://127.0.0.1:{_port}"; + + /// 启动监听循环(后台)。 + public Task StartAsync(CancellationToken ct) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{_port}/"); + _listener.Start(); + _ = AcceptLoopAsync(_cts.Token); + return Task.CompletedTask; + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + var listener = _listener!; + while (!ct.IsCancellationRequested) + { + HttpListenerContext ctx; + try + { + ctx = await listener.GetContextAsync().WaitAsync(ct); + } + catch (Exception) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception) + { + continue; + } + _ = HandleSafeAsync(ctx, ct); + } + } + + private async Task HandleSafeAsync(HttpListenerContext ctx, CancellationToken ct) + { + try + { + await RouteAsync(ctx, ct); + } + catch (Exception ex) + { + await WriteJsonAsync(ctx, 500, new { error = ex.Message }); + } + finally + { + try { ctx.Response.Close(); } catch { } + } + } + + private async Task RouteAsync(HttpListenerContext ctx, CancellationToken ct) + { + var path = ctx.Request.Url?.AbsolutePath ?? "/"; + var method = ctx.Request.HttpMethod; + + switch (method, path) + { + case ("GET", "/api/health"): + await WriteJsonAsync(ctx, 200, new { ok = true, service = "easycon-flow" }); + return; + + case ("GET", "/api/nodes"): + await WriteRawAsync(ctx, 200, FlowServiceState.NodeCatalogJson()); + return; + + case ("GET", "/api/device/video"): + await WriteJsonAsync(ctx, 200, _state.VideoInfo()); + return; + + case ("POST", "/api/device/video/connect"): + { + using var bodyDoc = await ReadJsonAsync(ctx); + var body = bodyDoc.RootElement; + var index = GetInt(body, "index", -1); + var api = GetInt(body, "api", 0); + var error = _state.ConnectVideo(index, api); + await WriteJsonAsync(ctx, error == null ? 200 : 400, error == null ? new { ok = true } : new { ok = false, error }); + return; + } + + case ("POST", "/api/device/video/disconnect"): + _state.DisconnectVideo(); + await WriteJsonAsync(ctx, 200, new { ok = true }); + return; + + case ("GET", "/api/device/mcu"): + await WriteJsonAsync(ctx, 200, _state.McuInfo()); + return; + + case ("POST", "/api/device/mcu/connect"): + { + using var bodyDoc = await ReadJsonAsync(ctx); + var body = bodyDoc.RootElement; + var port = GetString(body, "port") ?? "mock"; + var error = _state.ConnectMcu(port); + await WriteJsonAsync(ctx, error == null ? 200 : 400, error == null ? new { ok = true } : new { ok = false, error }); + return; + } + + case ("POST", "/api/device/mcu/disconnect"): + _state.DisconnectMcu(); + await WriteJsonAsync(ctx, 200, new { ok = true }); + return; + + case ("POST", "/api/flow/run"): + { + using var bodyDoc = await ReadJsonAsync(ctx); + var body = bodyDoc.RootElement; + string json; + if (body.TryGetProperty("path", out var pathEl)) + { + var filePath = pathEl.GetString() ?? ""; + var full = Path.IsPathRooted(filePath) ? filePath : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, filePath); + if (!File.Exists(full)) + { + await WriteJsonAsync(ctx, 400, new { error = $"图文件不存在: {full}" }); + return; + } + json = File.ReadAllText(full); + } + else if (body.TryGetProperty("json", out var jsonEl)) + { + json = jsonEl.GetString() ?? ""; + } + else + { + await WriteJsonAsync(ctx, 400, new { error = "需要 path 或 json 字段" }); + return; + } + + string runId; + try + { + runId = _state.StartFlow(json); + } + catch (FlowParseException ex) + { + await WriteJsonAsync(ctx, 400, new { error = ex.Message }); + return; + } + await WriteJsonAsync(ctx, 200, new { runId }); + return; + } + + case ("POST", "/api/flow/stop"): + { + using var bodyDoc = await ReadJsonAsync(ctx); + _state.StopFlow(GetString(bodyDoc.RootElement, "runId") ?? ""); + await WriteJsonAsync(ctx, 200, new { ok = true }); + return; + } + + case ("GET", "/api/flow/status"): + { + var query = ctx.Request.QueryString["runId"]; + await WriteJsonAsync(ctx, 200, _state.FlowStatus(string.IsNullOrEmpty(query) ? null : query)); + return; + } + + case ("POST", "/api/node/run"): + { + FlowServiceState.FlowNodeRunRequest? request; + try + { + using var bodyDoc = await ReadJsonAsync(ctx); + request = bodyDoc.RootElement.Deserialize(FlowGraph.JsonOpts); + } + catch (JsonException ex) + { + await WriteJsonAsync(ctx, 400, new { error = $"请求体解析失败: {ex.Message}" }); + return; + } + if (request is null) + { + await WriteJsonAsync(ctx, 400, new { error = "请求体为空" }); + return; + } + + var result = _state.RunNode(request); + await WriteJsonAsync(ctx, result.Ok ? 200 : 400, result); + return; + } + + case ("GET", "/api/options"): + await WriteJsonAsync(ctx, 200, new + { + appDir = AppDomain.CurrentDomain.BaseDirectory, + ocr = _state.OcrInfo(), + }); + return; + + case ("POST", "/api/options"): + { + using var bodyDoc = await ReadJsonAsync(ctx); + var body = bodyDoc.RootElement; + var backend = GetString(body, "ocrBackend"); + if (backend is null) + { + await WriteJsonAsync(ctx, 400, new { error = "需要 ocrBackend 字段" }); + return; + } + var error = _state.SetOcrBackend(backend, GetString(body, "ocrModelDir")); + await WriteJsonAsync(ctx, error == null ? 200 : 400, + error == null ? new { ok = true, ocr = _state.OcrInfo() } : new { ok = false, error }); + return; + } + + default: + ctx.Response.StatusCode = 404; + return; + } + } + + private static async Task ReadJsonAsync(HttpListenerContext ctx) + { + using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8); + var body = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) + return JsonDocument.Parse("{}"); + return JsonDocument.Parse(body); + } + + private static int GetInt(JsonElement root, string name, int fallback) => + root.TryGetProperty(name, out var el) + && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : fallback; + + private static string? GetString(JsonElement root, string name) => + root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() : null; + + private static async Task WriteJsonAsync(HttpListenerContext ctx, int status, object payload) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts); + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes); + } + + private static async Task WriteRawAsync(HttpListenerContext ctx, int status, string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes); + } + + /// + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_listener?.IsListening == true) + _listener.Stop(); + _listener?.Close(); + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowServiceState.cs b/src/EasyCon.Core/Flow/FlowServiceState.cs new file mode 100644 index 00000000..60e7a182 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowServiceState.cs @@ -0,0 +1,528 @@ +using EasyCon.Capture; +using EasyCon.Core.Capabilities; +using EasyCon.Core.Hosting; +using EasyCon.Core.Logging; +using EasyCon.Core.Maa; +using EasyCon.Core.Script; +using EasyDevice; +using EasyScript; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; + +namespace EasyCon.Core.Flow; + +/// +/// Flow 服务状态机:设备(视频源/单片机)的连接管理 + 编排图运行管理 + 单节点试跑。 +/// 前端(Python 画布)与外部 agent 经 HTTP/MCP 访问本状态。设备实例单一持有: +/// GUI 宿主经 把监控页实例接入(画布/agent/GUI 同一实例), +/// 独立宿主(CLI)不传桥、由本状态自持。 +/// 每次运行/试跑的能力集经 唯一装配点组装,并按次释放。 +/// +public sealed class FlowServiceState : IDisposable +{ + private readonly FlowDeviceBridge? _bridge; + private readonly MaaHostSession? _localHost; + private readonly object _gate = new(); + private IPadInput? _pad; + private bool _mockMcu; + private bool _mcuViaBridge; + + /// 桥接模式(GUI 宿主)下设备操作全部委托宿主实例;独立模式为 null。 + public FlowServiceState(FlowDeviceBridge? bridge = null) + { + _bridge = bridge; + if (bridge == null) _localHost = new MaaHostSession(); + } + private IControllerSession? Controller => _bridge?.Controller ?? _localHost?.Controller; + private readonly ConcurrentDictionary _runs = new(); + private int _runSeq; + private int _disposed; + private int _activeContexts; + + /// 服务侧 OCR 后端(none | tesseract | ppocr);跨运行复用同一引擎实例。 + public string OcrBackend { get; private set; } = "none"; + + /// PP-OCR 模型/字符集目录( = ppocr 时使用)。 + public string? OcrModelDir { get; private set; } + + private IOcrService? _ocr; + private string? _ocrError; + + // ---- 节点目录(前端画布渲染的单一事实源)---- + + /// 节点目录 JSON(结构见 )。 + public static string NodeCatalogJson() => FlowNodeCatalog.Json(); + + // ---- 视频源 ---- + + public bool VideoConnected => _bridge is not null ? _bridge.IsVideoConnected() : _localHost!.Frames.CaptureConnected; + + public object VideoInfo() + { + IReadOnlyList sources = []; + string? error = null; + try { sources = _bridge?.GetVideoSources?.Invoke() ?? CaptureSourceCatalog.Enumerate(OpenCvSharp.VideoCaptureAPIs.ANY); } + catch (Exception ex) { error = ex.Message; } + return new + { + connected = VideoConnected, + sources = sources.Select(c => new { index = c.Index, name = c.Name, id = c.Id, backend = c.Backend.ToString(), temporary = !c.Persistent }).ToArray(), + error, + }; + } + + /// 连接视频源;返回错误消息(null = 成功)。桥接模式下 api 由宿主(GUI)配置决定,不透传。 + public string? ConnectVideo(int index, int api = 0) + { + lock (_gate) + { + if (_bridge is not null) + return _bridge.ConnectVideo(index); + + if (!_localHost!.Frames.Connect(index, (OpenCvSharp.VideoCaptureAPIs)api)) + return $"视频源打开失败: [{index}]"; + return null; + } + } + + public void DisconnectVideo() + { + lock (_gate) + { + if (_bridge is not null) + { + _bridge.DisconnectVideo(); + return; + } + if (!_localHost!.Frames.Disconnect()) throw new InvalidOperationException("视频源仍在停止中"); + } + } + + // ---- 单片机 ---- + + public bool McuConnected => _mockMcu + || (_bridge is not null ? _bridge.IsMcuConnected() : Controller!.InputConnected); + + public object McuInfo() => new + { + connected = McuConnected, + ports = ECCore.GetDeviceNames().ToArray(), + }; + + /// 连接单片机;"mock" 为无硬件虚拟手柄(两种模式都由本状态自持,不走桥)。返回错误消息(null = 成功)。 + public string? ConnectMcu(string port) + { + lock (_gate) + { + if (port.Equals("mock", StringComparison.OrdinalIgnoreCase)) + { + _pad = new MockFlowPad(); + _mockMcu = true; + _mcuViaBridge = false; + return null; + } + if (_bridge is not null) + { + var error = _bridge.ConnectMcu(port); + if (error != null) + return error; + _pad = _bridge.GetPad(); + _mockMcu = false; + _mcuViaBridge = true; + return null; + } + try + { + if (Controller!.TryConnect(port) != ControllerConnectResult.Success) return $"单片机连接失败: {port}"; + } + catch (Exception ex) { return $"单片机连接失败: {port}: {ex.Message}"; } + _pad = Controller.CreatePad(DelayType.LowCPU); + _mockMcu = false; + return null; + } + } + + public void DisconnectMcu() + { + lock (_gate) + { + _pad = null; + _mockMcu = false; + if (_bridge is not null) + { + // mock 连接不经桥:别把宿主侧真实串口顺带断掉 + if (_mcuViaBridge) + _bridge.DisconnectMcu(); + _mcuViaBridge = false; + return; + } + Controller!.Disconnect(); + } + } + + // ---- 能力装配 ---- + + /// + /// 配置服务侧 OCR 后端(none | tesseract | ppocr)。 + /// 切换会释放旧引擎并延迟到下次运行/试跑时重建;返回错误消息(null = 成功)。 + /// + public string? SetOcrBackend(string backend, string? modelDir = null) + { + var normalized = (backend ?? "").Trim().ToLowerInvariant(); + if (!FlowNodeCatalog.OcrBackends.Contains(normalized)) + return $"未知 OCR 后端: {backend}(可选 {string.Join(" | ", FlowNodeCatalog.OcrBackends)})"; + if (normalized == "ppocr" && string.IsNullOrWhiteSpace(modelDir)) + return "ppocr 后端需要 modelDir(PP-OCR ONNX 模型与字典目录)"; + if (normalized == "maa") + { + if (string.IsNullOrWhiteSpace(modelDir)) return "maa 后端需要 modelDir 指向 OCR profile JSON"; + try { _ = MaaOcrProfile.Load(modelDir); } + catch (Exception ex) { return $"OCR profile 无效: {ex.Message}"; } + } + + lock (_gate) + { + if (Volatile.Read(ref _activeContexts) != 0) return "busy: OCR 后端仍由运行/试跑借用"; + _ocr?.Dispose(); + _ocr = null; + _ocrError = null; + OcrBackend = normalized; + OcrModelDir = modelDir; + return null; + } + } + + /// OCR 后端自检信息(前端设备面板展示)。 + public object OcrInfo() + { + lock (_gate) + { + return new + { + backend = OcrBackend, + modelDir = OcrModelDir, + // 引擎实例是懒建的:这里只说明「后端已选定且实例已创建」, + // 是否真能识别取决于 tessdata / 模型目录是否就位(失败原因走 error) + engineCreated = _ocr != null, + error = _ocrError, + options = FlowNodeCatalog.OcrBackends, + }; + } + } + + /// 取(必要时创建)服务侧共享 OCR 引擎;跨运行复用,避免每次重载模型。 + private IOcrService? AcquireOcr() + { + if (OcrBackend == "none") + return null; + lock (_gate) + { + if (_ocr != null || _ocrError != null) + return _ocr; + + try + { + _ocr = OcrBackend switch + { + "ppocr" => new PaddleOnnxOcr(OcrModelDir!), + "maa" => new MaaOcrService(MaaOcrProfile.Load(OcrModelDir!)), + _ => new TesseractOcrService(new OcrEngineCache + { + DefaultDataPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Tessdata"), + }), + }; + } + catch (Exception ex) + { + _ocrError = ex.Message; + CoreLog.Error($"Flow 服务 OCR 后端装配失败 (backend={OcrBackend}): {ex.Message}"); + } + return _ocr; + } + } + + /// + /// 依据当前设备状态构建一次运行的能力上下文(附租约;调用方负责释放)。 + /// 能力集统一经 组装(组合根唯一装配点): + /// 采集源/手柄以端口传入,OCR/推理按配置共享(借用语义,不得由单次运行释放)。 + /// + public (FlowHostContext Context, CapabilityLease? Lease) BuildRunContext(HostRunScope? scope = null) + { + lock (_gate) + { + // 两种模式统一走 FrameStoreCaptureSource(保留帧号语义:等帧/慢感知依赖 FrameIndex); + // 桥接模式下帧存储就是 GUI 监控页的那一份——画布、agent 的 get_frame 与 GUI 共用同一帧管线 + FrameHub? hub = _bridge?.Frames ?? _localHost?.Frames; + ICaptureSource? capture = hub?.CaptureConnected == true ? new FrameHubCaptureSource(hub) : _bridge is not null + ? _bridge.GetFrameStore() is { } store ? new FrameStoreCaptureSource(store) : null + : VideoConnected + ? new FrameHubCaptureSource(_localHost!.Frames) + : null; + if (capture != null && scope != null) capture = scope.WrapCapture(capture); + IOcrService? ocr = AcquireOcr(); + + CapabilityLease lease = ScriptHostAssembler.Assemble(new ScriptHostContext + { + CaptureSource = capture, + PadInput = !_mockMcu && scope != null ? scope.CreatePad() : _pad, + Ocr = ocr, + BorrowOcr = true, + EnableOcr = false, // 服务侧后端由 SetOcrBackend 决定,装配器不造默认替身 + AppDir = AppDomain.CurrentDomain.BaseDirectory, + }); + Interlocked.Increment(ref _activeContexts); + lease.Released += () => Interlocked.Decrement(ref _activeContexts); + + var context = new FlowHostContext + { + Capture = lease.Capabilities.Capture, + Vision = lease.Capabilities.Vision, + Ocr = lease.Capabilities.Ocr, + Inference = lease.Capabilities.Inference, + Pad = lease.Capabilities.Input, + AppDir = AppDomain.CurrentDomain.BaseDirectory, + }; + return (context, lease); + } + } + + // ---- 编排图运行管理 ---- + + public string StartFlow(string json) + { + var graph = FlowGraph.Parse(json); + var runId = $"run-{Interlocked.Increment(ref _runSeq):000}"; + var handle = new FlowRunHandle(runId, graph); + _runs[runId] = handle; + handle.Task = Task.Run(() => + { + try + { + void Execute(HostRunScope? scope) + { + var (context, lease) = BuildRunContext(scope); + using (lease) + { + handle.Report = new FlowExecutor(graph, context).Run(scope?.Token ?? handle.Cts.Token); + if (handle.Report.Error != null) scope?.MarkFailed("FlowError", handle.Report.ErrorNodeId); + if (handle.Report.WatchdogTriggered) scope?.MarkFailed("Deadline", handle.Report.ErrorNodeId); + } + } + if (!_mockMcu && Controller != null) + new HostRunCoordinator(Controller, _bridge?.Frames ?? _localHost?.Frames) + .Run("flow", graph.Name, false, handle.Cts.Token, scope => Execute(scope)); + else Execute(null); + handle.Status = handle.Report.Error != null ? "error" + : handle.Report.WatchdogTriggered ? "watchdog" : "completed"; + } + catch (OperationCanceledException) + { + handle.Status = "stopped"; + } + catch (Exception ex) + { + handle.Status = "error"; + handle.Error = ex.Message; + handle.Report?.Events.Enqueue("fatal " + ex.Message); + } + }); + return runId; + } + + public void StopFlow(string runId) + { + if (_runs.TryGetValue(runId, out var handle)) + handle.Cts.Cancel(); + } + + /// 运行状态快照(线程安全拷贝);runId 为空返回全部运行的摘要。 + public object FlowStatus(string? runId) + { + var keys = runId is null ? _runs.Keys.ToArray() : [runId]; + var runs = new List(); + foreach (var key in keys) + { + if (!_runs.TryGetValue(key, out var handle)) continue; + lock (handle.Sync) + { + var report = handle.Report; + runs.Add(new + { + runId = handle.RunId, + name = handle.Graph.Name, + status = handle.Status, + steps = report?.Steps ?? 0, + totalMs = report?.TotalMs ?? 0, + error = handle.Error ?? report?.Error, + errorNode = report?.ErrorNodeId, + events = handle.Report?.Events.ToArray() ?? [], + records = report?.Records.Values + .Select(r => new + { + id = r.NodeId, + type = r.Type, + count = r.ExecCount, + reused = r.ReusedCount, + lastMs = r.LastMs, + totalMs = r.TotalMs, + }) + .ToArray() ?? [], + }); + } + } + return new { runs }; + } + + // ---- 单节点试跑(画布右键「执行此节点」/ agent 试跑)---- + + /// 独立模式下的保留节点 id。 + private const string TrialNodeId = "__node__"; + + /// + /// 单节点试跑:语义 = 「执行画布上这一个节点」,与整图执行共用 。 + /// + /// + /// 两种模式:给 graph + nodeId 时从图中取节点(数据入边可引用上游, + /// 上游会被惰性求值——这正是画布上「跑这个节点看输出」的期望行为); + /// 否则用 type/params/inputs 构造独立节点(inputs 只接受字面量)。 + /// + /// + /// actuation 层(pad.* / script.run)拒绝试跑:试跑不得驱动设备,这是 fail-closed 的只读边界。 + /// + /// + public FlowNodeRunResult RunNode(FlowNodeRunRequest request, int timeoutSec = 15) + { + FlowGraph graph; + FlowNode node; + + try + { + if (!string.IsNullOrWhiteSpace(request.Graph)) + { + graph = FlowGraph.Parse(request.Graph); + if (string.IsNullOrWhiteSpace(request.NodeId)) + return FlowNodeRunResult.Fail("graph 模式需要 nodeId"); + var found = graph.Node(request.NodeId); + if (found == null) + return FlowNodeRunResult.Fail($"图中不存在节点: {request.NodeId}"); + node = found; + } + else + { + if (string.IsNullOrWhiteSpace(request.Type)) + return FlowNodeRunResult.Fail("需要 type,或 graph + nodeId"); + node = new FlowNode + { + Id = TrialNodeId, + Type = request.Type, + Params = request.Params ?? [], + Inputs = request.Inputs ?? [], + }; + graph = new FlowGraph { Name = "trial", Nodes = [node] }; + } + } + catch (FlowParseException ex) + { + return FlowNodeRunResult.Fail(ex.Message); + } + + if (!FlowNodeCatalog.IsTrialAllowed(node.Type)) + return FlowNodeRunResult.Fail($"节点类型 {node.Type} 属于 actuation 层(会驱动设备),不支持单节点试跑"); + + var (context, lease) = BuildRunContext(); + var sw = Stopwatch.StartNew(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, timeoutSec))); + try + { + // 执行许可覆盖惰性求值到的上游节点:试跑不得经数据边间接驱动设备 + var runtime = new FlowNodeRuntime(graph, context) + { + ExecuteGuard = candidate => FlowNodeCatalog.IsTrialAllowed(candidate.Type) + ? null + : $"试跑不得执行 {candidate.Type}(actuation 层,会驱动设备)", + }; + var result = runtime.Execute(node, cts.Token); + sw.Stop(); + return new FlowNodeRunResult(true, sw.Elapsed.TotalMilliseconds, result.Reused, null, result.Port, result.Outputs); + } + catch (OperationCanceledException) + { + sw.Stop(); + return FlowNodeRunResult.Fail($"试跑超时({timeoutSec} 秒)", sw.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + sw.Stop(); + return FlowNodeRunResult.Fail(ex.Message, sw.Elapsed.TotalMilliseconds); + } + finally + { + lease?.Dispose(); + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + foreach (var handle in _runs.Values) + handle.Cts.Cancel(); + Task.WhenAll(_runs.Values.Select(h => h.Task ?? Task.CompletedTask)).GetAwaiter().GetResult(); + _localHost?.Dispose(); + lock (_gate) + { + _ocr?.Dispose(); + _ocr = null; + } + } + + /// 单节点试跑请求。 + public sealed record FlowNodeRunRequest( + string? Type = null, + string? NodeId = null, + string? Graph = null, + Dictionary? Params = null, + Dictionary? Inputs = null); + + /// + /// 单节点试跑结果:outputs 为数据输出(含 image 的 Base64,便于前端直接预览), + /// port 为 exec 出口端口(compare 的 true/false 等条件节点只有出口、没有数据输出)。 + /// + public sealed record FlowNodeRunResult( + bool Ok, double Ms, bool Reused, string? Error, string? Port, Dictionary Outputs) + { + public static FlowNodeRunResult Fail(string error, double ms = 0) => + new(false, ms, false, error, null, []); + } + + /// 运行句柄。 + public sealed class FlowRunHandle(string runId, FlowGraph graph) + { + public string RunId { get; } = runId; + public FlowGraph Graph { get; } = graph; + public CancellationTokenSource Cts { get; } = new(); + public Task? Task { get; set; } + public volatile string Status = "running"; + public volatile string? Error; + public volatile FlowRunReport? Report; + public readonly object Sync = new(); + } + + /// 无硬件虚拟手柄(mock 连接用)。 + internal sealed class MockFlowPad : IPadInput + { + public void ClickButtons(GamePadKey key, int duration, CancellationToken token) { } + + public void PressButtons(GamePadKey key) { } + + public void ReleaseButtons(GamePadKey key) { } + + public void ClickStick(GamePadKey key, byte x, byte y, int duration, CancellationToken token) { } + + public void SetStick(GamePadKey key, byte x, byte y) { } + + public void ChangeAmiibo(uint index) { } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Flow/FlowServiceTools.cs b/src/EasyCon.Core/Flow/FlowServiceTools.cs new file mode 100644 index 00000000..37461e27 --- /dev/null +++ b/src/EasyCon.Core/Flow/FlowServiceTools.cs @@ -0,0 +1,264 @@ +using EasyCon.Core.LLM.Agent.Tools; +using EasyCon.Core.LLM.Tools; +using System.Text.Json; + +namespace EasyCon.Core.Flow; + +/// +/// Flow 服务的 MCP/Agent 工具集:设备查询与连接管理 + 编排图运行管理。 +/// 与 HTTP 服务同源(同一 实例)——外部 agent 与前端画布能力对等。 +/// +public static class FlowServiceTools +{ + /// 把设备/编排图工具注册到工具注册中心。 + public static void RegisterAll(ToolRegistry registry, FlowServiceState state) + { + registry.Register(new DeviceQueryTool(state)); + registry.Register(new DeviceConnectTool(state)); + registry.Register(new FlowRunTool(state)); + registry.Register(new FlowControlTool(state)); + registry.Register(new FlowNodeRunTool(state)); + } + + private static JsonSchema NoParams() => new() { Type = "object", Properties = new() }; + + /// 查询视频源/单片机列表与连接状态。 + private sealed class DeviceQueryTool(FlowServiceState state) : IAiTool + { + public string Name => "flow_device_query"; + + public string Description => + "查询设备状态:视频源列表+连接状态、单片机端口列表+连接状态。也可查询节点目录(kind=nodes)。" + + "用于决定连接哪个采集卡/单片机,或了解画布可用节点类型。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["kind"] = new JsonSchemaProperty { Type = "string", Description = "video | mcu | nodes(缺省 video)" }, + } + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var kind = args.TryGetValue("kind", out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() : "video"; + var payload = kind switch + { + "mcu" => JsonSerializer.Serialize(state.McuInfo()), + "nodes" => FlowServiceState.NodeCatalogJson(), + _ => JsonSerializer.Serialize(state.VideoInfo()), + }; + return Task.FromResult(ToolResult.Ok(payload)); + } + } + + /// 连接/断开视频源与单片机。 + private sealed class DeviceConnectTool(FlowServiceState state) : IAiTool + { + public string Name => "flow_device_connect"; + + public string Description => + "连接或断开设备。target=video 时 action=connect 需 index(采集设备索引);" + + "target=mcu 时 action=connect 需 port(串口名,\"mock\" = 无硬件虚拟手柄)。"; + + public bool RequiresConfirmation => true; // 连接真实设备属状态变更,需人工确认 + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["target"] = new JsonSchemaProperty { Type = "string", Description = "video | mcu" }, + ["action"] = new JsonSchemaProperty { Type = "string", Description = "connect | disconnect" }, + ["index"] = new JsonSchemaProperty { Type = "integer", Description = "视频源索引(target=video)" }, + ["port"] = new JsonSchemaProperty { Type = "string", Description = "串口名(target=mcu;mock = 虚拟)" }, + }, + Required = ["target", "action"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var target = Str(args, "target")?.ToLowerInvariant() ?? ""; + var action = Str(args, "action")?.ToLowerInvariant() ?? ""; + string? error; + switch (target, action) + { + case ("video", "connect"): + error = state.ConnectVideo(Int(args, "index", -1)); + break; + case ("video", "disconnect"): + state.DisconnectVideo(); + error = null; + break; + case ("mcu", "connect"): + error = state.ConnectMcu(Str(args, "port") ?? "mock"); + break; + case ("mcu", "disconnect"): + state.DisconnectMcu(); + error = null; + break; + default: + return Task.FromResult(ToolResult.Error("[错误] target 为 video|mcu,action 为 connect|disconnect")); + } + + return Task.FromResult(error == null + ? ToolResult.Ok($"[成功] {target} {action}(video 已连接={state.VideoConnected},mcu 已连接={state.McuConnected})") + : ToolResult.Error($"[错误] {error}")); + } + } + + /// 运行编排图(传图 JSON,或仓库内图文件路径)。 + private sealed class FlowRunTool(FlowServiceState state) : IAiTool + { + public string Name => "flow_run"; + + public string Description => + "执行一张编排图(flow.json):传 graph(图 JSON 字符串)或 path(图文件路径)。返回 runId," + + "随后用 flow_control(action=status) 查询进度/事件/节点计时。"; + + public bool RequiresConfirmation => true; // 图可驱动单片机,需人工确认 + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["graph"] = new JsonSchemaProperty { Type = "string", Description = "图 JSON(与 path 二选一)" }, + ["path"] = new JsonSchemaProperty { Type = "string", Description = "图文件路径(与 graph 二选一)" }, + } + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + string? json = Str(args, "graph"); + var path = Str(args, "path"); + if (json is null && path is not null) + { + var full = Path.IsPathRooted(path) ? path : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path); + if (!File.Exists(full)) + return Task.FromResult(ToolResult.Error($"[错误] 图文件不存在: {full}")); + json = File.ReadAllText(full); + } + if (string.IsNullOrWhiteSpace(json)) + return Task.FromResult(ToolResult.Error("[错误] 需要 graph 或 path 参数")); + + try + { + var runId = state.StartFlow(json); + return Task.FromResult(ToolResult.Ok($"[成功] 已启动,runId={runId}(用 flow_control status 查询)")); + } + catch (FlowParseException ex) + { + return Task.FromResult(ToolResult.Error($"[错误] 图不合法: {ex.Message}")); + } + } + } + + /// 单节点试跑(只读:actuation 层被服务端拒绝)。 + private sealed class FlowNodeRunTool(FlowServiceState state) : IAiTool + { + public string Name => "flow_node_run"; + + public string Description => + "试跑单个编排节点并返回其输出(感知/决策层;actuation 层的 pad.*/script.run 会被拒绝,本工具天然只读)。" + + "用法一:type + params + inputs(inputs 为字面量)独立试跑,例如 type=capture.frame、type=ocr.text 配 inputs.image。 " + + "用法二:graph + nodeId,从图中取节点试跑,其数据入边引用的上游节点会被一并求值——" + + "适合校准 ROI、验证识别结果、检查某个节点输出。节点类型与参数见 flow_device_query(kind=nodes)。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Exclusive; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["type"] = new JsonSchemaProperty { Type = "string", Description = "节点类型(如 capture.frame / ocr.text / compare)" }, + ["params"] = new JsonSchemaProperty { Type = "object", Description = "节点参数字典(与目录 params 同名)" }, + ["inputs"] = new JsonSchemaProperty { Type = "object", Description = "数据入边字面量字典(端口名 → 值)" }, + ["graph"] = new JsonSchemaProperty { Type = "string", Description = "图 JSON(与 nodeId 配合,替代 type)" }, + ["nodeId"] = new JsonSchemaProperty { Type = "string", Description = "图中节点 id(graph 模式必填)" }, + } + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var request = new FlowServiceState.FlowNodeRunRequest( + Type: Str(args, "type"), + NodeId: Str(args, "nodeId"), + Graph: Str(args, "graph"), + Params: Dict(args, "params"), + Inputs: Dict(args, "inputs")); + + var result = state.RunNode(request); + if (!result.Ok) + return Task.FromResult(ToolResult.Error($"[错误] {result.Error}")); + + return Task.FromResult(ToolResult.Ok(JsonSerializer.Serialize(new + { + ok = true, + ms = Math.Round(result.Ms, 1), + reused = result.Reused, + port = result.Port, + outputs = result.Outputs, + }))); + } + } + + /// 查询运行状态 / 停止运行。 + private sealed class FlowControlTool(FlowServiceState state) : IAiTool + { + public string Name => "flow_control"; + + public string Description => + "编排图运行管理:action=status 查询(可选 runId,缺省列全部运行);action=stop 停止指定运行(需 runId)。" + + "status 返回节点事件流与每节点计时。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["action"] = new JsonSchemaProperty { Type = "string", Description = "status | stop" }, + ["runId"] = new JsonSchemaProperty { Type = "string", Description = "运行 id(stop 必填;status 可选)" }, + }, + Required = ["action"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var action = Str(args, "action")?.ToLowerInvariant(); + var runId = Str(args, "runId"); + switch (action) + { + case "status": + return Task.FromResult(ToolResult.Ok(JsonSerializer.Serialize(state.FlowStatus( + string.IsNullOrEmpty(runId) ? null : runId)))); + case "stop": + if (string.IsNullOrEmpty(runId)) + return Task.FromResult(ToolResult.Error("[错误] stop 需要 runId")); + state.StopFlow(runId); + return Task.FromResult(ToolResult.Ok($"[成功] 已请求停止 {runId}")); + default: + return Task.FromResult(ToolResult.Error("[错误] action 为 status | stop")); + } + } + } + + private static string? Str(Dictionary args, string name) => + args.TryGetValue(name, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; + + private static Dictionary? Dict(Dictionary args, string name) => + args.TryGetValue(name, out var el) && el.ValueKind == JsonValueKind.Object + ? el.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()) + : null; + + private static int Int(Dictionary args, string name, int fallback) => + args.TryGetValue(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) + ? v : fallback; +} \ No newline at end of file diff --git a/src/EasyCon.Core/GamePadAdapter.cs b/src/EasyCon.Core/GamePadAdapter.cs index 9b53d90d..59fa4e91 100644 --- a/src/EasyCon.Core/GamePadAdapter.cs +++ b/src/EasyCon.Core/GamePadAdapter.cs @@ -1,74 +1,27 @@ -using EasyCon.Script; -using EasyDevice; +#nullable enable +using EasyCon.Core.Capabilities; +using EasyCon.Core.Maa; using EasyScript; namespace EasyCon.Core; -public sealed class GamePadAdapter(NintendoSwitch easyPad, bool highResolution = false) : ICGamePad +/// 保留旧脚本手柄接口,全部操作委托给 Maa 输入端口。 +public sealed class GamePadAdapter : ICGamePad { - private readonly NintendoSwitch NS = easyPad; + private readonly IPadInput _pad; + public DelayType DelayMethod { get; } - public DelayType DelayMethod => highResolution ? DelayType.HighResolution : DelayType.LowCPU; - - void ICGamePad.ClickButtons(GamePadKey key, int duration, CancellationToken token) - { - NS.Down(key.ToECKey()); - switch (DelayMethod) - { - case DelayType.Normal: Thread.Sleep(duration); break; - case DelayType.LowCPU: CustomDelay.AISleep(duration); break; - case DelayType.HighResolution: - default: - CustomDelay.Delay(duration, token); - break; - } - NS.Up(key.ToECKey()); - } - - void ICGamePad.PressButtons(GamePadKey key) - { - NS.Down(key.ToECKey()); - } - - void ICGamePad.ReleaseButtons(GamePadKey key) - { - NS.Up(key.ToECKey()); - } - - void ICGamePad.ChangeAmiibo(uint index) + public GamePadAdapter(IControllerSession session, bool highResolution = false, ControllerRunLease? run = null) { - NS.ChangeAmiiboIndex((byte)(index & 0x0F)); + DelayMethod = highResolution ? DelayType.HighResolution : DelayType.LowCPU; + _pad = session.CreatePad(DelayMethod, run); } - void ICGamePad.ClickStick(GamePadKey key, byte x, byte y, int duration, CancellationToken token) - { - NS.Down(key.ToECKey(x, y)); - switch (DelayMethod) - { - case DelayType.Normal: Thread.Sleep(duration); break; - case DelayType.LowCPU: CustomDelay.AISleep(duration); break; - case DelayType.HighResolution: - default: - CustomDelay.Delay(duration, token); - break; - } - NS.Up(key.ToECKey(x, y)); - } - - void ICGamePad.SetStick(GamePadKey key, byte x, byte y) - { - if (x == SwitchStick.STICK_CENTER && y == SwitchStick.STICK_CENTER) - { - NS.Up(key.ToECKey(x, y)); - } - else - { - NS.Down(key.ToECKey(x, y)); - } - } - - void ICGamePad.Reset() - { - NS.Reset(); - } + public void ClickButtons(GamePadKey key, int duration, CancellationToken token) => _pad.ClickButtons(key, duration, token); + public void PressButtons(GamePadKey key) => _pad.PressButtons(key); + public void ReleaseButtons(GamePadKey key) => _pad.ReleaseButtons(key); + public void ClickStick(GamePadKey key, byte x, byte y, int duration, CancellationToken token) => _pad.ClickStick(key, x, y, duration, token); + public void SetStick(GamePadKey key, byte x, byte y) => _pad.SetStick(key, x, y); + public void ChangeAmiibo(uint index) => _pad.ChangeAmiibo(index); + public void Reset() => ((MaaPadInput)_pad).Reset(); } \ No newline at end of file diff --git a/src/EasyCon.Core/Hosting/ScriptCompileProfiles.cs b/src/EasyCon.Core/Hosting/ScriptCompileProfiles.cs new file mode 100644 index 00000000..59469315 --- /dev/null +++ b/src/EasyCon.Core/Hosting/ScriptCompileProfiles.cs @@ -0,0 +1,52 @@ +#nullable enable +using EasyCon.Script; +using System.Collections.Immutable; + +namespace EasyCon.Core.Hosting; + +/// +/// 宿主编译档位(组合根的一部分):把「选项组合 → 编译行为」的对应关系集中一处, +/// 避免 GUI / CLI 各自手写 字面量而悄悄漂移。 +/// +/// +/// 单流化后(docs/VM2.md §3.1):所有产物都是同一形态(ECX1 平铺 + v3 定长指令编码), +/// **没有宽窄之分**——原 Desktop/Portable 的格式维度随 EnablePcWideSlots +/// 一起删除。当前定长指令编码的函数帧预算为 255 槽;参数与出站窗口按需分配, +/// 桌面与 MCU 共用该预算及同一条可序列化指令流。 +/// 「能不能装进设备」是资源问题,由烧录前的容量预检判定(docs/McuBytecodeDelivery.md,计划中), +/// 不再由编译档位表达。剩余的两档只是**缓存策略**(与产物内容无关,不进缓存键): +/// +/// +/// :现编(不落盘,进程级缓存兜底)——run/编辑器等高频重编路径。 +/// :obj/ 内容寻址缓存——compile/分发产物路径,重复编译零成本。 +/// +/// +public static class ScriptCompileProfiles +{ + /// + /// 交互现编档:不落盘(进程缓存兜底)。 + /// + /// 外部变量名集合(识图标签名等),null = 无。 + /// false 时跳过 SSA 优化(IR 观测用)。 + public static CompileOptions Interactive(IEnumerable? extVars = null, bool optimize = true) => new() + { + ExtVars = ToSet(extVars), + UseDiskCache = false, + UseProcessCache = true, + Optimize = optimize, + }; + + /// + /// 分发产物档:obj/ 内容寻址缓存;产物可落 .ecx 交 C VM 执行。 + /// + /// 外部变量名集合(识图标签名等),null = 无。 + public static CompileOptions Distributable(IEnumerable? extVars = null) => new() + { + ExtVars = ToSet(extVars), + UseDiskCache = true, + UseProcessCache = true, + }; + + static ImmutableHashSet? ToSet(IEnumerable? extVars) + => extVars is null ? null : [.. extVars]; +} \ No newline at end of file diff --git a/src/EasyCon.Core/Hosting/ScriptHardwareRequirements.cs b/src/EasyCon.Core/Hosting/ScriptHardwareRequirements.cs new file mode 100644 index 00000000..8eda268d --- /dev/null +++ b/src/EasyCon.Core/Hosting/ScriptHardwareRequirements.cs @@ -0,0 +1,26 @@ +using EasyCon.Script; +using EasyCon.Script.Binding; +using EasyCon.Script.Bytecode; + +namespace EasyCon.Core.Hosting; + +/// 宿主从链接后保留的调用判断实时采集需求;NeedIL 还包含已有图片的 ROI/OCR。 +public static class ScriptHardwareRequirements +{ + public static IReadOnlyList ReferencedLabels(CompileResult compilation) + => compilation.Image is not { } image ? [] : image.Functions + .SelectMany(function => function.Instructions) + .Where(instruction => instruction.Op == EcsOpcode.Img) + .Select(instruction => image.Consts[instruction.B].Str ?? throw new InvalidDataException("标签名常量不是字符串")) + .Distinct(StringComparer.Ordinal).ToArray(); + + public static bool RequiresLiveCapture(CompileResult? compilation) + { + if (compilation?.Image is not { } image) return false; + return image.Functions.Any(function => function.Instructions.Any(instruction => + instruction.Op == EcsOpcode.Img || + instruction.Op == EcsOpcode.CallN && instruction.Ext < (uint)image.Natives.Count && + (image.Natives[(int)instruction.Ext].Name == BuiltinFunctions.CaptureHole.Name || + image.Natives[(int)instruction.Ext].Name == BuiltinFunctions.TesseractHole.Name))); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Hosting/ScriptHostAssembler.cs b/src/EasyCon.Core/Hosting/ScriptHostAssembler.cs new file mode 100644 index 00000000..4ede5793 --- /dev/null +++ b/src/EasyCon.Core/Hosting/ScriptHostAssembler.cs @@ -0,0 +1,203 @@ +#nullable enable +using EasyCon.Capture; +using EasyCon.Capture.Audio; +using EasyCon.Core.Capabilities; +using EasyCon.Core.Logging; +using EasyCon.Core.Maa; +using EasyScript; + +namespace EasyCon.Core.Hosting; + +/// +/// 一次脚本运行的「能力原料」清单。宿主(GUI / CLI)只负责凑齐原料; +/// 组合规则、默认值与生命周期集中在 。 +/// +/// +/// 收敛动机:装配原先在 GUI(EasyCon2.Avalonia.Core/Services/ScriptService.cs)与 +/// CLI(EasyCon2.CLI/Program.cs)各手写一份对象字面量,已出现静默漂移—— +/// CLI 只 Dispose Ocr 不 Dispose Inference、两宿主都不装配 +/// (ARG/APP 走 EcxVm 硬编码回落)、文件能力靠 +/// BuiltinCallable 里的静态单例兜底。这些差异集中在装配器后不可能再各自漂移。 +/// +/// +public sealed class ScriptHostContext +{ + /// PRINT/ALERT/FREAD/BEEP 落点;null = 输出静默丢弃。 + public IConsoleIo? Console { get; init; } + + /// L1 键鼠摇杆来源(脚本层 );null 时脚本按键指令得到「不支持」。 + public ICGamePad? Pad { get; init; } + + /// 截屏帧委托(Base64 PNG);装配器据此装配 。 + public FrameDelegate? Frame { get; init; } + + /// 截屏帧端口(优先于 );宿主已持有端口实例时用本项。 + public ICaptureSource? CaptureSource { get; init; } + + /// 手柄端口(优先于 );嵌套装配(Flow 节点内跑脚本)用本项避免二次适配。 + public IPadInput? PadInput { get; init; } + + /// ROI 裁剪委托;与 任一非 null 即装配 。 + public RoiDelegate? Roi { get; init; } + + /// 图像标签匹配委托;null 时 @标签 求值抛「图像标签匹配器未初始化」。 + public LabelMatchDelegate? LabelMatch { get; init; } + + /// 图像处理/标签匹配端口(优先于 / 委托)。 + public IVisionService? VisionService { get; init; } + + /// OCR 后端(所有权移交装配结果);null 且 时装配默认 Tesseract。 + public IOcrService? Ocr { get; init; } + /// 显式 Maa OCR 模型/旧参数映射;未指定时保留已验证的旧默认后端。 + public string? MaaOcrProfilePath { get; init; } + public CancellationToken CancellationToken { get; init; } + + /// 推理后端(所有权移交装配结果);null 且 时装配默认 DNN。 + public IInference? Inference { get; init; } + + /// Existing game-audio source, borrowed without connecting or disposing it. + public IAudioCaptureSource? AudioSource { get; init; } + + /// Audio port override for hosts with their own implementation. + public IAudioService? AudioService { get; init; } + + /// Saved entry-script directory used to resolve audiolabel/name.audiolabel/label.json. + public string? ScriptDirectory { get; init; } + + /// 文件族能力;null = 桌面参考实现 。 + public IFileSystem? Files { get; init; } + + /// ARG(i) 参数表;null = 空表。装配后优先于 ScriptHostOptions.Args(EcxVm 既有语义)。 + public string[]? Args { get; init; } + + /// __APP__ 与 tessdata 的基准目录;null = 进程 BaseDirectory。 + public string? AppDir { get; init; } + + /// tessdata 目录;null = <AppDir>/Tessdata。 + public string? TessdataPath { get; init; } + + /// 未显式提供 时是否装配默认 Tesseract(默认 true)。 + public bool EnableOcr { get; init; } = true; + + /// 未显式提供 时是否装配默认 DNN(默认 true)。 + public bool EnableInference { get; init; } = true; + + /// + /// 借用语义(默认 false = 装配结果接管 / 所有权并负责释放)。 + /// 置 true 时资源所有权仍属调用方——嵌套装配必须如此, + /// 否则内层脚本运行结束时释放掉外层运行还在用的 OCR/推理服务。 + /// + public bool BorrowResources { get; init; } + public bool BorrowOcr { get; init; } + public bool BorrowInference { get; init; } +} + +/// +/// 装配结果:能力集 + 本次装配拥有的原生资源(OCR 引擎、DNN 会话)。 +/// 调用方必须在脚本运行结束后 Dispose(通常在 using 作用域内);重复 Dispose 安全。 +/// 装配器**接管**传入的 / 所有权。 +/// +public sealed class CapabilityLease(CapabilitySet capabilities, IOcrService? ocr, IInference? inference, + bool ownsOcr = true, bool ownsInference = true) : IDisposable +{ + private int _disposed; + internal event Action? Released; + + /// 本次运行的能力集。 + public CapabilitySet Capabilities { get; } = capabilities; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + // 借用语义:资源所有权在调用方(嵌套装配的外层),内层释放不得拆掉共享服务 + if (ownsOcr) DisposeQuietly(ocr, nameof(IOcrService)); + if (ownsInference) DisposeQuietly(inference, nameof(IInference)); + Released?.Invoke(); + } + + /// 释放失败不能反噬调用方(本方法通常在 finally 中执行)。 + private static void DisposeQuietly(IDisposable? resource, string name) + { + if (resource is null) + return; + + try + { + resource.Dispose(); + } + catch (Exception ex) + { + CoreLog.Warn($"{name} 释放失败: {ex.Message}"); + } + } +} + +/// +/// 宿主能力装配器 —— **全仓唯一构造 的地方**(组合根的唯一能力组装点)。 +/// +/// 契约(改装配行为只改这里,宿主不得自行拼装): +/// +/// 「null = 不可用」:宿主没给的原料对应能力即为 null,不静默造替身 +/// (例外是 ,其缺省是桌面参考实现,与 VM 文件族语义一致)。 +/// 默认值只在此定义一处: 恒装配、OCR/DNN 缺省装配但可关。 +/// 返回的 拥有 OCR/推理资源,宿主负责释放。 +/// 装配器不感知具体宿主:只吃委托与端口接口( 等), +/// 不认识 ICaptureService / FrameProducer,故 Core 不反向依赖宿主。 +/// +/// +public static class ScriptHostAssembler +{ + /// 按清单装配能力集;返回的租约负责原生资源释放。 + public static CapabilityLease Assemble(ScriptHostContext context) + { + ArgumentNullException.ThrowIfNull(context); + + string appDir = string.IsNullOrWhiteSpace(context.AppDir) + ? AppDomain.CurrentDomain.BaseDirectory + : context.AppDir; + + IVisionService? vision = context.VisionService + ?? (context.Roi is null && context.LabelMatch is null + ? null + : new DelegateVisionService(context.Roi, context.LabelMatch)); + + IOcrService? ocr = context.Ocr; + if (ocr is null && context.EnableOcr) + { + string? maaProfile = context.MaaOcrProfilePath ?? Environment.GetEnvironmentVariable("EASYCON_MAA_OCR_PROFILE"); + if (!string.IsNullOrWhiteSpace(maaProfile)) ocr = new MaaOcrService(MaaOcrProfile.Load(maaProfile), context.CancellationToken); + // OCR 无条件装配(与历史行为一致):tessdata 缺失时识别给出明确错误, + // 而非让脚本看到「能力不存在」这种无从排查的语义。 + ocr ??= new TesseractOcrService(new OcrEngineCache + { + DefaultDataPath = string.IsNullOrWhiteSpace(context.TessdataPath) + ? Path.Combine(appDir, "Tessdata") + : context.TessdataPath, + }); + } + + IInference? inference = context.Inference; + if (inference is null && context.EnableInference) + inference = new DnnInference(); + + var capabilities = new CapabilitySet + { + Input = context.PadInput ?? (context.Pad is null ? null : new PadInputAdapter(context.Pad)), + Console = context.Console, + Environment = new HostEnvironment(context.Args ?? [], appDir), + Files = context.Files ?? DesktopFileSystem.Instance, + Capture = context.CaptureSource ?? (context.Frame is null ? null : new DelegateCaptureSource(context.Frame)), + Vision = vision, + Ocr = ocr, + Inference = inference, + Audio = context.AudioService ?? (context.AudioSource == null ? null + : new AudioLabelService(context.AudioSource, context.ScriptDirectory)), + }; + + return new CapabilityLease(capabilities, ocr, inference, + ownsOcr: context.Ocr == null || !(context.BorrowOcr || context.BorrowResources), + ownsInference: context.Inference == null || !(context.BorrowInference || context.BorrowResources)); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/IO/CustomSleep.cs b/src/EasyCon.Core/IO/CustomSleep.cs index 83efaaa3..e2345f92 100644 --- a/src/EasyCon.Core/IO/CustomSleep.cs +++ b/src/EasyCon.Core/IO/CustomSleep.cs @@ -25,25 +25,27 @@ public static void AISleep(int milliseconds) if (milliseconds <= 0) return; var sw = Stopwatch.StartNew(); - long remainingTicks = milliseconds * 10000L; // Stopwatch 以 100ns 为单位 + long targetTicks = (long)(milliseconds * (double)Stopwatch.Frequency / 1000.0); + long remainingTicks = targetTicks; // 如果剩余时间 > 5ms,先使用平台特定高精度睡眠 - if (remainingTicks > 50000) // 5ms + if (remainingTicks > Stopwatch.Frequency / 200) // 5ms { // 预留 2-3ms 给最后自旋 - int sleepMs = Math.Max(1, (int)((remainingTicks - 30000) / 10000)); + double remainingMilliseconds = remainingTicks * 1000.0 / Stopwatch.Frequency; + int sleepMs = Math.Max(1, (int)(remainingMilliseconds - 3)); // 调用平台特定的高精度睡眠 HighResolutionSleep(sleepMs); // 重新计算剩余时间 - remainingTicks = milliseconds * 10000L - sw.ElapsedTicks; + remainingTicks = targetTicks - sw.ElapsedTicks; } // 最后自旋微调(精度关键阶段) while (remainingTicks > 0) { - if (remainingTicks <= 2000) // 剩余 < 0.2ms,极短自旋 + if (remainingTicks <= Stopwatch.Frequency / 5000) // 剩余 < 0.2ms,极短自旋 { Thread.SpinWait(1); } @@ -51,7 +53,7 @@ public static void AISleep(int milliseconds) { Thread.Yield(); // 主动让出CPU,但保持就绪状态 } - remainingTicks = milliseconds * 10000L - sw.ElapsedTicks; + remainingTicks = targetTicks - sw.ElapsedTicks; } } /// diff --git a/src/EasyCon.Core/ImgLabelExt.cs b/src/EasyCon.Core/ImgLabelExt.cs deleted file mode 100644 index 18d7a762..00000000 --- a/src/EasyCon.Core/ImgLabelExt.cs +++ /dev/null @@ -1,15 +0,0 @@ -using EasyCon.Capture; -using OpenCvSharp; -using System.Drawing; -using System.Runtime.Versioning; - -public static class ImgLabelExt -{ - /// - /// 从图像中获取指定范围的位图 - /// - public static Mat GetRange(this Mat self, System.Drawing.Point pos, ImgLabel img) - { - return new Mat(self, new Rect(pos.X + img.RangeX, pos.Y + img.RangeY, img.TargetWidth, img.TargetHeight)); - } -} \ No newline at end of file diff --git a/src/EasyCon.Core/Input/PadSession.cs b/src/EasyCon.Core/Input/PadSession.cs new file mode 100644 index 00000000..63ae7153 --- /dev/null +++ b/src/EasyCon.Core/Input/PadSession.cs @@ -0,0 +1,263 @@ +#nullable enable +using EasyCon.Core.Maa; +using EasyDevice; +using EasyDevice.Connection; +using EasyScript; +using System.Diagnostics; + +namespace EasyCon.Core.Input; + +/// 物理手柄会话。只管理协议、输入所有权与连接,不认识采集或 Maa 原生对象。 +internal sealed class PadSession : IDisposable +{ + private readonly NintendoSwitch _device; + private readonly object _gate = new(); + private SourceOwnership? _ownership; + private InputOwner _owner; + private long _epoch; + private bool _ready; + private bool _disposed; + + public PadSession() : this(new NintendoSwitch()) { } + public PadSession(NintendoSwitch device) + { + _device = device; + _owner = NewOwner(null, default); + _device.StatusChanged += status => + { + if (status == Status.Error) + { + bool wasReady = _ready; + _ready = false; + InputOwner owner = CurrentOwner; + if (wasReady || owner.RunId != null) owner.Cancel(RunCancellationReason.DeviceLost); + } + StatusChanged?.Invoke(status); + }; + } + + public bool IsConnected => _ready && _device.IsConnected(); + public bool SupportsLegacyCommands => _device.SupportsLegacyCommands; + public string? FirmwareName => _device.FirmwareName; + public string? FirmwareVersion => _device.FirmwareVersion; + public bool? ConsoleConnected => _device.ConsoleConnected; + public string? LastConnectionError => _device.LastConnectionError; + public string? Endpoint { get; private set; } + public long InputEpoch { get { lock (_gate) return _owner.Epoch; } } + public bool HeldByUser { get; private set; } + public long ConnectionEpoch { get; private set; } + public InputOwner CurrentOwner { get { lock (_gate) return _owner; } } + public CleanupStatus LastCleanup { get; private set; } + public event LogHandler Log { add => _device.Log += value; remove => _device.Log -= value; } + public event BytesTransferedHandler BytesSent { add => _device.BytesSent += value; remove => _device.BytesSent -= value; } + public event BytesTransferedHandler BytesReceived { add => _device.BytesReceived += value; remove => _device.BytesReceived -= value; } + public event StatusChangedHandler? StatusChanged; + public SwitchReport GetReport() => ((IReporter)_device).GetReport(); + + private InputOwner NewOwner(string? runId, CancellationToken token) => new(runId, ++_epoch, token); + + public void Validate(InputOwner owner, bool cleanup = false) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!ReferenceEquals(owner, _owner)) + throw new ControllerOperationException("StaleEpoch", "设备写代次已失效"); + if (!cleanup) owner.Token.ThrowIfCancellationRequested(); + } + } + + public InputOwner Transfer(string? runId, CancellationToken token) + { + lock (_gate) + { + _owner.Cancel(RunCancellationReason.UserStop); + _owner = NewOwner(runId, token); + return _owner; + } + } + + public bool Release(InputOwner owner) + { + lock (_gate) + { + Validate(owner, cleanup: true); + bool written = _device.ResetAndWaitForWriteAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult(); + LastCleanup = written ? CleanupStatus.Written : CleanupStatus.ReleaseUnconfirmed; + if (!written) _ready = false; + return written; + } + } + + public ControllerReply Execute(ControllerCommand command, InputOwner owner, CancellationToken operationToken) + { + Validate(owner); + if (command.RunId != owner.RunId || command.Epoch != owner.Epoch) + throw new ControllerOperationException("InvalidOwner", "请求与固定绑定的输入所有者不一致"); + if (command.Op == "device.connect") + { + _ready = false; + ConnectionEpoch++; + _device.Disconnect(); + Endpoint = null; + HeldByUser = false; + _ownership?.Dispose(); + _ownership = null; + string port = command.Port ?? throw new ControllerOperationException("InvalidPort", "缺少通信端口"); + ConnectionEndpoint target = ConnectionEndpoint.Parse(port); + string endpoint = target.Address; + try { _ownership = new SourceOwnership(target.OwnershipKey); } + catch (IOException ex) { throw new ControllerOperationException("Busy", ex.Message); } + try + { + NintendoSwitch.ConnectResult result = _device.TryConnect(endpoint); + if (result != NintendoSwitch.ConnectResult.Success) + { + _ownership.Dispose(); _ownership = null; + return new(command.RequestId, true, Value: result.ToString()); + } + Endpoint = endpoint; + HeldByUser = true; + _ready = Release(owner); + return new(command.RequestId, true, Completion: _ready ? InputCompletion.Written : InputCompletion.Accepted, + Value: (_ready ? ControllerConnectResult.Success : ControllerConnectResult.ReleaseUnconfirmed).ToString()); + } + catch + { + _device.Disconnect(); + _ownership?.Dispose(); _ownership = null; + throw; + } + } + if (command.Op == "device.disconnect") + { + bool written = Release(owner); + _ready = false; + HeldByUser = false; + ConnectionEpoch++; + _device.Disconnect(); + _ownership?.Dispose(); _ownership = null; + Endpoint = null; + return new(command.RequestId, true, written ? null : "ReleaseUnconfirmed", + written ? InputCompletion.Written : InputCompletion.Accepted); + } + if (!IsConnected) throw new ControllerOperationException("InputDisconnected", "手柄未连接或中性报告尚未确认"); + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(owner.Token, operationToken); + CancellationToken token = cancellation.Token; + token.ThrowIfCancellationRequested(); + string? value = null; + bool accepted = true; + InputCompletion completion = InputCompletion.Accepted; + switch (command.Op) + { + case "input.down": _device.Down(RequiredKey(command)); break; + case "input.up": _device.Up(RequiredKey(command)); break; + case "input.pulse": + if (command.Duration < 0) throw new ControllerOperationException("InvalidDuration", "按键持续时间不能为负"); + ECKey key = RequiredKey(command); + _device.Down(key); + try { Delay(command.Duration, command.Timing, token); } + finally { lock (_gate) { Validate(owner, cleanup: true); _device.Up(key); } } + break; + case "input.report": + if (command.Report == null || command.Report.Hat > 8) throw new ControllerOperationException("InvalidReport", "无效的完整手柄报告"); + _device.ApplyReport(command.Report.ToReport()); break; + case "input.left": ValidateDirection(command.Direction); _device.LeftDirection(command.Direction, command.Down); break; + case "input.right": ValidateDirection(command.Direction); _device.RightDirection(command.Direction, command.Down); break; + case "input.hat": ValidateDirection(command.Direction); _device.HatDirection(command.Direction, command.Down); break; + case "input.reset": accepted = Release(owner); completion = InputCompletion.Written; break; + case "amiibo.index": accepted = _device.ChangeAmiiboIndex(command.Index); break; + case "device.flash": accepted = _device.Flash(RequiredData(command)); break; + case "device.start": accepted = _device.RemoteStart(); break; + case "device.stop": accepted = _device.RemoteStop(); break; + case "device.version": value = _device.GetVersion().ToString(); break; + case "device.unpair": accepted = _device.UnPair(); break; + case "device.mode": accepted = _device.ChangeControllerMode(command.Index); break; + case "device.color": accepted = _device.ChangeControllerColor(RequiredData(command)); break; + case "amiibo.save": accepted = _device.SaveAmiibo(command.Index, RequiredData(command)); break; + case "record.start": _device.StartRecord(); break; + case "record.stop": _device.StopRecord(); break; + case "record.pause": _device.PauseRecord(); break; + case "record.script": value = _device.GetRecordScript(); break; + default: throw new ControllerOperationException("UnsupportedOperation", $"不支持的控制器操作 {command.Op}"); + } + return new(command.RequestId, accepted, accepted ? null : "ProtocolFailure", accepted ? completion : InputCompletion.Accepted, value); + } + + public bool StandardKey(InputOwner owner, int code, bool down) + { + lock (_gate) + { + Validate(owner, cleanup: !down); + if (!IsConnected) return false; + ECKey? key = code switch + { + >= 0 and <= 15 => ECKeyUtil.Button((SwitchButton)(1 << code)), + >= 16 and <= 24 => ECKeyUtil.HAT((SwitchHAT)(code - 16)), + _ => null, + }; + if (key == null) return false; + if (down) _device.Down(key); else _device.Up(key); + return true; + } + } + + private static ECKey RequiredKey(ControllerCommand command) => command.Key?.ToKey() ?? throw new ControllerOperationException("InvalidKey", "缺少按键"); + private static byte[] RequiredData(ControllerCommand command) => command.Data ?? throw new ControllerOperationException("InvalidData", "缺少数据"); + private static void ValidateDirection(DirectionKey direction) + { + if (((int)direction & ~15) != 0) throw new ControllerOperationException("InvalidDirection", "无效方向"); + } + private static void Delay(int duration, DelayType timing, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (timing == DelayType.HighResolution) CustomDelay.Delay(duration, token); + else if (timing == DelayType.Normal) token.WaitHandle.WaitOne(duration); + else + { + long end = Stopwatch.GetTimestamp() + (long)(duration * (double)Stopwatch.Frequency / 1000); + while (Stopwatch.GetTimestamp() < end) + { + token.ThrowIfCancellationRequested(); + int remaining = (int)((end - Stopwatch.GetTimestamp()) * 1000 / Stopwatch.Frequency); + if (remaining > 0) CustomDelay.AISleep(Math.Min(remaining, 10)); + else Thread.Yield(); + } + } + token.ThrowIfCancellationRequested(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _owner.Cancel(RunCancellationReason.HostShutdown); + _device.Disconnect(); + _ownership?.Dispose(); + _owner.Dispose(); + } +} + +internal sealed class InputOwner : IDisposable +{ + private readonly CancellationTokenSource _cancellation; + public InputOwner(string? runId, long epoch, CancellationToken token) + { + RunId = runId; + Epoch = epoch; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + Token = _cancellation.Token; + } + public string? RunId { get; } + public long Epoch { get; } + public CancellationToken Token { get; } + public RunCancellationReason? Reason { get; private set; } + + public void Cancel(RunCancellationReason reason) + { + Reason ??= reason; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + } + public void Dispose() => _cancellation.Dispose(); +} \ No newline at end of file diff --git a/src/EasyCon.Core/LLM/Agent/AgentOrchestrator.cs b/src/EasyCon.Core/LLM/Agent/AgentOrchestrator.cs new file mode 100644 index 00000000..2f673b9b --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/AgentOrchestrator.cs @@ -0,0 +1,882 @@ +using EasyCon.Core.LLM; +using EasyCon.Core.LLM.Agent.Tools; +using EasyCon.Core.LLM.Messages; +using EasyCon.Core.LLM.Models; +using EasyCon.Core.LLM.Skills; +using EasyCon.Core.LLM.Tools; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent; + +/// +/// Agent 编排事件,ViewModel 据此更新 UI。 +/// +public abstract record AgentEvent +{ + /// 新一轮开始,携带当前对话前缀(用于 UI 占位)。 + public record RoundStart(string Prefix) : AgentEvent; + + /// 正文内容增量。 + public record ContentDelta(string Text) : AgentEvent; + + /// 思考/推理内容增量。 + public record ThinkingDelta(string Text) : AgentEvent; + + /// 工具调用开始执行(Id 关联同轮并行同名调用)。 + public record ToolExecuting(string Name, string Id) : AgentEvent; + + /// 工具调用完成,携带结果摘要(Id 与 对应)。 + public record ToolCompleted(string Name, string Id, string Summary, string FullResult) : AgentEvent; + + /// 工具等待人工确认(UI 据此弹确认框;拒绝后该工具以错误结果回传模型)。 + public record ToolAwaitingConfirmation(string Name, string Id, string Reason) : AgentEvent; + + /// Token 用量更新。 + public record UsageUpdated(int Prompt, int Completion, int Total) : AgentEvent; + + /// 错误信息。 + public record Error(string Message) : AgentEvent; + + /// 流式传输中断,正在重试。 + public record Retrying(int Attempt, int MaxAttempts, string Reason) : AgentEvent; + + /// 对话循环结束。 + public record Completed(string FinalContent) : AgentEvent; + + /// 调试信息(原始 SSE 数据、解析异常等)。 + public record DebugInfo(string Message) : AgentEvent; +} + +/// +/// AI Agent 核心编排器,管理 ReAct 循环、工具调度、prompt 组装。 +/// 与 UI 完全解耦,可独立测试。 +/// +public class AgentOrchestrator +{ + public const int MaxToolRounds = 50; + public const int MaxHistoryMessages = 40; + private const int ToolTimeoutSeconds = 30; + + /// 取帧工具名。get_frame 的图片消息编排有专属语义,见 AttachFrameImage。 + public const string FrameToolName = "get_frame"; + + /// 旧帧图降级后的占位文本(保留对话时间轴位置,不再携带像素)。 + public const string FrameOmittedPlaceholder = "[旧画面已省略:最新画面见后文]"; + + /// 取帧类工具与其它工具同轮混调时的拒绝文案(fail-closed,指导模型下轮单独调用)。 + public const string FrameMixedRoundError = + "[错误] 取帧类工具必须单独调用:与其它工具同轮执行会让画面早于本轮动作,请本轮只调用该工具,其余动作下一轮再调。"; + + /// 当前模型不支持视觉输入时取帧类工具的拒绝文案。 + public const string FrameVisionUnsupportedError = + "[错误] 当前模型不支持视觉输入,无法使用取帧类工具。请在模型列表选择具备视觉能力的模型后再试。"; + + /// 单个工具结果写入历史的字符预算,超出即截断并附显式尾注。 + public const int MaxToolResultChars = 20_000; + + private readonly ToolRegistry _tools; + private readonly PromptAssembler? _promptAssembler; + private readonly Func? _clientFactory; + private readonly Func? _projectDirectoryProvider; + + /// + /// 危险工具()的人工确认回调。 + /// 返回 true 才执行;为 null 时 fail-closed 直接拒绝该工具调用。 + /// + private readonly Func>? _confirmationHandler; + private int _latestFrameImageIndex = -1; + private readonly ReflectionState _reflectionState = new(); + + /// + /// 反思状态管理器:失败按结构化状态计数,重复调用按「工具名+参数」签名检测。 + /// 两者都与结果文本解耦——结果可携带时间戳等每次变化的凭据,不得参与检测。 + /// + private class ReflectionState + { + private const int MaxReflections = 3; + private const int FailureThreshold = 2; + private const int RepeatedCallThreshold = 3; + + private int _reflectionCount; + private int _consecutiveFailures; + private int _repeatedCallStreak; + private string _lastCallSignature = ""; + private string _lastCallTool = ""; + private string _triggerReason = ""; + private bool _shouldTriggerReflection; + + /// 用户发送新消息时重置反思状态。 + public void Reset() + { + _reflectionCount = 0; + _consecutiveFailures = 0; + _repeatedCallStreak = 0; + _lastCallSignature = ""; + _lastCallTool = ""; + _triggerReason = ""; + _shouldTriggerReflection = false; + } + + /// 按工具结果的结构化状态计数失败(Error/Retryable 均计入,Success 重置)。 + public void TrackResult(ToolResultStatus status) + { + if (status == ToolResultStatus.Success) + { + _consecutiveFailures = 0; + return; + } + + _consecutiveFailures++; + if (_consecutiveFailures >= FailureThreshold) + SetTrigger($"已连续 {_consecutiveFailures} 次工具执行失败"); + } + + /// 按「工具名+参数签名」检测重复调用,参数变化即重置连击。 + public void TrackCall(string toolName, string argumentsJson) + { + var signature = toolName + "|" + StableArgumentsKey(argumentsJson); + if (signature != _lastCallSignature) + { + _lastCallSignature = signature; + _lastCallTool = toolName; + _repeatedCallStreak = 1; + return; + } + + _repeatedCallStreak++; + if (_repeatedCallStreak < RepeatedCallThreshold) + return; + + SetTrigger($"已连续 {_repeatedCallStreak} 次以相同参数调用 {_lastCallTool}"); + _repeatedCallStreak = 0; + } + + /// 追踪助手回复中的反思标记。 + public void TrackReflectionMarker(string assistantContent) + { + if (!string.IsNullOrEmpty(assistantContent) && assistantContent.Contains("反思:")) + _reflectionCount++; + } + + /// 判断是否需要注入反思提示(触发即消费,一轮最多一条)。 + public bool ShouldInjectReflection() + { + if (_reflectionCount >= MaxReflections) + return false; + + if (!_shouldTriggerReflection) + return false; + + _shouldTriggerReflection = false; + return true; + } + + /// 反思深度:已反思过仍循环,或失败远超阈值,升级为深度反思。 + public string GetReflectionDepth() => + _reflectionCount > 0 || _consecutiveFailures > FailureThreshold ? "deep" : "shallow"; + + /// 触发原因(现场数据),随反思提示一同注入。 + public string DescribeTrigger() => _triggerReason; + + public string GetReflectionPrompt() + { + var body = GetReflectionDepth() == "deep" + ? SystemPrompts.ReflectionDeep + : SystemPrompts.Reflection; + return $"{DescribeTrigger()}。{body}"; + } + + private void SetTrigger(string reason) + { + _triggerReason = reason; + _shouldTriggerReflection = true; + } + + /// 参数的稳定签名:顶层键排序后拼接,参数微调即产生不同签名。 + private static string StableArgumentsKey(string argumentsJson) + { + if (string.IsNullOrWhiteSpace(argumentsJson)) return ""; + try + { + using var doc = JsonDocument.Parse(argumentsJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return argumentsJson; + return string.Join(";", doc.RootElement.EnumerateObject() + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => p.Name + "=" + p.Value.GetRawText())); + } + catch + { + return argumentsJson; + } + } + } + + public AgentOrchestrator(ToolRegistry tools) : this(tools, skillRegistry: null) { } + + /// + /// 创建带技能体系的编排器。传入的 为空时, + /// 回退到旧的硬编码 SystemPrompts 路径(向后兼容)。 + /// + public AgentOrchestrator(ToolRegistry tools, SkillRegistry? skillRegistry) + : this(tools, skillRegistry, clientFactory: null) + { + } + + /// + /// 创建编排器并可注入客户端工厂。 为空时走 + /// ChatClientFactory(生产路径);测试注入 fake client 以覆盖错误链路。 + /// 返回当前脚本项目目录(未打开脚本为 null), + /// 用于装配 AGENTS.md 项目指令与工作区文件工具的根目录。 + /// + public AgentOrchestrator( + ToolRegistry tools, + SkillRegistry? skillRegistry, + Func? clientFactory, + Func? projectDirectoryProvider = null) + : this(tools, skillRegistry, clientFactory, projectDirectoryProvider, confirmationHandler: null) + { + } + + /// + /// 完整构造: 为危险工具的人工确认回调 + /// (返回 true 才执行;null 时危险工具被 fail-closed 拒绝)。 + /// + public AgentOrchestrator( + ToolRegistry tools, + SkillRegistry? skillRegistry, + Func? clientFactory, + Func? projectDirectoryProvider, + Func>? confirmationHandler) + { + _tools = tools; + _clientFactory = clientFactory; + _projectDirectoryProvider = projectDirectoryProvider; + _confirmationHandler = confirmationHandler; + if (skillRegistry is { All.Count: > 0 }) + _promptAssembler = new PromptAssembler(skillRegistry); + } + + /// + /// 重置帧图片索引和反思状态(新对话时调用)。 + /// + public void ResetState() + { + _latestFrameImageIndex = -1; + _reflectionState.Reset(); + } + + /// + /// 注入帧图片消息:新帧永远紧跟本轮 tool 文本结果追加到历史末尾, + /// 旧帧降级为纯文本占位(保留对话时间轴上的位置,不再携带像素)。 + /// 不变式:历史中至多一张真图,且总在最近一次 get_frame 的 tool 消息之后—— + /// 视觉模型按消息位置归属画面时序,若把新图原地回写到旧槽位, + /// 它会落在后续动作轮之前而被模型解读为"行动前的画面"(旧画面问题的根因)。 + /// + private void AttachFrameImage(List history, ChatMessage img) + { + if (_latestFrameImageIndex >= 0 && _latestFrameImageIndex < history.Count) + history[_latestFrameImageIndex] = ChatMessage.User(FrameOmittedPlaceholder); + + _latestFrameImageIndex = history.Count; + history.Add(img); + } + + /// + /// 执行完整的 Agent 对话循环。 + /// + /// 对话历史(会被修改,追加新消息)。 + /// 模型 ID。 + /// API 供应商配置。 + /// 事件回调,ViewModel 据此更新 UI。 + /// 取消令牌。 + /// 视觉能力从 provider 中 modelId 对应的模型条目解析(工具的 RequiresVision 由这里裁决), + /// 不再接受宿主传入布尔——能力判定收归 runtime,集成方无法误报。 + public async Task RunAsync( + List history, + string modelId, + ProviderConfig provider, + Action onEvent, + CancellationToken ct) + { + // 模型条目缺失时按"无视觉"处理(fail-closed):声明了 RequiresVision 的工具会被拒绝 + var visionSupported = provider.Models.FirstOrDefault(m => m.Id == modelId)?.Vision ?? false; + const int maxStreamRetries = 2; + var toolDefs = _tools.ToToolDefinitions(); + var hasTools = toolDefs.Count > 0; + var pendingReply = new StringBuilder(); + var pendingThinking = new StringBuilder(); + + // 重置反思状态(用户新消息) + _reflectionState.Reset(); + + // 订阅调试日志与请求阶段重试,转发到 UI + var debugHandler = new Action(msg => onEvent(new AgentEvent.DebugInfo(msg))); + OpenAIChatClient.DebugLog += debugHandler; + var retryHandler = new Action((attempt, maxAttempts, reason) => + onEvent(new AgentEvent.Retrying(attempt, maxAttempts, reason))); + OpenAIChatClient.RequestRetrying += retryHandler; + try + { + for (var round = 0; round < MaxToolRounds; round++) + { + var streamSuccess = false; + var accumulator = new ToolCallAccumulator(); + var hasContent = false; + + // RoundStart 在流式重试循环之外发射,确保即使重试 UI 也只创建一个 AssistantMessage + onEvent(new AgentEvent.RoundStart("")); + pendingReply.Clear(); + pendingThinking.Clear(); + + // ── 流式断线重连循环 ── + for (var streamRetry = 0; streamRetry <= maxStreamRetries; streamRetry++) + { + accumulator = new ToolCallAccumulator(); + + var client = _clientFactory?.Invoke(provider) ?? ChatClientFactory.Create(provider); + var request = new ChatRequest + { + Model = modelId, + Messages = BuildMessages(history, round) + }; + if (hasTools) + { + request.Tools = toolDefs; + request.ToolChoice = ToolChoice.Auto; + } + + var retryableError = false; + hasContent = false; + + await foreach (var delta in client.SendStreamAsync(request, ct)) + { + switch (delta.Type) + { + case DeltaType.Thinking: + pendingThinking.Append(delta.Text); + onEvent(new AgentEvent.ThinkingDelta(delta.Text)); + break; + case DeltaType.Content: + pendingReply.Append(delta.Text); + hasContent = true; + onEvent(new AgentEvent.ContentDelta(delta.Text)); + break; + case DeltaType.ToolCall: + if (delta.ToolCallDelta is not null) + accumulator.Append(delta.ToolCallDelta); + break; + case DeltaType.Error: + if (delta.Retryable) + { + // 流式传输中断 → 标记为重试,不追加到回复 + retryableError = true; + } + else + { + pendingReply.Append($"[错误] {delta.Text}"); + onEvent(new AgentEvent.Error(delta.Text)); + } + break; + case DeltaType.Usage: + onEvent(new AgentEvent.UsageUpdated(delta.PromptTokens, delta.CompletionTokens, delta.TotalTokens)); + break; + } + + if (retryableError) break; + } + + // 可重试错误 → 丢弃本轮部分数据,等待后重发同一轮请求 + if (retryableError && streamRetry < maxStreamRetries) + { + pendingReply.Clear(); + pendingThinking.Clear(); + onEvent(new AgentEvent.Retrying( + streamRetry + 1, maxStreamRetries, "流式传输中断,正在重新连接...")); + await Task.Delay(TimeSpan.FromSeconds(2 * (streamRetry + 1)), ct); + continue; + } + + // 可重试错误但重试已耗尽 + if (retryableError) + { + onEvent(new AgentEvent.Error("流式传输多次中断,无法恢复")); + var aborted = ComposeFinal(pendingReply, "[连接中断:多次重试后仍无法完成本轮响应]"); + history.Add(ChatMessage.Assistant(aborted)); + onEvent(new AgentEvent.Completed(aborted)); + return; + } + + streamSuccess = true; + break; + } + + if (!streamSuccess) continue; + + // 没有工具调用或没有注册工具 → 本轮即最终回复 + var toolCalls = accumulator.Build(); + if (!hasTools || toolCalls.Count == 0) + { + // 终局必须落历史:正常回复原样入档,流内错误文本与空回复也要留下交代, + // 模型下轮不应对自己上轮的输出或沉默零认知 + history.Add(ChatMessage.Assistant(ComposeFinal(pendingReply, "[模型返回了空回复]"))); + if (hasContent) + _reflectionState.TrackReflectionMarker(pendingReply.ToString()); + onEvent(new AgentEvent.Completed(pendingReply.ToString())); + return; + } + + // 将 assistant 的工具调用加入历史,并检测重复调用签名 + foreach (var tc in toolCalls) + _reflectionState.TrackCall(tc.Function.Name, tc.Function.Arguments); + + var callMessage = ChatMessage.Assistant(toolCalls); + if (pendingReply.Length > 0) + { + // 中间轮的助手正文(含按提示词写下的"反思:/思考:")必须随工具调用一起入历史 + callMessage.Content = pendingReply.ToString(); + _reflectionState.TrackReflectionMarker(pendingReply.ToString()); + } + history.Add(callMessage); + + // 执行工具调用 + var results = await ExecuteToolCallsAsync(toolCalls, history, onEvent, ct, visionSupported); + + // 按顺序将工具结果加入历史,并处理多模态附加消息 + for (var i = 0; i < toolCalls.Count; i++) + { + var content = ApplyResultBudget(results[i].Content); + history.Add(ChatMessage.Tool(toolCalls[i].Id, content, toolCalls[i].Function.Name)); + _reflectionState.TrackResult(results[i].Status); + + // 处理附加的多模态消息(如 get_frame 的图片) + if (results[i].AttachedMessage is { } img) + AttachFrameImage(history, img); + } + } + + // 达到最大轮次 + var final = ComposeFinal(pendingReply, "[已达到工具调用最大轮次,任务被中止]"); + history.Add(ChatMessage.Assistant(final)); + onEvent(new AgentEvent.Completed(final)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { // 停止事实写入历史:下一轮模型需要知道自己曾被用户打断 + history.Add(ChatMessage.Assistant(ComposeFinal(pendingReply, "[已停止]"))); + throw; + } + finally + { + OpenAIChatClient.DebugLog -= debugHandler; + OpenAIChatClient.RequestRetrying -= retryHandler; + } + } + + /// 终止交代 = 已流出正文 + 终止标记;正文为空时仅保留标记。 + private static string ComposeFinal(StringBuilder pending, string marker) + { + var text = pending.ToString().Trim(); + return text.Length == 0 ? marker : text + "\n" + marker; + } + + /// + /// 批量执行工具调用。按模型声明顺序分组调度:Exclusive 工具独占串行, + /// 连续声明的 Parallel(只读)工具并行走,写类工具因此天然不与任何工具并发。 + /// + private async Task> ExecuteToolCallsAsync( + List toolCalls, + List history, + Action onEvent, + CancellationToken ct, + bool visionSupported) + { + // 取帧类工具有两条 fail-closed 约束:需要视觉能力(RequiresVision); + // 必须单独调用(ReturnsImage——混轮并行会让画面早于本轮动作而被模型误当行动结果)。 + var mixedRound = toolCalls.Count > 1; + + Task Dispatch(ToolCall tc) + { + var tool = _tools.Get(tc.Function.Name); + if (tool is { RequiresVision: true } && !visionSupported) + return RejectFrameCall(tc, FrameVisionUnsupportedError, onEvent); + if (tool is { ReturnsImage: true } && mixedRound) + return RejectFrameCall(tc, FrameMixedRoundError, onEvent); + + // 危险工具确认门:无回调 fail-closed 拒绝;回调拒绝则以错误结果回传模型 + if (_tools.Get(tc.Function.Name) is { RequiresConfirmation: true }) + return ExecuteWithConfirmationAsync(tc, onEvent, ct); + + return ExecuteToolCallAsync(tc, onEvent, ct); + } + + var results = new ToolResult[toolCalls.Count]; + var index = 0; + while (index < toolCalls.Count) + { + if (!IsParallelTool(toolCalls[index])) + { + results[index] = await Dispatch(toolCalls[index]); + index++; + continue; + } + + var end = index + 1; + while (end < toolCalls.Count && IsParallelTool(toolCalls[end])) + end++; + + var batch = Enumerable.Range(index, end - index) + .Select(k => Dispatch(toolCalls[k])) + .ToArray(); + var batchResults = await Task.WhenAll(batch); + for (var k = index; k < end; k++) + results[k] = batchResults[k - index]; + + index = end; + } + + return results.ToList(); + } + + private bool IsParallelTool(ToolCall toolCall) => + _tools.Get(toolCall.Function.Name)?.Concurrency == ToolConcurrency.Parallel; + + /// 拒绝 get_frame 调用:不执行工具,直接回传错误结果(事件照常发射,保持 UI 行完整)。 + private static Task RejectFrameCall(ToolCall toolCall, string error, Action onEvent) + { + onEvent(new AgentEvent.ToolExecuting(toolCall.Function.Name, toolCall.Id)); + onEvent(new AgentEvent.ToolCompleted(toolCall.Function.Name, toolCall.Id, error, error)); + return Task.FromResult(ToolResult.Error(error)); + } + + /// + /// 危险工具执行路径:先向宿主请求人工确认。 + /// 未配置确认回调时 fail-closed 拒绝;用户拒绝时以可读错误回传模型,工具不执行。 + /// + private async Task ExecuteWithConfirmationAsync( + ToolCall toolCall, + Action onEvent, + CancellationToken ct) + { + var reason = $"工具 {toolCall.Function.Name} 需要人工确认"; + if (_confirmationHandler is null) + { + var denied = $"{reason},但当前环境未提供确认机制,已拒绝执行。"; + onEvent(new AgentEvent.ToolExecuting(toolCall.Function.Name, toolCall.Id)); + onEvent(new AgentEvent.ToolCompleted(toolCall.Function.Name, toolCall.Id, denied, denied)); + return ToolResult.Error(denied); + } + + onEvent(new AgentEvent.ToolAwaitingConfirmation(toolCall.Function.Name, toolCall.Id, reason)); + bool approved; + try + { + approved = await _confirmationHandler(toolCall).WaitAsync(ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + var fault = $"{reason}时确认流程出错: {ex.Message},已拒绝执行。"; + onEvent(new AgentEvent.ToolCompleted(toolCall.Function.Name, toolCall.Id, fault, fault)); + return ToolResult.Error(fault); + } + + if (!approved) + { + var rejected = $"{reason},用户已拒绝执行。请调整方案或向用户说明后再试。"; + onEvent(new AgentEvent.ToolCompleted(toolCall.Function.Name, toolCall.Id, rejected, rejected)); + return ToolResult.Error(rejected); + } + + return await ExecuteToolCallAsync(toolCall, onEvent, ct); + } + + /// + /// 执行单个工具调用,返回完整 ToolResult(含可能的多模态附加消息)。 + /// + private async Task ExecuteToolCallAsync( + ToolCall toolCall, + Action onEvent, + CancellationToken ct) + { + var fn = toolCall.Function; + var tool = _tools.Get(fn.Name); + if (tool is null) + return ToolResult.Error($"[错误] 未知工具: {fn.Name}"); + + try + { + onEvent(new AgentEvent.ToolExecuting(fn.Name, toolCall.Id)); + + var args = fn.ParseArguments(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(ToolTimeoutSeconds)); + + // 工具体可能包含长时间同步 CPU 工作(截图编码、grep、目录遍历), + // 放到线程池执行,避免整轮 ReAct 循环占用 UI 线程导致界面卡顿。 + // 注意:同步工具一旦开始执行仍无法被超时中断,取消经 token 尽力传播。 + var result = await Task.Run(() => tool.ExecuteAsync(args, timeoutCts.Token)); + var summary = Truncate(result.Content, 200); + onEvent(new AgentEvent.ToolCompleted(fn.Name, toolCall.Id, summary, result.Content)); + return result; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return ToolResult.Error($"[错误] 工具 {fn.Name} 执行超时({ToolTimeoutSeconds}秒),请调整参数后重试。"); + } + catch (Exception ex) + { + return ToolResult.Error($"[工具执行异常] {ex.Message}"); + } + } + + /// + /// 测试钩子:模拟连续工具失败以触发反思注入。走真实的 TrackResult 阈值路径。 + /// 仅供单元测试使用,生产代码不应调用。 + /// + internal void TestHook_SimulateReflectionTrigger() + { + // FailureThreshold = 2:连续两次失败结果即触发 + _reflectionState.TrackResult(ToolResultStatus.Error); + _reflectionState.TrackResult(ToolResultStatus.Error); + } + + /// + /// 组装发送给模型的消息列表。 + /// 结构: + /// [0] system: 基础角色提示词(干净,不包裹) + /// [1..N] user: 包裹的技能内容 / 反思 / 截断 + /// 最后为对话历史。 + /// + internal List BuildMessages(List history, int currentRound = 0) + { + // 基础角色提示词(始终使用 system 角色,不包裹) + var identityPrompt = _promptAssembler is not null + ? _promptAssembler.GetIdentityPrompt() + : SystemPrompts.Identity; + var baseRolePrompt = _promptAssembler is not null + ? _promptAssembler.GetBaseRolePrompt() + : SystemPrompts.Default; + + // 技能内容(路径 A:PromptAssembler / 路径 B:Legacy BuildSystemPrompt) + var skillContent = _promptAssembler is not null + ? _promptAssembler.Build(history, currentRound) + : BuildSystemPrompt(history, currentRound); + + // 反思提示 + var reflectionPrompt = _reflectionState.ShouldInjectReflection() + ? _reflectionState.GetReflectionPrompt() + : null; + + // 滑动窗口截断 + List body; + string? truncationNotice = null; + if (history.Count <= MaxHistoryMessages) + { + body = history; + } + else + { + var start = SkipToSafeBoundaryIndex(history, history.Count - MaxHistoryMessages); + + // 最新帧图必须随其 tool 结果一同出现在请求内:若被滑窗裁掉, + // 模型只收到"已获取当前画面"的文本而无图,只能凭旧记忆行动。 + // 回扩窗口把 [assistant(tool_calls), tool, user(image)] 三联整体带上。 + if (_latestFrameImageIndex >= 0 && _latestFrameImageIndex < start) + { + start = _latestFrameImageIndex; + while (start > 0 && IsToolSequenceMessage(history[start - 1])) + start--; + } + + truncationNotice = $"[系统提示] 为控制上下文长度,前面的 {start} 条对话已被省略。"; + body = history.Skip(start).ToList(); + } + + var messages = new List(); + + // ── [0] system: 身份标识(仅一句)── + messages.Add(ChatMessage.System(identityPrompt)); + + // ── [1] system: 基础角色提示词(干净,不包裹)── + messages.Add(ChatMessage.System(baseRolePrompt)); + + // ── user: 技能内容 ── + if (!string.IsNullOrWhiteSpace(skillContent)) + messages.Add(ChatMessage.User($"\n{skillContent}\n")); + + // ── user: 截断说明 ── + if (truncationNotice is not null) + messages.Add(ChatMessage.User($"\n{truncationNotice}\n")); + + // ── user: AGENTS.md 项目指令(存在才注入,内容真实读取)── + var agentsMd = LoadAgentsMd(); + if (agentsMd is not null) + { + var currentDate = DateTime.Now.ToString("yyyy-MM-dd"); + messages.Add(ChatMessage.User($@" +As you answer the user's questions, you can use the following context: +Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written. + +Contents of AGENTS.md (user default instructions): + +{agentsMd} + +# currentDate +Today's date is {currentDate}. + +IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task. +")); + } + + // ── [n..] 对话历史 ── + messages.AddRange(body); + + // 反思提醒贴着失败现场:追加在对话末尾(而非上下文前部),文本自带触发原因 + if (reflectionPrompt is not null) + messages.Add(ChatMessage.User($"\n{reflectionPrompt}\n")); + + return messages; + } + + /// + /// 滑动窗口截断时,把截断点对齐到可作为对话起点的消息下标。 + /// 直接截断可能落在 tool 消息上,形成"孤儿 tool"(缺少前置的 + /// assistant.tool_calls),违反多数供应商的消息顺序约束。 + /// 这里向前跳过连续的 tool 消息,直到落在 user / assistant。 + /// + private static int SkipToSafeBoundaryIndex(List history, int skip) + { + var start = Math.Min(skip, history.Count); + while (start < history.Count && history[start].Role == "tool") + start++; + return start; + } + + /// 帧图三联回扩时需要吞并的前置消息:tool 结果,或携带 tool_calls 的 assistant。 + private static bool IsToolSequenceMessage(ChatMessage m) => + m.Role == "tool" || (m.ToolCalls is { Count: > 0 }); + + /// AGENTS.md 注入内容的字符上限,超出即截断并注明。 + internal const int MaxAgentsMdChars = 20_000; + + /// + /// 读取当前脚本项目目录下的 AGENTS.md(用户项目指令)。 + /// 未打开脚本、目录不存在或无此文件时不注入;读取失败静默跳过,不阻塞请求。 + /// + private string? LoadAgentsMd() + { + var projectDir = _projectDirectoryProvider?.Invoke(); + if (string.IsNullOrEmpty(projectDir)) + return null; + + var path = Path.Combine(projectDir, "AGENTS.md"); + if (!File.Exists(path)) + return null; + + try + { + var content = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(content)) + return null; + if (content.Length > MaxAgentsMdChars) + content = content[..MaxAgentsMdChars] + + $"\n[AGENTS.md 内容过长已截断:仅保留前 {MaxAgentsMdChars} 字符]"; + return content; + } + catch + { + return null; + } + } + + /// + /// [Legacy fallback] 旧的硬编码技能内容组装逻辑(不含基础角色提示词)。 + /// 仅当编排器未注入 SkillRegistry 时使用(向后兼容旧测试与旧调用方)。 + /// 基础角色提示词(SystemPrompts.Default)由 BuildMessages 以 system 角色单独发送。 + /// + /// 基于工具调用历史决定注入哪些 prompt 段落,关键词检测作为 fallback。 + /// + internal string BuildSystemPrompt(List history, int currentRound) + { + var sb = new StringBuilder(); + + // 扫描历史中实际的工具调用 + var usedTools = history + .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) + .SelectMany(m => m.ToolCalls!) + .Select(tc => tc.Function.Name) + .ToHashSet(StringComparer.Ordinal); + + var needsScriptSyntax = usedTools.Any(t => + t is "read_script" or "write_script" or "edit_script" + or "compile_script" or "format_script" or "grep_script"); + if (!needsScriptSyntax) + needsScriptSyntax = HasRecentUserKeyword(history, ["脚本", "script", "ecs", "语法", "代码", "编写"]); + + var needsVision = usedTools.Contains("get_frame"); + if (!needsVision) + needsVision = HasRecentUserKeyword(history, ["画面", "屏幕", "截图", "看看", "识别", "看到", "图像"]); + + var needsExecution = usedTools.Any(t => t is "run_script" or "stop_script"); + if (!needsExecution) + needsExecution = HasRecentUserKeyword(history, ["运行", "执行", "自动", "跑脚本"]); + + if (needsScriptSyntax) + sb.Append(SystemPrompts.ScriptSyntax); + + if (needsVision && needsExecution) + { + sb.Append(SystemPrompts.ReActLoop); + } + else + { + if (needsVision) + sb.Append(SystemPrompts.Vision); + if (needsExecution) + sb.Append(SystemPrompts.ScriptExecution); + } + + if (currentRound > 0) + { + var remaining = MaxToolRounds - currentRound; + sb.Append("\n\n[系统] 当前已使用 ") + .Append(currentRound).Append(" 轮工具调用,剩余 ").Append(remaining).Append(" 轮。"); + if (remaining <= 10) + sb.Append(" 轮次即将耗尽,请尽快完成目标并回复用户。"); + } + + return sb.ToString(); + } + + private static bool HasRecentUserKeyword(List history, string[] keywords) + { + return history + .Where(m => m.Role == "user") + .TakeLast(3) + .Select(m => m.Content?.ToString()?.ToLowerInvariant() ?? "") + .Any(msg => keywords.Any(k => msg.Contains(k))); + } + + private static string Truncate(string text, int max) + { + if (string.IsNullOrEmpty(text)) return ""; + return text.Length <= max ? text : text[..max] + "..."; + } + + /// + /// 单条工具结果的体积预算:超限截断并附显式尾注。 + /// 防线设在编排层入口,内置工具与 MCP 工具的长输出都不得静默挤占上下文。 + /// + internal static string ApplyResultBudget(string content) + { + if (content.Length <= MaxToolResultChars) + return content; + return string.Concat(content.AsSpan(0, MaxToolResultChars), + $"\n\n[工具输出过长已截断:原始 {content.Length} 字符,仅保留前 {MaxToolResultChars} 字符。]"); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/LLM/Agent/Mcp/McpServerHost.cs b/src/EasyCon.Core/LLM/Agent/Mcp/McpServerHost.cs new file mode 100644 index 00000000..0d928084 --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Mcp/McpServerHost.cs @@ -0,0 +1,271 @@ +using EasyCon.Core.LLM.Agent.Tools; +using EasyCon.Core.Logging; +using Microsoft.Extensions.AI; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Mcp; + +/// +/// 把 ToolRegistry(IAiTool)导出为 MCP 工具并承载 MCP Server。 +/// GUI 用 HTTP 传输(loopback /mcp),CLI 用 stdio 传输——同一套工具,能力完全对等。 +/// 危险工具(RequiresConfirmation)在导出时过滤:MCP 调用方无法参与 GUI 的人工确认,fail-closed。 +/// +public static class McpServerHost +{ + /// + /// 把注册表中可导出的工具包装为 McpServerTool 列表。 + /// = false 时过滤 RequiresConfirmation 工具(fail-closed 默认); + /// 显式开启后才导出(外部 agent 驱动设备/图,需宿主明确授权)。 + /// + public static List ExportTools(ToolRegistry registry, bool includeDangerous = false) + { + var tools = new List(); + foreach (var (_, tool) in registry.Snapshot()) + { + if (tool.RequiresConfirmation && !includeDangerous) + continue; + tools.Add(McpServerTool.Create(new AiToolAIFunction(tool))); + } + return tools; + } + + private static McpServerOptions BuildOptions(string name, string version, List tools) => new() + { + ServerInfo = new Implementation { Name = name, Version = version }, + ToolCollection = [.. tools] + }; + + /// 启动 stdio 传输的 MCP Server 并开始运行(阻塞直到 stdin 关闭)。 + public static async Task RunStdioAsync(ToolRegistry registry, string name, string version, CancellationToken ct, + bool includeDangerous = false) + { + var options = BuildOptions(name, version, ExportTools(registry, includeDangerous)); + var server = McpServer.Create(new StdioServerTransport(options), options); + await server.RunAsync(ct); + } + + /// + /// 启动 loopback HTTP 传输的 MCP Server(streamable HTTP,无状态模式)。 + /// 返回停止句柄;进程退出时由调用方释放。 + /// + public static async Task StartHttpAsync( + ToolRegistry registry, string name, string version, int port, CancellationToken ct, + bool includeDangerous = false) + { + var tools = ExportTools(registry, includeDangerous); + var http = new McpHttpServer(name, version, tools, port); + await http.StartAsync(ct); + return http; + } +} + +/// loopback HTTP MCP Server:HttpListener + StreamableHttpServerTransport(无状态)。 +public sealed class McpHttpServer : IAsyncDisposable +{ + private readonly string _name; + private readonly string _version; + private readonly List _tools; + private readonly int _port; + private HttpListener? _listener; + private CancellationTokenSource? _cts; + + internal McpHttpServer(string name, string version, List tools, int port) + { + _name = name; + _version = version; + _tools = tools; + _port = port; + } + + /// 监听地址。 + public string Url => $"http://127.0.0.1:{_port}/mcp"; + + /// 启动监听循环(后台)。 + public Task StartAsync(CancellationToken ct) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{_port}/mcp/"); + _listener.Start(); + _ = AcceptLoopAsync(_cts.Token); + CoreLog.Info($"[McpServer] HTTP MCP 已监听 {Url}"); + return Task.CompletedTask; + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + var listener = _listener!; + while (!ct.IsCancellationRequested) + { + HttpListenerContext ctx; + try + { + ctx = await listener.GetContextAsync().WaitAsync(ct); + } + catch (Exception) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + CoreLog.Info($"[McpServer] 请求接收失败: {ex.Message}"); + continue; + } + _ = HandleAsync(ctx, ct); + } + } + + private async Task HandleAsync(HttpListenerContext ctx, CancellationToken ct) + { + try + { + switch (ctx.Request.HttpMethod) + { + case "POST": + await HandlePostAsync(ctx, ct); + break; + case "GET": + await HandleGetAsync(ctx, ct); + break; + case "DELETE": + WriteEmpty(ctx, 200); + break; + default: + WriteEmpty(ctx, 405); + break; + } + } + catch (Exception ex) + { + CoreLog.Info($"[McpServer] 请求处理失败: {ex.Message}"); + try { WriteEmpty(ctx, 500); } catch { /* 连接已断开 */ } + } + finally + { + try { ctx.Response.Close(); } catch { } + } + } + + private async Task HandlePostAsync(HttpListenerContext ctx, CancellationToken ct) + { + using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8); + var body = await reader.ReadToEndAsync(ct); + JsonRpcMessage? message; + try + { + message = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + } + catch (JsonException) + { + message = null; + } + if (message is null) + { + WriteEmpty(ctx, 400); + return; + } + + // 无状态模式:每个 POST 一个独立 transport,会话不开(简单且无泄漏面) + await using var transport = new StreamableHttpServerTransport { Stateless = true }; + var options = BuildOptions(); + var server = McpServer.Create(transport, options); + _ = server.RunAsync(CancellationToken.None); + + await using var responseStream = new MemoryStream(); + var hasResponse = await transport.HandlePostRequestAsync(message, responseStream, ct); + if (hasResponse && responseStream.Length > 0) + { + var bytes = responseStream.ToArray(); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes, ct); + } + else + { + // 通知类消息无响应体:202 Accepted + WriteEmpty(ctx, 202); + } + } + + private async Task HandleGetAsync(HttpListenerContext ctx, CancellationToken ct) + { + // 无状态模式不支持 GET SSE 流:明确拒绝并提示用 POST + ctx.Response.StatusCode = 405; + ctx.Response.ContentType = "text/plain"; + var bytes = "text/event-stream not supported in stateless mode; use POST"u8.ToArray(); + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes, ct); + } + + private McpServerOptions BuildOptions() => new() + { + ServerInfo = new Implementation { Name = _name, Version = _version }, + ToolCollection = [.. _tools] + }; + + private static void WriteEmpty(HttpListenerContext ctx, int status) + { + ctx.Response.StatusCode = status; + ctx.Response.ContentLength64 = 0; + } + + /// + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_listener is { IsListening: true }) + _listener.Stop(); + _listener?.Close(); + await Task.CompletedTask; + } +} + +/// +/// IAiTool → AIFunction 桥接:schema 用工具自带 JSON Schema 原样透传, +/// 调用结果以纯文本回传(多模态附加内容在外部 agent 场景降级为提示文本)。 +/// +public sealed class AiToolAIFunction : AIFunction +{ + private readonly IAiTool _tool; + + /// 包装一个 IAiTool。 + public AiToolAIFunction(IAiTool tool) => _tool = tool; + + /// + public override string Name => _tool.Name; + + /// + public override string Description => _tool.Description; + + /// + public override JsonElement JsonSchema => + JsonSerializer.SerializeToElement(_tool.Parameters); + + /// + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + var args = new Dictionary(); + foreach (var (key, value) in arguments) + { + if (value is JsonElement el) + args[key] = el.Clone(); + else if (value is not null) + args[key] = JsonSerializer.SerializeToElement(value); + } + + var result = await _tool.ExecuteAsync(args, cancellationToken); + var text = result.Status == ToolResultStatus.Success + ? result.Content + : $"{result.Content}\n[状态] {result.Status}"; + return result.AttachedMessage is not null + ? text + "\n[提示] 本工具还产生了图片附加内容,HTTP MCP 通道不支持图片,请改用 GUI 内置 Agent 查看。" + : text; + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/PromptAssembler.cs b/src/EasyCon.Core/LLM/Agent/PromptAssembler.cs similarity index 99% rename from src/EasyCon2.Avalonia.Core/AiAgent/PromptAssembler.cs rename to src/EasyCon.Core/LLM/Agent/PromptAssembler.cs index f58f8c9c..ae7a1e1a 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/PromptAssembler.cs +++ b/src/EasyCon.Core/LLM/Agent/PromptAssembler.cs @@ -3,7 +3,7 @@ using EasyCon.Core.LLM.Skills; using System.Text; -namespace EasyCon2.Avalonia.Core.AiAgent; +namespace EasyCon.Core.LLM.Agent; /// /// 系统提示词组装器,替代 中硬编码的 BuildSystemPrompt。 diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/SkillExecutor.cs b/src/EasyCon.Core/LLM/Agent/SkillExecutor.cs similarity index 62% rename from src/EasyCon2.Avalonia.Core/AiAgent/SkillExecutor.cs rename to src/EasyCon.Core/LLM/Agent/SkillExecutor.cs index e9ba8617..b89b2024 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/SkillExecutor.cs +++ b/src/EasyCon.Core/LLM/Agent/SkillExecutor.cs @@ -1,11 +1,11 @@ using EasyCon.Core.LLM; +using EasyCon.Core.LLM.Agent.Tools; using EasyCon.Core.LLM.Messages; using EasyCon.Core.LLM.Models; using EasyCon.Core.LLM.Skills; using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.AiAgent.Tools; -namespace EasyCon2.Avalonia.Core.AiAgent; +namespace EasyCon.Core.LLM.Agent; /// /// Skill 执行器。支持两种执行模式: @@ -20,7 +20,7 @@ public class SkillExecutor { private readonly SkillRegistry _skills; private readonly ToolRegistry _tools; - private readonly Func _getProvider; + private readonly Func _getProvider; private readonly Func _getModelId; private const int MaxSubAgentRounds = 10; @@ -30,9 +30,9 @@ public class SkillExecutor /// /// 技能注册中心。 /// 工具注册中心。 - /// 延迟获取当前 ProviderConfig(模型可能切换)。 + /// 延迟获取当前 ProviderConfig(模型可能切换);未选择有效供应商时为 null。 /// 延迟获取当前 modelId(模型可能切换)。 - public SkillExecutor(SkillRegistry skills, ToolRegistry tools, Func getProvider, Func getModelId) + public SkillExecutor(SkillRegistry skills, ToolRegistry tools, Func getProvider, Func getModelId) { _skills = skills; _tools = tools; @@ -46,22 +46,21 @@ public SkillExecutor(SkillRegistry skills, ToolRegistry tools, Func技能名称。 /// 传递给技能的用户消息。 /// 取消令牌。 - /// 执行结果文本。 - public async Task ExecuteAsync(string skillName, string message, CancellationToken ct) + /// 执行结果;fork 模式可携带子 Agent 产出的最新图片。 + public async Task ExecuteAsync(string skillName, string message, CancellationToken ct) { var skill = _skills.Get(skillName); if (skill is null) - return $"[错误] 未知技能: {skillName}。可用 list_skills 查看已注册技能。"; + return ToolResult.Error($"[错误] 未知技能: {skillName}。可用 list_skills 查看已注册技能。"); var isFork = string.Equals(skill.Manifest.Context, "fork", StringComparison.OrdinalIgnoreCase); if (!isFork) { - // Inline 模式:返回 body + 消息,由主 LLM 在下一轮处理 - var body = skill.Body; - if (string.IsNullOrWhiteSpace(body)) - return $"(技能 {skillName} 无正文指令)"; - return $"{body}\n\n---\n\n要处理的内容: {message}"; + // inline 技能的指令已随上下文按需注入,也可 read_skill 显式获取; + // execute_skill 收敛为 fork 专用通道,避免同一份正文出现第三个来源 + return ToolResult.Error( + $"[提示] 技能 {skillName} 为 inline 模式:完整指令可用 read_skill 获取,无需 execute_skill 执行。"); } // Fork 模式:启动子 Agent ReAct 循环 @@ -71,11 +70,15 @@ public async Task ExecuteAsync(string skillName, string message, Cancell /// /// 子 Agent ReAct 循环。独立 LLM 调用,不共享主 Agent 的对话历史。 /// - private async Task RunSubAgentAsync(Skill skill, string message, CancellationToken ct) + private async Task RunSubAgentAsync(Skill skill, string message, CancellationToken ct) { + var provider = _getProvider(); + if (provider is null || string.IsNullOrWhiteSpace(provider.BaseUrl) || string.IsNullOrWhiteSpace(provider.ApiKey)) + return ToolResult.Error("[错误] 未选择有效的模型供应商,无法执行 fork 技能。请先在模型列表中选择可用模型。"); + var body = skill.Body; if (string.IsNullOrWhiteSpace(body)) - return "(技能无正文指令,无法执行 fork 模式)"; + return ToolResult.Error("(技能无正文指令,无法执行 fork 模式)"); var history = new List { @@ -83,7 +86,7 @@ private async Task RunSubAgentAsync(Skill skill, string message, Cancell ChatMessage.User(message) }; - var client = ChatClientFactory.Create(_getProvider()); + var client = ChatClientFactory.Create(provider); var toolDefs = BuildSubAgentToolDefs(skill); var hasTools = toolDefs.Count > 0; @@ -109,17 +112,20 @@ private async Task RunSubAgentAsync(Skill skill, string message, Cancell } catch (OperationCanceledException) { - return "[子Agent] 执行超时"; + // 区分用户主动停止与超时:一律报「超时」会带偏模型的自我修正方向 + return ToolResult.Error(ct.IsCancellationRequested ? "[子Agent] 执行已停止" : "[子Agent] 执行超时"); } if (!response.Success) - return $"[子Agent错误] {response.ErrorMessage ?? "未知错误"}"; + return ToolResult.Error($"[子Agent错误] {response.ErrorMessage ?? "未知错误"}"); // 无工具调用 → 最终回复 if (!response.HasToolCalls) { var content = response.Content; - return string.IsNullOrWhiteSpace(content) ? "(子Agent 未返回内容)" : content; + return ToolResult.Ok( + string.IsNullOrWhiteSpace(content) ? "(子Agent 未返回内容)" : content, + FindLatestImage(history)); } // 有工具调用 → 执行并追加结果 @@ -131,16 +137,33 @@ private async Task RunSubAgentAsync(Skill skill, string message, Cancell } } - return "[子Agent] 已达到最大工具调用轮次,请简化任务后重试。"; + return ToolResult.Error("[子Agent] 已达到最大工具调用轮次,请简化任务后重试。"); + } + + /// + /// 从子 Agent 历史中取最近的图片消息。子 Agent 的中间图片不回填主对话, + /// 但最终画面是主模型决策需要的观察结果,随 AttachedMessage 一并上交。 + /// + private static ChatMessage? FindLatestImage(List history) + { + for (var i = history.Count - 1; i >= 0; i--) + { + if (history[i].Content is List parts && parts.Any(p => p.Type == "image_url")) + return history[i]; + } + return null; } /// /// 按 skill 的 allowed_tools 白名单过滤工具定义。 - /// 白名单为空时允许全部工具。 + /// 白名单为空时允许全部工具,但 execute_skill 一律排除: + /// 否则子 Agent 可再 fork 子 Agent,LLM 循环嵌套与开销无上界。 /// private List BuildSubAgentToolDefs(Skill skill) { - var allTools = _tools.ToToolDefinitions(); + var allTools = _tools.ToToolDefinitions() + .Where(t => t.Function.Name != Tools.ExecuteSkillTool.ToolName) + .ToList(); var allowed = skill.Manifest.AllowedTools; if (allowed is null || allowed.Count == 0) return allTools; diff --git a/src/EasyCon.Core/LLM/Agent/Tools/AtomicRuntimeTools.cs b/src/EasyCon.Core/LLM/Agent/Tools/AtomicRuntimeTools.cs new file mode 100644 index 00000000..92440f3f --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/AtomicRuntimeTools.cs @@ -0,0 +1,302 @@ +using EasyCon.Core.Capabilities; +using EasyCon.Core.LLM.Messages; +using EasyCon.Core.LLM.Tools; +using EasyScript; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// 原子运行时工具:直接驱动手柄输入 / 取帧 / OCR, +/// 让模型具备"看画面 → 按键 → 再看画面"的原子闭环,无需写整段脚本。 +/// 依赖只含 Core 能力端口(IPadInput / ICaptureSource / IOcrService), +/// 宿主经 Func 惰性提供能力实例(未连接返回 null),工具不感知任何集成—— +/// 与 EvalEcsTool 同一范式,GUI/CLI 可装配同一实现。 +/// +public static class AtomicRuntimeTools +{ + /// 注册全部原子运行时工具。 + public static void RegisterAll( + ToolRegistry registry, + Func padProvider, + Func captureProvider, + Func ocrProvider) + { + registry.Register(new PressButtonTool(padProvider)); + registry.Register(new SetStickTool(padProvider)); + registry.Register(new CaptureFrameTool(captureProvider)); + registry.Register(new OcrFrameTool(ocrProvider, captureProvider)); + } +} + +/// press_button:按键点击(可重复)。按键名在 JSON 边界一次解析为 GamePadKey,此后全程强类型。 +public class PressButtonTool : IAiTool +{ + private readonly Func _padProvider; + + public PressButtonTool(Func padProvider) => _padProvider = padProvider; + + public string Name => "press_button"; + + public string Description => + "在 Switch 上按一个实体按键(如 A/B/X/Y/L/R/ZL/ZR/PLUS/TOP/LEFT 等)。" + + "适合诊断、调试和少量交互;长序列操作请改用脚本。会真实操作设备,执行前需用户确认。"; + + public bool RequiresConfirmation => true; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["key"] = new JsonSchemaProperty { Type = "string", Description = "按键名(A/B/X/Y/L/R/ZL/ZR/MINUS/PLUS/LCLICK/RCLICK/HOME/CAPTURE/TOP/LEFT/RIGHT/UP/DOWN/LS/RS)" }, + ["duration_ms"] = new JsonSchemaProperty { Type = "integer", Description = "单次按住时长(毫秒),默认 100" }, + ["times"] = new JsonSchemaProperty { Type = "integer", Description = "重复次数,默认 1,上限 100" }, + ["interval_ms"] = new JsonSchemaProperty { Type = "integer", Description = "两次之间的间隔(毫秒),默认 100" } + }, + Required = ["key"] + }; + + public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var key = GetString(args, "key"); + if (string.IsNullOrWhiteSpace(key)) + return ToolResult.Error("[错误] 缺少必填参数 key"); + if (!TryParseKey(key, out var gamePadKey, out var keyError)) + return ToolResult.Error($"[错误] {keyError}"); + + var pad = _padProvider(); + if (pad is null) + return ToolResult.Error("[错误] 单片机未连接,无法执行按键"); + + var times = GetInt(args, "times", 1); + if (times < 1 || times > 100) + return ToolResult.Error($"[错误] 次数超出范围 (1-100): {times}"); + + var durationMs = Math.Clamp(GetInt(args, "duration_ms", 100), 1, 10_000); + var intervalMs = Math.Clamp(GetInt(args, "interval_ms", 100), 1, 10_000); + + try + { + for (var i = 0; i < times; i++) + { + pad.ClickButtons(gamePadKey, durationMs, ct); + if (i < times - 1 && intervalMs > 0) + await Task.Delay(intervalMs, ct); + } + return ToolResult.Ok($"[成功] 已按 {key} × {times}(每次 {durationMs}ms,间隔 {intervalMs}ms)"); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return ToolResult.Error($"[错误] 按键执行失败: {ex.Message}"); + } + } + + internal static bool TryParseKey(string key, out GamePadKey parsed, out string error) + { + if (Enum.TryParse(key.Trim(), ignoreCase: true, out parsed) && parsed != GamePadKey.None) + { + error = ""; + return true; + } + parsed = GamePadKey.None; + error = $"未知按键名: {key}(合法值如 A/B/X/Y/L/R/ZL/ZR/PLUS/TOP/LEFT/LS/RS 等)"; + return false; + } + + internal static string? GetString(Dictionary args, string name) => + args.TryGetValue(name, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; + + internal static int GetInt(Dictionary args, string name, int fallback) => + args.TryGetValue(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : fallback; +} + +/// set_stick:摇杆偏转(持续后自动回中)。 +public class SetStickTool : IAiTool +{ + private readonly Func _padProvider; + + public SetStickTool(Func padProvider) => _padProvider = padProvider; + + public string Name => "set_stick"; + + public string Description => + "偏转 Switch 的摇杆(LS=左摇杆 / RS=右摇杆)。坐标 0-255,128 为中心。" + + "默认持续 duration_ms 后自动回中;duration_ms=0 表示保持不复位。会真实操作设备,执行前需用户确认。"; + + public bool RequiresConfirmation => true; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["key"] = new JsonSchemaProperty { Type = "string", Description = "摇杆名(LS 或 RS)" }, + ["x"] = new JsonSchemaProperty { Type = "integer", Description = "X 偏转(0-255,128 为中心,0=最左,255=最右)" }, + ["y"] = new JsonSchemaProperty { Type = "integer", Description = "Y 偏转(0-255,128 为中心,0=最上,255=最下)" }, + ["duration_ms"] = new JsonSchemaProperty { Type = "integer", Description = "持续时间(毫秒),0 表示保持不复位,默认 300" } + }, + Required = ["key", "x", "y"] + }; + + public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var key = GetString(args, "key"); + if (string.IsNullOrWhiteSpace(key)) + return ToolResult.Error("[错误] 缺少必填参数 key"); + if (!PressButtonTool.TryParseKey(key, out var gamePadKey, out var keyError)) + return ToolResult.Error($"[错误] {keyError}"); + + var pad = _padProvider(); + if (pad is null) + return ToolResult.Error("[错误] 单片机未连接,无法设置摇杆"); + + var x = GetInt(args, "x", 128); + var y = GetInt(args, "y", 128); + if (x is < 0 or > 255 || y is < 0 or > 255) + return ToolResult.Error($"[错误] 摇杆坐标超出范围 (0-255): ({x},{y})"); + + var durationMs = GetInt(args, "duration_ms", 300); + + try + { + pad.SetStick(gamePadKey, (byte)x, (byte)y); + if (durationMs > 0) + { + await Task.Delay(Math.Clamp(durationMs, 1, 30_000), ct); + pad.SetStick(gamePadKey, 128, 128); // 回中 + } + return ToolResult.Ok(durationMs > 0 + ? $"[成功] 摇杆 {key} 已偏转 ({x},{y}) 持续 {durationMs}ms 后回中" + : $"[成功] 摇杆 {key} 已偏转 ({x},{y})(保持,不复位)"); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return ToolResult.Error($"[错误] 摇杆执行失败: {ex.Message}"); + } + } + + private static string? GetString(Dictionary args, string name) => PressButtonTool.GetString(args, name); + + private static int GetInt(Dictionary args, string name, int fallback) => PressButtonTool.GetInt(args, name, fallback); +} + +/// capture_frame:抓取当前画面(半分辨率 JPEG,多模态消息附加给模型)。 +public class CaptureFrameTool : IAiTool +{ + private readonly Func _captureProvider; + + public CaptureFrameTool(Func captureProvider) => _captureProvider = captureProvider; + + public string Name => "capture_frame"; + + public string Description => + "抓取 Switch 当前画面并作为图片返回给模型(需要多模态模型)。" + + "适合在动作之后确认画面状态。同轮请勿与其它工具混调。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Exclusive; + + public bool RequiresVision => true; + + public bool ReturnsImage => true; + + public JsonSchema Parameters => new() { Type = "object", Properties = new() }; + + public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var capture = _captureProvider(); + if (capture is null) + return ToolResult.Error("[错误] 视频源未连接或无画面"); + + // 取帧 + 首帧等待 + 模型侧压缩统一走 ModelFrame 收口 + var base64 = await ModelFrame.CaptureForModelAsync(capture, ct); + if (base64 is null) + return ToolResult.Error("[错误] 视频源未连接或无画面"); + + var image = ChatMessage.User( + [ + ContentPart.FromText("当前视频画面:"), + ContentPart.FromImageBase64("image/jpeg", base64) + ]); + return ToolResult.Ok("[成功] 已获取当前画面(见附加图片)", image); + } +} + +/// ocr_frame:对当前画面指定区域做 OCR(无需多模态模型)。ROI 裁剪由采集能力端口完成。 +public class OcrFrameTool : IAiTool +{ + private readonly Func _ocrProvider; + private readonly Func _captureProvider; + + public OcrFrameTool(Func ocrProvider, Func captureProvider) + { + _ocrProvider = ocrProvider; + _captureProvider = captureProvider; + } + + public string Name => "ocr_frame"; + + public string Description => + "识别 Switch 当前画面上的文字(OCR)。可指定区域(相对 1920x1080 画面)," + + "不指定则识别全图。返回文字与置信度,不需要多模态模型。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["x"] = new JsonSchemaProperty { Type = "integer", Description = "区域左上角 X(0-1920),默认 0(全图)" }, + ["y"] = new JsonSchemaProperty { Type = "integer", Description = "区域左上角 Y(0-1080),默认 0" }, + ["width"] = new JsonSchemaProperty { Type = "integer", Description = "区域宽(>0 时启用 ROI),默认 0" }, + ["height"] = new JsonSchemaProperty { Type = "integer", Description = "区域高(>0 时启用 ROI),默认 0" }, + ["language"] = new JsonSchemaProperty { Type = "string", Description = "识别语言(如 chi_sim/eng),缺省用引擎默认" } + } + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var capture = _captureProvider(); + if (capture is null) + return Task.FromResult(ToolResult.Error("[错误] 视频源未连接或 OCR 服务不可用")); + + // 工具语义:width/height 任一缺省 0 = 不裁 ROI(全图识别);显式 ROI 原样透传。 + // 全图走语义化 CaptureFullFrame(不用 ModelFrame——压缩伤识别率) + var width = GetInt(args, "width", 0); + var height = GetInt(args, "height", 0); + var png = width > 0 && height > 0 + ? capture.CaptureFrame(GetInt(args, "x", 0), GetInt(args, "y", 0), width, height) + : capture.CaptureFullFrame(); + if (string.IsNullOrEmpty(png)) + return Task.FromResult(ToolResult.Error("[错误] 视频源未连接或无画面")); + + var ocr = _ocrProvider(); + if (ocr is null) + return Task.FromResult(ToolResult.Error("[错误] 视频源未连接或 OCR 服务不可用")); + + var language = GetString(args, "language"); + var text = ocr.Recognize(ImageRef.FromBase64(png), new OcrQuery + { + Language = string.IsNullOrWhiteSpace(language) ? null : language, + X = 0, + Y = 0, + Width = 0, + Height = 0 // ROI 已在采集端口内裁剪 + }); + return Task.FromResult(ToolResult.Ok( + $"[OCR {ocr.Backend} 置信度 {ocr.LastConfidence}%]\n{(string.IsNullOrWhiteSpace(text) ? "(未识别到文字)" : text)}")); + } + + private static string? GetString(Dictionary args, string name) => PressButtonTool.GetString(args, name); + + private static int GetInt(Dictionary args, string name, int fallback) => PressButtonTool.GetInt(args, name, fallback); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/CompileScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/CompileScriptTool.cs similarity index 82% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/CompileScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/CompileScriptTool.cs index 153378bc..031352b5 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/CompileScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/CompileScriptTool.cs @@ -1,8 +1,7 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// compile_script 工具:编译当前编辑区脚本,返回成功与否。 @@ -10,9 +9,9 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class CompileScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptEditorPort _service; - public CompileScriptTool(IToolCallService service) => _service = service; + public CompileScriptTool(IScriptEditorPort editor) => _service = editor; public string Name => "compile_script"; diff --git a/src/EasyCon.Core/LLM/Agent/Tools/DefaultTools.cs b/src/EasyCon.Core/LLM/Agent/Tools/DefaultTools.cs new file mode 100644 index 00000000..86f047cc --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/DefaultTools.cs @@ -0,0 +1,47 @@ +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// 注册默认 AI 工具集。 +/// 新增工具只需在此方法中添加一行 Register 调用。 +/// 装配约定:设备/取帧类工具经能力供给(GetPadInput/GetCaptureSource/GetOcrService) +/// 接 Core 端口实现;编辑器/运行/观测类工具接宿主窄端口(由调用方按端口分别供给, +/// 本方法不感知 GUI 的 IToolCallService 组合视图)。 +/// +public static class DefaultTools +{ + /// 按窄端口逐一装配默认工具集;宿主的组合视图(IToolCallService)在调用侧拆开传入。 + public static void RegisterAll( + ToolRegistry registry, + IScriptEditorPort editor, + IScriptRunPort run, + IWorkspaceInfoPort workspace, + IObservabilityPort observability, + IDeviceRuntimePort device) + { + // 编辑器缓冲区(宿主集成词汇,工具只依赖窄端口) + registry.Register(new ReadScriptTool(editor)); + registry.Register(new WriteScriptTool(editor)); + registry.Register(new EditScriptTool(editor)); + registry.Register(new GrepScriptTool(editor)); + registry.Register(new CompileScriptTool(editor)); + registry.Register(new FormatScriptTool(editor)); + + // 运行控制与可观测 + registry.Register(new RunScriptTool(run)); + registry.Register(new StopScriptTool(run)); + registry.Register(new GetDeviceStatusTool(observability)); + registry.Register(new GetLogsTool(observability)); + registry.Register(new GetProjectTreeTool(workspace)); + + // 原子运行时与取帧(Core 能力端口实现,宿主只供给能力实例) + registry.Register(new GetFrameTool(device.GetCaptureSource)); + AtomicRuntimeTools.RegisterAll( + registry, + padProvider: device.GetPadInput, + captureProvider: device.GetCaptureSource, + ocrProvider: device.GetOcrService); + registry.Register(new EvalEcsTool( + captureProvider: () => device.GetCaptureSource(), + ocrProvider: () => device.GetOcrService())); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/EditScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/EditScriptTool.cs similarity index 93% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/EditScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/EditScriptTool.cs index c1a56be7..512fafd3 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/EditScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/EditScriptTool.cs @@ -1,8 +1,7 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// edit_script 工具:通过精确查找替换修改编辑区脚本内容。 @@ -10,9 +9,9 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class EditScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptEditorPort _service; - public EditScriptTool(IToolCallService service) => _service = service; + public EditScriptTool(IScriptEditorPort editor) => _service = editor; public string Name => "edit_script"; diff --git a/src/EasyCon.Core/LLM/Agent/Tools/EvalEcsTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/EvalEcsTool.cs new file mode 100644 index 00000000..1b8bf8c0 --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/EvalEcsTool.cs @@ -0,0 +1,147 @@ +using EasyCon.Core.Capabilities; +using EasyCon.Core.Hosting; +using EasyCon.Core.LLM.Tools; +using EasyCon.Core.Runner; +using EasyCon.Core.Script; +using EasyCon.Script; +using EasyScript; +using System.Collections.Immutable; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// eval_ecs:即时执行一段 ECS 脚本片段并返回 PRINT 输出。 +/// 标准库(NET_IMAGE / PPOCR_TENSOR / PPOCR_NORM / NET_RUNH / OCR 等)自动可用, +/// 使 Agent 能以"调用库函数"的方式对实际采集卡画面做识别与计算—— +/// 宿主不提供任何领域特例,与脚本作者走完全相同的代码路径。 +/// +/// 安全边界:能力集**不装配 Pad**——KEY/STICK 等设备指令运行即失败, +/// 本工具天然只读(感知 + 计算),因此无需人工确认门; +/// 会动设备的动作必须走写脚本 + run_script(已有 RequiresConfirmation 门)。 +/// 片段无状态:每次调用独立会话,变量不跨调用保持。 +/// +public class EvalEcsTool : IAiTool +{ + private const int TimeoutSeconds = 15; + private const int MaxLines = 200; + + private readonly Func _captureProvider; + private readonly Func _ocrProvider; + + /// 返回实时采集源(GUI 为采集卡帧委托;测试可注入静态图片)。 + /// 返回宿主 OCR 服务(脚本 OCR_INIT/OCR 通路;可为 null)。 + public EvalEcsTool(Func captureProvider, Func ocrProvider) + { + _captureProvider = captureProvider; + _ocrProvider = ocrProvider; + } + + public string Name => "eval_ecs"; + + public string Description => + "即时执行一段 ECS 脚本片段(标准库全量可用:NET_IMAGE/PPOCR_TENSOR/PPOCR_NORM/NET_RUNH/NET_ARGMAX/OCR 等)," + + "返回 PRINT 输出。画面来自当前采集卡实时帧——适合校准 ROI 坐标、验证识别结果、做数值计算。" + + "注意:仅感知与计算(无手柄指令,KEY/STICK 不可用);变量不跨调用保持,请在一段代码内完成取帧→识别→输出。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Exclusive; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["code"] = new JsonSchemaProperty + { + Type = "string", + Description = "ECS 语句序列(不含 FUNC 定义头;标准库函数可直接调用)。用 PRINT 输出需要回传的结果。" + } + }, + Required = ["code"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var code = args.TryGetValue("code", out var c) && c.ValueKind == JsonValueKind.String + ? c.GetString() ?? "" + : ""; + if (string.IsNullOrWhiteSpace(code)) + return Task.FromResult(ToolResult.Error("[错误] 缺少必填参数 code")); + + try + { + var engine = new EasyScriptEngine(); + var session = engine.FromSource(code, new ScriptHostOptions + { + Compile = ScriptCompileProfiles.Interactive([]) + }); + + if (session.Info.Image is not { } image) + { + var diags = string.Join("\n", session.Info.Diagnostics + .Where(d => d.IsError) + .Select(d => d.Message) + .Take(10)); + return Task.FromResult(ToolResult.Error($"[错误] 片段编译失败:\n{diags}")); + } + + var lines = new List(); + using CapabilityLease lease = ScriptHostAssembler.Assemble(new ScriptHostContext + { + Console = new ConsoleRecorder(lines), + CaptureSource = _captureProvider(), + Ocr = _ocrProvider(), + BorrowOcr = true, + }); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(TimeoutSeconds)); + try + { + EcxVm.Run(image, lease.Capabilities, timeoutCts.Token, null, session.Info.NativeSymbols); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return Task.FromResult(ToolResult.Error($"[错误] eval 超时({TimeoutSeconds} 秒),请简化片段或缩小识别区域。")); + } + catch (ScriptException ex) + { + return Task.FromResult(ToolResult.Error($"[错误] 运行出错: {ex.Message}")); + } + + if (lines.Count == 0) + return Task.FromResult(ToolResult.Ok("(无 PRINT 输出)")); + + var shown = lines.Take(MaxLines); + var text = string.Join("\n", shown); + if (lines.Count > MaxLines) + text += $"\n[输出过长已截断:共 {lines.Count} 行,仅保留前 {MaxLines} 行]"; + return Task.FromResult(ToolResult.Ok(text)); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Error($"[错误] eval 执行异常: {ex.Message}")); + } + } + + /// 捕获 PRINT/ALERT 输出的轻量 IO 适配。 + sealed class ConsoleRecorder(List lines) : IConsoleIo + { + public void Print(string message, bool newline = true) + { + lines.Add(message); + if (lines.Count > MaxLines + 50) + lines.RemoveAt(0); // 防失控脚本撑爆内存;正式截断在结果组装处 + } + + public void Alert(string message) => lines.Add("[ALERT] " + message); + + public string ReadLine() => ""; + + public bool TryReadLine(out string line) + { + line = ""; + return false; + } + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ExecuteSkillTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/ExecuteSkillTool.cs similarity index 79% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/ExecuteSkillTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/ExecuteSkillTool.cs index 7784980d..a42857ec 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ExecuteSkillTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/ExecuteSkillTool.cs @@ -1,7 +1,7 @@ using EasyCon.Core.LLM.Tools; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// execute_skill 工具:按名称执行已注册的 skill。 @@ -14,16 +14,18 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class ExecuteSkillTool : IAiTool { + public const string ToolName = "execute_skill"; + private readonly SkillExecutor _executor; public ExecuteSkillTool(SkillExecutor executor) => _executor = executor; - public string Name => "execute_skill"; + public string Name => ToolName; public string Description => - "执行一个已注册的 skill。skill 是一段预定义的指令,执行后返回结果。" + - "当用户请求与某个 skill 的描述匹配时,应调用此工具。" + - "可用 list_skills 查看所有已注册 skill 的名称和描述。"; + "执行一个 fork 型 skill(context: fork,独立子任务执行,只回传最终结果)。" + + "inline 技能的指令会按需自动注入上下文,也可用 read_skill 获取,无需经此工具。" + + "可用 list_skills 查看所有已注册 skill 的名称与执行模式。"; public JsonSchema Parameters => new() { @@ -57,9 +59,6 @@ public async Task ExecuteAsync(Dictionary args, if (args.TryGetValue("message", out var msgEl) && msgEl.ValueKind == JsonValueKind.String) message = msgEl.GetString() ?? ""; - var result = await _executor.ExecuteAsync(skillName, message, ct); - return result.StartsWith("[错误]") - ? ToolResult.Error(result) - : ToolResult.Ok(result); + return await _executor.ExecuteAsync(skillName, message, ct); } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/FormatScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/FormatScriptTool.cs similarity index 80% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/FormatScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/FormatScriptTool.cs index 964460ca..f4f7963a 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/FormatScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/FormatScriptTool.cs @@ -1,8 +1,7 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// format_script 工具:格式化当前编辑区脚本并写回编辑区。 @@ -10,9 +9,9 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class FormatScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptEditorPort _service; - public FormatScriptTool(IToolCallService service) => _service = service; + public FormatScriptTool(IScriptEditorPort editor) => _service = editor; public string Name => "format_script"; diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetDeviceStatusTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/GetDeviceStatusTool.cs similarity index 81% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetDeviceStatusTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/GetDeviceStatusTool.cs index c3e15878..4b4e419c 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetDeviceStatusTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/GetDeviceStatusTool.cs @@ -1,20 +1,21 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// get_device_status 工具:返回设备/视频源/手柄连接状态及脚本运行状态。 /// public class GetDeviceStatusTool : IAiTool { - private readonly IToolCallService _service; + private readonly IObservabilityPort _service; - public GetDeviceStatusTool(IToolCallService service) => _service = service; + public GetDeviceStatusTool(IObservabilityPort observability) => _service = observability; public string Name => "get_device_status"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "获取当前设备连接状态:单片机、视频源、虚拟手柄是否连接,以及脚本是否正在运行。"; public JsonSchema Parameters => new() { Type = "object" }; diff --git a/src/EasyCon.Core/LLM/Agent/Tools/GetFrameTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/GetFrameTool.cs new file mode 100644 index 00000000..37037fe7 --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/GetFrameTool.cs @@ -0,0 +1,55 @@ +using EasyCon.Core.Capabilities; +using EasyCon.Core.LLM.Messages; +using EasyCon.Core.LLM.Tools; +using System.Globalization; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// get_frame 工具:获取当前视频帧并以图片形式注入对话上下文,让模型理解画面。 +/// 图片数据通过 ToolResult.AttachedMessage 返回,由编排层注入历史。 +/// 依赖只有采集能力端口(编排器按模型视觉能力做 RequiresVision 门控)。 +/// +public class GetFrameTool : IAiTool +{ + private readonly Func _captureProvider; + + public GetFrameTool(Func captureProvider) => _captureProvider = captureProvider; + + public string Name => "get_frame"; + + public string Description => "截取当前视频画面并让 AI 理解图像内容。调用后 AI 会自动看到当前画面并进行分析。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Exclusive; + + public bool RequiresVision => true; + + public bool ReturnsImage => true; + + public JsonSchema Parameters => new() { Type = "object" }; + + public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var capture = _captureProvider(); + if (capture is null) + return ToolResult.Error("视频源未连接,请先连接视频源后再试。"); + + // 取帧 + 首帧等待 + 模型侧压缩统一走 ModelFrame 收口 + var base64 = await ModelFrame.CaptureForModelAsync(capture, ct); + if (base64 is null) + return ToolResult.Error("帧获取失败,请检查视频源连接状态。"); + + // 捕获时刻随结果文本回传,供模型判断帧的新旧(配合"行动后需重新取帧"的编排约定) + var capturedAt = DateTime.Now.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + + // 图片消息通过 ToolResult.AttachedMessage 返回,由编排层注入历史 + var imageMessage = ChatMessage.User( + [ + ContentPart.FromText("当前视频画面:"), + ContentPart.FromImageBase64("image/jpeg", base64) + ]); + + return ToolResult.Ok($"已获取当前画面(捕获于 {capturedAt}),正在分析...", imageMessage); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetLogsTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/GetLogsTool.cs similarity index 81% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetLogsTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/GetLogsTool.cs index 4d5014cb..85ca6d7c 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetLogsTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/GetLogsTool.cs @@ -1,20 +1,21 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// get_logs 工具:返回最近的运行日志(按行)。 /// public class GetLogsTool : IAiTool { - private readonly IToolCallService _service; + private readonly IObservabilityPort _service; - public GetLogsTool(IToolCallService service) => _service = service; + public GetLogsTool(IObservabilityPort observability) => _service = observability; public string Name => "get_logs"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "获取最近的运行日志,用于排查编译错误或运行时问题。"; public JsonSchema Parameters => new() diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetProjectTreeTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/GetProjectTreeTool.cs similarity index 77% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetProjectTreeTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/GetProjectTreeTool.cs index f13cebf6..847df004 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetProjectTreeTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/GetProjectTreeTool.cs @@ -1,9 +1,8 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// get_project_tree 工具:返回当前项目的目录结构树(文本形式)。 @@ -11,12 +10,14 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class GetProjectTreeTool : IAiTool { - private readonly IToolCallService _service; + private readonly IWorkspaceInfoPort _service; - public GetProjectTreeTool(IToolCallService service) => _service = service; + public GetProjectTreeTool(IWorkspaceInfoPort workspace) => _service = workspace; public string Name => "get_project_tree"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "获取当前项目的目录结构树。标签文件(.IL/.ILX)标记为 [标签],库文件(lib/ 目录下的 .ecs)标记为 [库]。"; public JsonSchema Parameters => new() { Type = "object" }; diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GrepScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/GrepScriptTool.cs similarity index 95% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/GrepScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/GrepScriptTool.cs index bf93698e..81f1a0bf 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GrepScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/GrepScriptTool.cs @@ -1,10 +1,9 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// grep_script 工具:在编辑区脚本中按正则表达式搜索。 @@ -12,12 +11,14 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class GrepScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptEditorPort _service; - public GrepScriptTool(IToolCallService service) => _service = service; + public GrepScriptTool(IScriptEditorPort editor) => _service = editor; public string Name => "grep_script"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "在编辑区脚本中按正则表达式搜索,返回匹配行号(含上下文行)和总出现次数。与 read_script 的区别是支持正则和精确行号定位。"; public JsonSchema Parameters => new() diff --git a/src/EasyCon.Core/LLM/Agent/Tools/IAiTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/IAiTool.cs new file mode 100644 index 00000000..878155f2 --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/IAiTool.cs @@ -0,0 +1,68 @@ +using EasyCon.Core.LLM.Tools; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// 工具同轮并发资格。编排器据此决定多工具调用是并行成组还是独占串行。 +/// +public enum ToolConcurrency +{ + /// 有副作用:独占执行,不与任何工具同轮并发。 + Exclusive, + + /// 只读:可与其它只读工具并行。 + Parallel +} + +/// +/// 通用 AI 工具接口。每个工具负责声明自身 schema 并执行调用。 +/// +public interface IAiTool +{ + /// + /// 工具唯一名称,模型通过此名称调用。 + /// + string Name { get; } + + /// + /// 工具描述,供模型理解何时使用。 + /// + string Description { get; } + + /// + /// 参数 JSON Schema。 + /// + JsonSchema Parameters { get; } + + /// + /// 同轮并发资格。默认独占:语义未知的工具(含 MCP 适配)不参与并行。 + /// + ToolConcurrency Concurrency => ToolConcurrency.Exclusive; + + /// + /// 是否需要人工确认后才能执行(设备动作等有副作用的危险操作)。 + /// 编排器在执行前向宿主请求确认;宿主未提供确认回调时 fail-closed 拒绝执行。 + /// + bool RequiresConfirmation => false; + + /// + /// 是否依赖多模态视觉(结果里有模型需要"看"的图像)。 + /// 编排器按当前模型能力过滤:模型不支持视觉时 fail-closed 拒绝调用, + /// 不再由宿主传入布尔开关。能力需求属于工具契约,不属于集成方。 + /// + bool RequiresVision => false; + + /// + /// 结果会向模型附加图像消息。此类调用必须独占一轮(混轮并行会让画面 + /// 早于本轮动作而被模型误当行动结果),编排器据此拒绝混轮调用。 + /// + bool ReturnsImage => false; + + /// + /// 执行工具调用,返回结果(含状态信息,回传给模型)。 + /// + /// 已解析的参数字典,可能为空。 + /// 取消令牌。 + Task ExecuteAsync(Dictionary args, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ListSkillsTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/ListSkillsTool.cs similarity index 95% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/ListSkillsTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/ListSkillsTool.cs index 41ad39d0..08517b0f 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ListSkillsTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/ListSkillsTool.cs @@ -3,7 +3,7 @@ using System.Text; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// list_skills 元工具:列出所有已注册技能的名称、描述与可用参考文档。 @@ -17,6 +17,8 @@ public class ListSkillsTool : IAiTool public string Name => "list_skills"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "列出当前可用的所有技能(name + 描述 + 关联工具 + 参考文档列表)。" + "不确定某任务需要哪个技能时先调用此工具查看索引。"; diff --git a/src/EasyCon.Core/LLM/Agent/Tools/ModelFrame.cs b/src/EasyCon.Core/LLM/Agent/Tools/ModelFrame.cs new file mode 100644 index 00000000..792138d1 --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/ModelFrame.cs @@ -0,0 +1,56 @@ +using EasyCon.Core.Capabilities; +using OpenCvSharp; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// 模型侧帧编码:把能力端口产出的 PNG 帧转成对模型省 token 的半分辨率 JPEG。 +/// 端口契约(D4)固定 Base64 PNG 全帧;面向模型的压缩是工具层表现策略, +/// 不反向写进能力接口。解码失败时原样返回 PNG(上层按"有帧"继续,宁可多花 token 不可瞎)。 +/// +internal static class ModelFrame +{ + /// + /// 取帧类工具唯一入口:整帧捕获(语义化 )→ + /// 首帧等待(连接后立即取帧的时序竞争,共约 2 秒有界重试)→ 模型侧压缩。 + /// 无帧返回 null,由调用方决定错误文案;OCR 类工具直接走 CaptureFrame/CaptureFullFrame, + /// 不要用本方法(压缩伤识别率)。 + /// + public static async Task CaptureForModelAsync(ICaptureSource source, CancellationToken ct = default) + { + var png = source.CaptureFullFrame(); + for (var attempt = 0; string.IsNullOrEmpty(png) && attempt < 9; attempt++) + { + try + { + await Task.Delay(200, ct); + } + catch (OperationCanceledException) + { + break; + } + png = source.CaptureFullFrame(); + } + return string.IsNullOrEmpty(png) ? null : EncodeForModel(png); + } + + public static string EncodeForModel(string pngBase64) + { + try + { + using var mat = Mat.FromImageData(Convert.FromBase64String(pngBase64)); + if (mat.Empty()) + return pngBase64; + + using var resized = new Mat(); + Cv2.Resize(mat, resized, new Size(Math.Max(1, mat.Width / 2), Math.Max(1, mat.Height / 2)), + 0, 0, InterpolationFlags.Linear); + return Convert.ToBase64String( + resized.ToBytes(".jpg", [new ImageEncodingParam(ImwriteFlags.JpegQuality, 80)])); + } + catch + { + return pngBase64; + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/LLM/Agent/Tools/Ports.cs b/src/EasyCon.Core/LLM/Agent/Tools/Ports.cs new file mode 100644 index 00000000..312f7515 --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/Ports.cs @@ -0,0 +1,93 @@ +using EasyCon.Core.Capabilities; + +namespace EasyCon.Core.LLM.Agent.Tools; + +// ── 窄端口 ───────────────────────────────── +// 设计约定(runtime knows capabilities, not integrations): +// 每个工具只依赖它需要的那一个窄端口;这些端口属于 Core 词汇(工具的构造函数 +// 只允许依赖 Core 词汇表,见 ToolPortBoundaryTests),宿主集成细节由实现方承载。 +// GUI 侧的 IToolCallService 只是把这些端口聚合为单一注入点的组合视图,留在 GUI。 + +/// 脚本编辑区端口:读/写/精确替换编辑器缓冲区,编译与格式化。 +public interface IScriptEditorPort +{ + /// 获取当前编辑区的完整脚本文本。 + string GetScriptContent(); + + /// 写入脚本内容到编辑区。append=true 追加到末尾,false 替换全部。 + void WriteScriptContent(string content, bool append); + + /// 查找并替换编辑区中的文本。返回实际替换次数,-1 表示未找到匹配。 + int EditScriptContent(string oldString, string newString, int count); + + /// 获取当前脚本文件路径(未保存时为 null)。 + string? GetScriptPath(); + + /// 编译当前脚本,返回是否成功。 + Task CompileScriptAsync(); + + /// 格式化当前脚本并写入编辑区,返回格式化后的文本。 + Task FormatScriptAsync(); +} + +/// +/// 脚本运行端口:编译并运行当前编辑区脚本、停止、运行状态。 +/// 今天经由编辑区内容启动(GUI 会话语义);待 Core 暴露可装配的脚本运行控制端口 +/// 后再迁移。 +/// +public interface IScriptRunPort +{ + /// 编译并运行当前编辑区脚本。返回是否成功启动。 + Task RunScriptAsync(); + + /// 停止正在运行的脚本。 + void StopScript(); + + /// 脚本是否正在运行。 + bool IsScriptRunning { get; } +} + +/// 工作区信息端口:项目目录与目录树。 +public interface IWorkspaceInfoPort +{ + /// 获取当前项目的目录结构树(文本形式)。返回 null 表示未打开项目。 + string? GetProjectTree(); + + /// 获取当前项目根目录的绝对路径。未打开项目时返回 null。 + string? GetProjectDirectory(); +} + +/// 可观测端口:设备连接状态与最近日志。 +public interface IObservabilityPort +{ + /// 获取设备/视频源/手柄连接状态及脚本运行状态。 + DeviceStatusInfo GetDeviceStatus(); + + /// 获取最近 N 条日志(按行)。 + string GetRecentLogs(int maxLines); +} + +/// +/// 设备运行时端口:惰性提供 Core 能力实例(未连接返回 null)。 +/// 能力实例本身按 Core 端口约束(IPadInput/ICaptureSource/IOcrService)—— +/// 工具直接消费能力端口,本接口只负责"按连接状态供给实例",不再承载动作方法。 +/// +public interface IDeviceRuntimePort +{ + /// 手柄输入能力(IPadInput)。设备未连接时返回 null。 + IPadInput? GetPadInput(); + + /// 实时采集源(帧委托)。视频源未就绪时返回 null。 + ICaptureSource? GetCaptureSource(); + + /// 宿主 OCR 服务(OCR_INIT/OCR 通路)。未装配返回 null。 + IOcrService? GetOcrService(); +} + +/// 设备连接与运行状态快照。 +public record DeviceStatusInfo( + bool IsDeviceConnected, + bool IsCaptureConnected, + bool IsControllerConnected, + bool IsScriptRunning +); \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ReadScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/ReadScriptTool.cs similarity index 77% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/ReadScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/ReadScriptTool.cs index 643b6520..177d7c7a 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ReadScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/ReadScriptTool.cs @@ -1,9 +1,8 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// read_script 工具:返回编辑区当前脚本文本。 @@ -11,15 +10,17 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class ReadScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptEditorPort _service; - public ReadScriptTool(IToolCallService service) + public ReadScriptTool(IScriptEditorPort editor) { - _service = service; + _service = editor; } public string Name => "read_script"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "读取当前编辑区的脚本内容。可指定 start_line 和 end_line(1-based 行号)按行范围部分返回。"; public JsonSchema Parameters => new() @@ -57,13 +58,23 @@ public Task ExecuteAsync(Dictionary args, Cance return Task.FromResult(ToolResult.Ok("(编辑区无脚本内容)")); } - // 带行号输出,便于模型定位 + // 带行号输出,便于模型定位;超预算即截断并注明, + // 提示模型用 start_line/end_line 分段读取剩余部分 + const int maxOutputChars = 16_000; var sb = new StringBuilder(); + var truncated = false; for (var i = startLine; i <= endLine; i++) { + if (sb.Length > maxOutputChars) + { + truncated = true; + break; + } sb.Append(i).Append(": ").Append(lines[i - 1]); if (i < endLine) sb.Append('\n'); } + if (truncated) + sb.Append("\n...[脚本过长已截断:请用 start_line/end_line 参数分段读取剩余部分]"); var header = (startLine == 1 && endLine == lines.Length) ? $"共 {lines.Length} 行:\n" diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ReadSkillTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/ReadSkillTool.cs similarity index 96% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/ReadSkillTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/ReadSkillTool.cs index cf3a1fb1..00340501 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ReadSkillTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/ReadSkillTool.cs @@ -2,7 +2,7 @@ using EasyCon.Core.LLM.Tools; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// read_skill 元工具:读取技能的完整指令(Level 2)或参考文档(Level 3)。 @@ -17,6 +17,8 @@ public class ReadSkillTool : IAiTool public string Name => "read_skill"; + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + public string Description => "读取指定技能的完整指令或参考文档。技能名可用 list_skills 查询。" + "省略 reference 返回技能完整指令(SKILL.md 正文);" + diff --git a/src/EasyCon.Core/LLM/Agent/Tools/RunScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/RunScriptTool.cs new file mode 100644 index 00000000..428eb62d --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/RunScriptTool.cs @@ -0,0 +1,43 @@ +using EasyCon.Core.LLM.Tools; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// run_script 工具:编译并运行当前编辑区的脚本。 +/// +public class RunScriptTool : IAiTool +{ + /// 脚本会驱动真实硬件,需要人工确认。 + public bool RequiresConfirmation => true; + + private readonly IScriptRunPort _run; + + public RunScriptTool(IScriptRunPort run) + { + _run = run; + } + + public string Name => "run_script"; + + public string Description => + "编译并运行当前编辑区的脚本。脚本在后台运行,不会阻塞对话。" + + "是否需要单片机由脚本内容判定:含按键/摇杆操作时运行服务会自动尝试连接单片机," + + "纯识别/计算脚本无需连接即可运行。启动结果与连接/编译信息请用 get_logs 跟进。"; + + public JsonSchema Parameters => new() { Type = "object" }; + + public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + // 不在工具层做连接前置拦截:是否需要单片机由编译产物判定(ScriptService 按 + // KeyAction 决定自动连接/装配 Pad),纯感知脚本无单片机也应可运行; + // 自动连接的成败是异步写日志的,模型经 get_logs 获知。 + if (_run.IsScriptRunning) + return ToolResult.Retryable("脚本已在运行中。", "请先调用 stop_script 停止当前脚本,再重新运行。"); + + var ok = await _run.RunScriptAsync(); + return ok + ? ToolResult.Ok("脚本已启动运行。连接/编译等运行期信息以 get_logs 为准。") + : ToolResult.Retryable("编译失败,无法运行。", "请调用 get_logs 查看错误信息,修复后重新编译运行。"); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/StopScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/StopScriptTool.cs similarity index 69% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/StopScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/StopScriptTool.cs index 9950e39e..481bd0f6 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/StopScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/StopScriptTool.cs @@ -1,17 +1,16 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// stop_script 工具:停止正在运行的脚本。 /// public class StopScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptRunPort _run; - public StopScriptTool(IToolCallService service) => _service = service; + public StopScriptTool(IScriptRunPort run) => _run = run; public string Name => "stop_script"; @@ -21,10 +20,10 @@ public class StopScriptTool : IAiTool public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) { - if (!_service.IsScriptRunning) + if (!_run.IsScriptRunning) return Task.FromResult(ToolResult.Ok("当前没有运行中的脚本。")); - _service.StopScript(); + _run.StopScript(); return Task.FromResult(ToolResult.Ok("脚本已停止。")); } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ToolRegistry.cs b/src/EasyCon.Core/LLM/Agent/Tools/ToolRegistry.cs similarity index 61% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/ToolRegistry.cs rename to src/EasyCon.Core/LLM/Agent/Tools/ToolRegistry.cs index 4ba063f0..af5ece61 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ToolRegistry.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/ToolRegistry.cs @@ -1,16 +1,19 @@ using EasyCon.Core.LLM.Tools; +using System.Collections.Concurrent; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// 工具注册中心,管理可用工具并提供按名称分发和 schema 导出。 +/// 并发模型:注册表会被编排器后台线程(ReAct 循环 Get/ToToolDefinitions)与 +/// UI 线程(MCP 工具集变更 Register/Unregister)并发访问,必须线程安全。 /// public class ToolRegistry { - private readonly Dictionary _tools = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _tools = new(StringComparer.Ordinal); /// - /// 注册一个工具。 + /// 注册一个工具。重名覆盖(后者胜)。 /// public ToolRegistry Register(IAiTool tool) { @@ -21,16 +24,18 @@ public ToolRegistry Register(IAiTool tool) /// /// 按名称注销一个工具。不存在时静默忽略。 /// - public void Unregister(string name) => _tools.Remove(name); + public void Unregister(string name) => _tools.TryRemove(name, out _); /// /// 按条件批量注销工具。用于 MCP 工具集变更时移除所有旧适配器。 /// public void Unregister(Predicate match) { - var toRemove = _tools.Where(kv => match(kv.Value)).Select(kv => kv.Key).ToList(); - foreach (var name in toRemove) - _tools.Remove(name); + foreach (var kv in _tools) + { + if (match(kv.Value)) + _tools.TryRemove(kv.Key, out _); + } } /// @@ -43,6 +48,11 @@ public void Unregister(Predicate match) /// public IAiTool? Get(string name) => _tools.TryGetValue(name, out var tool) ? tool : null; + /// + /// 快照当前全部工具(名称 → 工具),供 MCP 导出等遍历场景使用。 + /// + public IEnumerable> Snapshot() => _tools.ToArray(); + /// /// 导出 OpenAI tools 数组格式的工具定义列表。 /// diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ToolResult.cs b/src/EasyCon.Core/LLM/Agent/Tools/ToolResult.cs similarity index 96% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/ToolResult.cs rename to src/EasyCon.Core/LLM/Agent/Tools/ToolResult.cs index fa64618b..43ab11af 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/ToolResult.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/ToolResult.cs @@ -1,6 +1,6 @@ using EasyCon.Core.LLM.Messages; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// 工具执行结果,携带状态信息帮助模型决策。 diff --git a/src/EasyCon.Core/LLM/Agent/Tools/WorkspaceFileTools.cs b/src/EasyCon.Core/LLM/Agent/Tools/WorkspaceFileTools.cs new file mode 100644 index 00000000..6e54e9cc --- /dev/null +++ b/src/EasyCon.Core/LLM/Agent/Tools/WorkspaceFileTools.cs @@ -0,0 +1,351 @@ +using EasyCon.Core.LLM.Tools; +using System.Text.Json; + +namespace EasyCon.Core.LLM.Agent.Tools; + +/// +/// 工作区文件工具集:以脚本项目目录为根的通用文件读写能力。 +/// 所有路径都限制在根目录内(拒绝 .. 逃逸);写类操作先把原文件 +/// 快照到 .easycon/history/(带时间戳),误改可手工回滚。 +/// 与宿主的编辑区无关,GUI/CLI/无头环境行为一致。 +/// +public static class WorkspaceFileTools +{ + /// 单次读取的字符上限,超出截断并附尾注。 + public const int MaxReadChars = 40_000; + + /// 单次写入的字符上限。 + public const int MaxWriteChars = 200_000; + + /// 把工作区文件工具注册到工具注册中心。 + /// 工具注册中心。 + /// 工作区根端口(未打开脚本项目时 RootPath 为 null)。 + public static void RegisterAll(ToolRegistry registry, IWorkspaceRoot workspace) + { + registry.Register(new GlobFilesTool(workspace)); + registry.Register(new ReadFileTool(workspace)); + registry.Register(new WriteFileTool(workspace)); + registry.Register(new EditFileTool(workspace)); + } + + /// 把相对路径安全解析到根目录内;逃逸或非法路径返回 null。 + public static string? ResolveSafe(string root, string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + return null; + var candidate = relativePath.Replace('\\', '/'); + if (Path.IsPathRooted(candidate)) + return null; + var combined = Path.GetFullPath(Path.Combine(root, candidate)); + var normalizedRoot = Path.GetFullPath(root); + if (!combined.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && !string.Equals(combined, normalizedRoot, StringComparison.Ordinal)) + return null; + return combined; + } + + /// 写入/覆盖前把原文件快照到 .easycon/history/。 + public static void SnapshotOriginal(string root, string absolutePath) + { + if (!File.Exists(absolutePath)) + return; + var historyDir = Path.Combine(root, ".easycon", "history"); + Directory.CreateDirectory(historyDir); + var name = Path.GetFileName(absolutePath); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss-fff"); + File.Copy(absolutePath, Path.Combine(historyDir, $"{stamp}_{name}"), overwrite: true); + } + + /// 工作区未就绪(未打开脚本项目)的标准错误结果。 + public static ToolResult NoWorkspace() => + ToolResult.Error("[错误] 未打开脚本项目,文件工具不可用。请先打开一个脚本文件。"); +} + +/// +/// 工作区根端口:文件工具经它拿到当前工作区根目录。 +/// 命名端口而非 Func<string?>——依赖可命名、可多态、可用测试替身。 +/// +public interface IWorkspaceRoot +{ + /// 工作区根目录绝对路径;未打开脚本项目时为 null。 + string? RootPath { get; } +} + +/// IWorkspaceRoot 的默认实现:包装一个(可动态解析的)根路径供给。 +public sealed class WorkspaceRoot(Func pathProvider) : IWorkspaceRoot +{ + public string? RootPath => pathProvider(); +} + +/// glob_files:按通配符模式列出工作区内的文件。 +public class GlobFilesTool : IAiTool +{ + private readonly IWorkspaceRoot _workspace; + + public GlobFilesTool(IWorkspaceRoot workspace) => _workspace = workspace; + + public string Name => "glob_files"; + + public string Description => + "在工作区内按通配符模式查找文件(如 \"**/*.ecs\"、\"recipes/*.json\")。返回相对路径列表。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["pattern"] = new JsonSchemaProperty + { + Type = "string", + Description = "glob 模式,相对工作区根目录。支持 ** 递归。" + } + }, + Required = ["pattern"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var root = _workspace.RootPath; + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + return Task.FromResult(WorkspaceFileTools.NoWorkspace()); + + var pattern = args.TryGetValue("pattern", out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString()! + : ""; + if (string.IsNullOrWhiteSpace(pattern)) + return Task.FromResult(ToolResult.Error("[错误] 缺少必填参数 pattern")); + + try + { + var options = new EnumerationOptions + { + RecurseSubdirectories = pattern.Contains("**", StringComparison.Ordinal), + IgnoreInaccessible = true, + MatchCasing = MatchCasing.CaseInsensitive + }; + var searchPattern = pattern.Replace("**/", ""); + var rootName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar)); + var matches = Directory.EnumerateFiles(root, searchPattern, options) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}.easycon{Path.DirectorySeparatorChar}")) + .Select(f => Path.GetRelativePath(root, f).Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(f => f, StringComparer.Ordinal) + .Take(200) + .ToList(); + var lines = new List { $"共 {matches.Count} 个匹配文件:" }; + lines.AddRange(matches); + return Task.FromResult(ToolResult.Ok(string.Join("\n", lines))); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Error($"[错误] glob 执行失败: {ex.Message}")); + } + } +} + +/// read_file:读取工作区内一个 UTF-8 文本文件。 +public class ReadFileTool : IAiTool +{ + private readonly IWorkspaceRoot _workspace; + + public ReadFileTool(IWorkspaceRoot workspace) => _workspace = workspace; + + public string Name => "read_file"; + + public string Description => + "读取工作区内一个文本文件的内容(UTF-8)。超过 40000 字符会被截断。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["path"] = new JsonSchemaProperty { Type = "string", Description = "相对工作区根目录的文件路径。" } + }, + Required = ["path"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var root = _workspace.RootPath; + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + return Task.FromResult(WorkspaceFileTools.NoWorkspace()); + + var path = args.TryGetValue("path", out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString()! + : ""; + var absolute = WorkspaceFileTools.ResolveSafe(root, path); + if (absolute is null) + return Task.FromResult(ToolResult.Error($"[错误] 非法路径: {path}(必须是工作区内的相对路径)")); + if (!File.Exists(absolute)) + return Task.FromResult(ToolResult.Error($"[错误] 文件不存在: {path}")); + + try + { + var content = File.ReadAllText(absolute); + if (content.Length > WorkspaceFileTools.MaxReadChars) + content = string.Concat(content.AsSpan(0, WorkspaceFileTools.MaxReadChars), + $"\n\n[文件过长已截断:原始 {content.Length} 字符,仅保留前 {WorkspaceFileTools.MaxReadChars} 字符。]"); + return Task.FromResult(ToolResult.Ok(content)); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Error($"[错误] 读取失败: {ex.Message}")); + } + } +} + +/// write_file:写入或覆盖工作区内的文本文件(写前自动快照)。 +public class WriteFileTool : IAiTool +{ + private readonly IWorkspaceRoot _workspace; + + public WriteFileTool(IWorkspaceRoot workspace) => _workspace = workspace; + + public string Name => "write_file"; + + public string Description => + "把完整内容写入工作区内的文本文件(覆盖)。原文件会自动快照到 .easycon/history/ 以便回滚。"; + + public ToolConcurrency Concurrency => ToolConcurrency.Parallel; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["path"] = new JsonSchemaProperty { Type = "string", Description = "相对工作区根目录的文件路径。" }, + ["content"] = new JsonSchemaProperty { Type = "string", Description = "要写入的完整内容。" } + }, + Required = ["path", "content"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var root = _workspace.RootPath; + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + return Task.FromResult(WorkspaceFileTools.NoWorkspace()); + + var path = args.TryGetValue("path", out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString()! + : ""; + var absolute = WorkspaceFileTools.ResolveSafe(root, path); + if (absolute is null) + return Task.FromResult(ToolResult.Error($"[错误] 非法路径: {path}(必须是工作区内的相对路径)")); + + var content = args.TryGetValue("content", out var c) && c.ValueKind == JsonValueKind.String + ? c.GetString() ?? "" + : ""; + if (content.Length > WorkspaceFileTools.MaxWriteChars) + return Task.FromResult(ToolResult.Error( + $"[错误] 内容过长({content.Length} 字符,上限 {WorkspaceFileTools.MaxWriteChars})。请分次写入或缩减内容。")); + + try + { + WorkspaceFileTools.SnapshotOriginal(root, absolute); + var dir = Path.GetDirectoryName(absolute); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(absolute, content); + var action = File.Exists(absolute) ? "已覆盖(原文件已快照)" : "已创建"; + return Task.FromResult(ToolResult.Ok($"[成功] {path} {action},共 {content.Length} 字符。")); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Error($"[错误] 写入失败: {ex.Message}")); + } + } +} + +/// edit_file:在工作区文件内做精确文本替换(写前自动快照)。 +public class EditFileTool : IAiTool +{ + private readonly IWorkspaceRoot _workspace; + + public EditFileTool(IWorkspaceRoot workspace) => _workspace = workspace; + + public string Name => "edit_file"; + + public string Description => + "在工作区文件内查找一段精确文本并替换(old_string 必须唯一,除非指定 replace_all)。原文件会自动快照。"; + + public JsonSchema Parameters => new() + { + Type = "object", + Properties = new() + { + ["path"] = new JsonSchemaProperty { Type = "string", Description = "相对工作区根目录的文件路径。" }, + ["old_string"] = new JsonSchemaProperty { Type = "string", Description = "要查找的精确文本。" }, + ["new_string"] = new JsonSchemaProperty { Type = "string", Description = "替换为的文本。" }, + ["replace_all"] = new JsonSchemaProperty { Type = "boolean", Description = "替换所有匹配(默认只允许唯一匹配)。" } + }, + Required = ["path", "old_string", "new_string"] + }; + + public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) + { + var root = _workspace.RootPath; + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + return Task.FromResult(WorkspaceFileTools.NoWorkspace()); + + var path = args.TryGetValue("path", out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString()! + : ""; + var absolute = WorkspaceFileTools.ResolveSafe(root, path); + if (absolute is null) + return Task.FromResult(ToolResult.Error($"[错误] 非法路径: {path}(必须是工作区内的相对路径)")); + if (!File.Exists(absolute)) + return Task.FromResult(ToolResult.Error($"[错误] 文件不存在: {path}")); + + var oldString = args.TryGetValue("old_string", out var o) && o.ValueKind == JsonValueKind.String + ? o.GetString() ?? "" + : ""; + var newString = args.TryGetValue("new_string", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? "" + : ""; + var replaceAll = args.TryGetValue("replace_all", out var r) && r.ValueKind == JsonValueKind.True; + if (oldString.Length == 0) + return Task.FromResult(ToolResult.Error("[错误] old_string 不能为空")); + + try + { + var content = File.ReadAllText(absolute); + var count = CountOccurrences(content, oldString); + if (count == 0) + return Task.FromResult(ToolResult.Error("[错误] 未找到 old_string,文件未修改。")); + if (count > 1 && !replaceAll) + return Task.FromResult(ToolResult.Error( + $"[错误] old_string 出现 {count} 次,不唯一。请扩大上下文使其唯一,或设置 replace_all=true。")); + + WorkspaceFileTools.SnapshotOriginal(root, absolute); + var updated = replaceAll ? content.Replace(oldString, newString) : ReplaceFirst(content, oldString, newString); + File.WriteAllText(absolute, updated); + return Task.FromResult(ToolResult.Ok($"[成功] {path} 已替换 {count} 处。")); + } + catch (Exception ex) + { + return Task.FromResult(ToolResult.Error($"[错误] 编辑失败: {ex.Message}")); + } + } + + private static int CountOccurrences(string content, string needle) + { + var count = 0; + var index = 0; + while ((index = content.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + return count; + } + + private static string ReplaceFirst(string content, string needle, string replacement) + { + var index = content.IndexOf(needle, StringComparison.Ordinal); + return string.Concat(content.AsSpan(0, index), replacement, content.AsSpan(index + needle.Length)); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/WriteScriptTool.cs b/src/EasyCon.Core/LLM/Agent/Tools/WriteScriptTool.cs similarity index 89% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/WriteScriptTool.cs rename to src/EasyCon.Core/LLM/Agent/Tools/WriteScriptTool.cs index cf27380c..4e7cd2cc 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/WriteScriptTool.cs +++ b/src/EasyCon.Core/LLM/Agent/Tools/WriteScriptTool.cs @@ -1,8 +1,7 @@ using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon.Core.LLM.Agent.Tools; /// /// write_script 工具:将模型生成的脚本代码写入编辑区。 @@ -10,9 +9,9 @@ namespace EasyCon2.Avalonia.Core.AiAgent.Tools; /// public class WriteScriptTool : IAiTool { - private readonly IToolCallService _service; + private readonly IScriptEditorPort _service; - public WriteScriptTool(IToolCallService service) => _service = service; + public WriteScriptTool(IScriptEditorPort editor) => _service = editor; public string Name => "write_script"; diff --git a/src/EasyCon.Core/LLM/ChatClientFactory.cs b/src/EasyCon.Core/LLM/ChatClientFactory.cs index b4b73c1e..32e9c96a 100644 --- a/src/EasyCon.Core/LLM/ChatClientFactory.cs +++ b/src/EasyCon.Core/LLM/ChatClientFactory.cs @@ -9,7 +9,8 @@ public static class ChatClientFactory public static IChatClient Create(ProviderConfig provider) { - var key = $"{provider.Api}|{provider.BaseUrl}"; + // ApiKey 参与缓存键:同址不同凭证的供应商不得共享客户端(错用对方凭证) + var key = $"{provider.Api}|{provider.BaseUrl}|{provider.ApiKey}"; lock (_lock) { if (_cache.TryGetValue(key, out var cached)) diff --git a/src/EasyCon.Core/LLM/ChatRequest.cs b/src/EasyCon.Core/LLM/ChatRequest.cs index 19d76a24..2bfdfa6b 100644 --- a/src/EasyCon.Core/LLM/ChatRequest.cs +++ b/src/EasyCon.Core/LLM/ChatRequest.cs @@ -25,6 +25,11 @@ public class ChatRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool Stream { get; set; } + /// 流式请求选项。include_usage 要求服务端在流末尾 chunk 回传 usage 统计。 + [JsonPropertyName("stream_options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StreamOptions? StreamOptions { get; set; } + /// /// 工具定义列表,模型可据此决定是否调用工具。 /// @@ -43,6 +48,15 @@ public class ChatRequest public ToolChoice? ToolChoice { get; set; } } +/// +/// 流式请求选项。 +/// +public class StreamOptions +{ + [JsonPropertyName("include_usage")] + public bool IncludeUsage { get; set; } +} + /// /// 工具选择策略。 /// diff --git a/src/EasyCon.Core/LLM/Mcp/McpConfig.cs b/src/EasyCon.Core/LLM/Mcp/McpConfig.cs index b7c0b331..ef17a71a 100644 --- a/src/EasyCon.Core/LLM/Mcp/McpConfig.cs +++ b/src/EasyCon.Core/LLM/Mcp/McpConfig.cs @@ -1,11 +1,15 @@ +using System.Text.Json.Serialization; + namespace EasyCon.Core.LLM.Mcp; /// -/// mcp.json 的根配置类型。 +/// mcp.json 的根配置类型。JsonPropertyName 钉死小写 schema: +/// 序列化选项无命名策略,不钉的话落盘会被写成 PascalCase。 /// public class McpConfig { /// MCP 配置节点。 + [JsonPropertyName("mcp")] public McpSection Mcp { get; set; } = new(); } @@ -17,5 +21,6 @@ public class McpSection /// /// 服务器配置字典,key 为 slug(如 "filesystem"、"github"),value 为配置。 /// + [JsonPropertyName("servers")] public Dictionary Servers { get; set; } = []; } \ No newline at end of file diff --git a/src/EasyCon.Core/LLM/Mcp/McpServerConfig.cs b/src/EasyCon.Core/LLM/Mcp/McpServerConfig.cs index 9bafd1ca..fda78b3c 100644 --- a/src/EasyCon.Core/LLM/Mcp/McpServerConfig.cs +++ b/src/EasyCon.Core/LLM/Mcp/McpServerConfig.cs @@ -1,7 +1,10 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + namespace EasyCon.Core.LLM.Mcp; /// -/// MCP 服务器传输协议类型。 +/// MCP 服务器传输协议类型。落盘为小写字符串("stdio"),读取兼容任意大小写与数字。 /// public enum McpTransport { @@ -10,26 +13,54 @@ public enum McpTransport // 预留:Sse, Http — 后续扩展远程传输时使用 } +/// McpTransport 与小写字符串的互转;读取兼容任意大小写与历史文件中的数字。 +public sealed class McpTransportConverter : JsonConverter +{ + public override McpTransport Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + reader.Skip(); + return McpTransport.Stdio; + } + + var text = reader.GetString(); + return Enum.TryParse(text, ignoreCase: true, out var value) + ? value + : McpTransport.Stdio; + } + + public override void Write(Utf8JsonWriter writer, McpTransport value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString().ToLowerInvariant()); +} + /// /// 单个 MCP 服务器的连接配置,对齐 Claude Desktop 的 mcpServers 约定。 /// public class McpServerConfig { /// 显示名称(UI 友好)。 + [JsonPropertyName("name")] public string Name { get; set; } = ""; /// 传输协议。 + [JsonPropertyName("transport")] + [JsonConverter(typeof(McpTransportConverter))] public McpTransport Transport { get; set; } = McpTransport.Stdio; /// stdio: 可执行文件路径(如 npx、node、python 等)。 + [JsonPropertyName("command")] public string Command { get; set; } = ""; /// stdio: 命令行参数。 + [JsonPropertyName("args")] public List Args { get; set; } = []; /// stdio: 环境变量(追加到子进程环境)。 + [JsonPropertyName("env")] public Dictionary Env { get; set; } = []; /// 是否启用(false 时不启动子进程)。 + [JsonPropertyName("enabled")] public bool Enabled { get; set; } = true; } \ No newline at end of file diff --git a/src/EasyCon.Core/LLM/OpenAIChatClient.cs b/src/EasyCon.Core/LLM/OpenAIChatClient.cs index 4abf973b..89efa333 100644 --- a/src/EasyCon.Core/LLM/OpenAIChatClient.cs +++ b/src/EasyCon.Core/LLM/OpenAIChatClient.cs @@ -24,6 +24,12 @@ public sealed class OpenAIChatClient : IChatClient /// public static event Action? DebugLog; + /// + /// 请求阶段透明重试回调(参数:attempt、maxAttempts、原因)。 + /// 请求阶段的重试对调用方完全透明,订阅此事件才能在 UI 上提示"正在重试"。 + /// + public static event Action? RequestRetrying; + public OpenAIChatClient(string baseUrl, string apiKey) { _http = new HttpClient @@ -124,6 +130,8 @@ public async Task> ListModelsAsync(CancellationToken ct = default) public async IAsyncEnumerable SendStreamAsync(ChatRequest request, [EnumeratorCancellation] CancellationToken ct = default) { request.Stream = true; + // 不显式请求时多数 OpenAI 兼容端点流式下不回传 usage,token 统计会静默缺失 + request.StreamOptions ??= new StreamOptions { IncludeUsage = true }; // ── 请求阶段:带重试的指数退避(透明重试,未 yield 任何 delta)── // C# 不允许在 catch 块中 yield,因此用局部变量捕获状态,在 try/catch 外部处理。 @@ -220,8 +228,10 @@ public async IAsyncEnumerable SendStreamAsync(ChatRequest request, if (IsTransientStatus(resp.StatusCode) && attempt < MaxRetries) { var retryAfter = ParseRetryAfter(resp); + var status = resp.StatusCode; resp.Dispose(); resp = null; + RequestRetrying?.Invoke(attempt, MaxRetries, $"HTTP {(int)status} {status}"); await Task.Delay(ComputeBackoff(attempt, retryAfter), ct); continue; } @@ -238,6 +248,7 @@ public async IAsyncEnumerable SendStreamAsync(ChatRequest request, } catch (HttpRequestException ex) when (attempt < MaxRetries) { + RequestRetrying?.Invoke(attempt, MaxRetries, ex.Message); await Task.Delay(ComputeBackoff(attempt, null), ct); } catch (HttpRequestException ex) @@ -412,7 +423,7 @@ private static ChatResponse ParseFullResponse(string body) /// 从流式 chunk 提取增量片段列表,解析 content、reasoning_content 和 tool_calls。 /// 单个 chunk 可能同时包含多种 delta。 /// - private static List ExtractDeltas(string data) + internal static List ExtractDeltas(string data) { var deltas = new List(); @@ -421,6 +432,15 @@ private static List ExtractDeltas(string data) using var doc = JsonDocument.Parse(data); var root = doc.RootElement; + // 流内错误负载:部分供应商/网关在 HTTP 200 的流里以 + // data: {"error":{...}} 形式报错(无 choices 字段), + // 不解析会被静默丢弃,最终表现为无任何提示的空回复 + if (root.TryGetProperty("error", out var err)) + { + deltas.Add(StreamDelta.Error(ExtractErrorText(err), retryable: false)); + return deltas; + } + // Token 用量(流式最后一个 chunk 可能携带 usage 字段) if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { @@ -473,6 +493,24 @@ private static List ExtractDeltas(string data) return deltas; } + /// + /// 从流内 error 节点提取可读文本,兼容对象(取 message)与字符串两种形态, + /// 其余形态回退到原始 JSON(截断)。 + /// + private static string ExtractErrorText(JsonElement err) + { + if (err.ValueKind == JsonValueKind.String) + return err.GetString() ?? ""; + + if (err.ValueKind == JsonValueKind.Object + && err.TryGetProperty("message", out var msg) + && msg.ValueKind == JsonValueKind.String) + return msg.GetString() ?? ""; + + var raw = err.GetRawText(); + return raw.Length > 300 ? raw[..300] + "..." : raw; + } + /// /// 解析流式 delta 中的单个 tool_call 增量。 /// diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Skills/BundledSkills.cs b/src/EasyCon.Core/LLM/Skills/BundledSkills.cs similarity index 85% rename from src/EasyCon2.Avalonia.Core/AiAgent/Skills/BundledSkills.cs rename to src/EasyCon.Core/LLM/Skills/BundledSkills.cs index 2ee199a1..48aa1d67 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Skills/BundledSkills.cs +++ b/src/EasyCon.Core/LLM/Skills/BundledSkills.cs @@ -1,6 +1,6 @@ using EasyCon.Core.LLM.Skills; -namespace EasyCon2.Avalonia.Core.AiAgent.Skills; +namespace EasyCon.Core.LLM.Agent.Skills; /// /// 内置技能:直接在代码中初始化 Skill 对象,不从文件系统加载。 @@ -35,7 +35,7 @@ private static Skill CreateEcscriptAuthoring() - 关键字必须全大写(IF/FOR/FUNC/WAIT/PRINT),禁止混用大小写 - 逻辑运算符全小写(and/or/not) - - 变量以 `$` 开头 + - 变量以 `$` 开头,常量以 `_` 开头 - 脚本以行为基础,每条指令独占一行 - 流程控制块必须换行书写,IF/ENDIF、FOR/NEXT、WHILE/END 等各占一行,禁止同行 - **严格遵循语法**:只能使用语法说明中明确列出的关键字、函数、结构,禁止自创语法、臆造函数 @@ -355,59 +355,15 @@ private static Skill CreateVisionAnalysis() }; const string body = """ - # 视觉观察模式 + # 视觉观察 - 通过 `get_frame` 获取当前视频画面进行分析。 + 通过 `get_frame` 获取当前视频画面进行分析。按 ReAct 循环推进,每轮只做一件事: - ## 调用规则 + - 观察轮:调用 `get_frame`(必须单独调用),收到画面后当轮只做分析——关键元素、与用户目标的差距、下一步计划 + - 行动轮:`write_script` → `compile_script` → `run_script`(编译不过必须先修复),行动后说明结果 + - 验证轮:行动后调用 `get_frame` 验证;脚本异步执行,不要假设已生效 - 1. **每次只能调用一次 get_frame**,禁止在同一次响应中同时调用 get_frame 和其他工具 - 2. 调用 get_frame 后,本轮响应只能分析画面内容,不得调用任何其他工具 - 3. 分析完成后在回复中说明观察结果和下一步计划 - 4. 下一轮响应再根据分析结果执行操作 - - ## 分析要求 - - - 仔细描述画面中的关键元素(界面、文字、状态、图标等) - - 将画面内容与用户目标对比,判断当前进度 - - 明确说明下一步需要做什么(继续观察 / 执行操作 / 任务完成) - - ## 视觉 + 脚本联合的 ReAct 循环 - - 当任务同时涉及视觉观察和脚本执行时,遵循以下循环模式(每轮只做一件事): - - **观察轮**(只做一件事): - - 调用 get_frame → 收到画面 → 分析内容 → 回复分析结果和下一步计划 - - 禁止同时调用其他工具 - - **行动轮**(不获取画面): - - 根据上一轮的分析结果执行操作(编写脚本、编译验证、运行脚本、停止脚本等) - - 行动完成后说明执行结果 - - **验证轮**(行动后的观察): - - 调用 get_frame 验证行动结果 → 分析是否达成目标 → 决定继续或结束 - - **反思轮**(验证后的自我评估,当遇到问题时触发): - - 评估:上一步是否成功?结果是否可靠? - - 识别:推理是否有漏洞?是否遗漏关键信息? - - 调整:应该坚持、修正还是彻底改变路径? - - 更新:是否需要修正对问题的理解? - - ## 主动结束条件 - - 满足以下任一条件时,停止调用工具并回复用户: - - 用户的目标已明确达成 - - 确认无法完成任务(设备异常、画面无变化等) - - 连续两次操作结果相同,说明策略无效 - - 剩余轮次不足 5 轮 - - ## 注意事项 - - - 不要假设操作一定成功,每次行动后必须 get_frame 验证 - - 脚本运行是异步的,run_script 后需等待再 get_frame - - 发现异常时立即 stop_script 并报告 - - 轮次有限,每一步都要有明确目的 - - 遇到连续失败时,先反思再行动,避免盲目重试 + 停止调用工具、直接总结回复用户的条件:目标已达成;确认无法完成(设备异常、连续验证结果相同、剩余轮次不足)。 """; return new Skill(manifest, body); diff --git a/src/EasyCon.Core/LLM/Skills/SkillTriggerContext.cs b/src/EasyCon.Core/LLM/Skills/SkillTriggerContext.cs index 4a2f374d..b5e64743 100644 --- a/src/EasyCon.Core/LLM/Skills/SkillTriggerContext.cs +++ b/src/EasyCon.Core/LLM/Skills/SkillTriggerContext.cs @@ -8,7 +8,7 @@ public sealed class SkillTriggerContext /// 本轮最新的用户消息文本(小写化由评估器处理)。 public string UserMessage { get; init; } = ""; - /// 对话历史中已调用过的所有工具名(去重)。 + /// 最近工具活动窗口内调用过的工具名(去重)。 public IReadOnlyCollection RecentTools { get; init; } = Array.Empty(); /// 当前 ReAct 轮次(0 表示首轮)。 @@ -28,8 +28,11 @@ public static SkillTriggerContext FromHistory( int round = 0, int recentUserCount = 3) { + // 工具触发只看最近窗口:技能随工具停用而降温,不得随全史永久闩锁 + const int toolActivityWindow = 6; var recentTools = history .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) + .TakeLast(toolActivityWindow) .SelectMany(m => m.ToolCalls!) .Select(tc => tc.Function.Name) .Distinct(StringComparer.Ordinal) diff --git a/src/EasyCon.Core/LLM/SystemPrompts.cs b/src/EasyCon.Core/LLM/SystemPrompts.cs index 4e26f9a5..d3f1ee2a 100644 --- a/src/EasyCon.Core/LLM/SystemPrompts.cs +++ b/src/EasyCon.Core/LLM/SystemPrompts.cs @@ -123,165 +123,65 @@ public static class SystemPrompts """; /// - /// 视觉观察提示词。 - /// 当对话涉及画面分析、截图识别时追加。 + /// 视觉观察提示词(无技能体系时的回退路径)。 /// public const string Vision = """ - ## 视觉观察模式 + ## 视觉观察 - 你可以通过 get_frame 获取当前视频画面进行分析。 - - ### 调用规则 - - 1. **每次只能调用一次 get_frame**,禁止在同一次响应中同时调用 get_frame 和其他工具 - 2. 调用 get_frame 后,本轮响应只能分析画面内容,不得调用任何其他工具 - 3. 分析完成后在回复中说明观察结果和下一步计划 - 4. 下一轮响应再根据分析结果执行操作 - - ### 分析要求 - - - 仔细描述画面中的关键元素(界面、文字、状态、图标等) - - 将画面内容与用户目标对比,判断当前进度 - - 明确说明下一步需要做什么(继续观察 / 执行操作 / 任务完成) + - get_frame 必须单独调用,同轮不得调用其他工具 + - 收到画面后当轮只做分析:关键元素、与用户目标的差距、下一步计划 """; /// - /// 脚本执行提示词。 - /// 当对话涉及脚本运行、自动化执行时追加。 + /// 脚本执行提示词(无技能体系时的回退路径)。 /// public const string ScriptExecution = """ - ## 脚本执行规则 - - 你可以通过 run_script / stop_script 控制脚本在设备上执行。 - - ### 脚本编写铁律 - - 1. **严格遵循 ECScript 语法**:只能使用语法说明中明确列出的关键字、函数、语法结构,绝对禁止自创语法、臆造函数或编造不存在的 API - 2. **写完必须编译**:每次 write_script 后必须调用 compile_script 验证,编译不通过则查看 get_logs 定位错误并修复,直到编译成功后才能 run_script - 3. **完整流程**:write_script → compile_script → (失败则修复重编)→ run_script - 4. **不确定的语法宁可不用**:如果对某个语法结构没有把握,用更简单确定的写法替代,或先向用户确认 + ## 脚本执行 - ### 执行注意 - - - 脚本运行是异步的,run_script 后脚本在后台执行 - - 运行中的脚本可以通过 stop_script 随时停止 - - 发现异常时立即 stop_script 并报告 + - 流程:write_script → compile_script →(失败修复重编)→ run_script,编译不过不得运行 + - 只使用语法说明中列出的语法,不确定的宁可不用 + - run_script 是异步的:启动后需等待并验证,不要假设已生效 + - 行为异常时立即 stop_script 并向用户报告 """; /// - /// 反思提示词。 - /// 当工具执行失败或策略无效时追加,引导模型进行自我评估。 + /// 反思提示词。注入时编排器会在开头附上触发原因(现场数据)。 /// public const string Reflection = """ - ## 反思与自我修正 - - 在执行多步骤任务时,每次观察结果后请先进行反思: - - [反思框架] - - **结果评估**:上一步是否成功?结果是否可靠? - - **错误识别**:我的推理是否有漏洞?是否遗漏了关键信息? - - **策略调整**:下一步应该坚持、修正还是彻底改变路径? - - **知识更新**:我是否需要修正对问题的理解? - - [输出要求] - 先输出反思(以"反思:"开头),再输出下一步思考(以"思考:"开头)。 - - [反思维度] - - 如果工具调用失败:分析失败原因,是参数错误、语法错误还是逻辑错误? - - 如果结果与预期不符:检查假设是否正确,是否需要换一种方法? - - 如果陷入重复:说明当前策略无效,必须尝试不同的路径 - - 如果任务复杂:分解为更小的子目标,逐一验证 + 先停下重试,按序自检: + 1. 失败或无效的根本原因是什么(参数、语法、逻辑,还是对画面的误判)? + 2. 换一条明确不同的路径;无法继续时,向用户说明障碍与已掌握的信息。 + 3. 不要用相同参数重调同一工具。 + 输出以「反思:」开头,随后以「思考:」开头给出下一步计划。 """; /// - /// 深度反思提示词(结构化分析)。 - /// 当陷入循环或多次失败时追加,要求结构化输出。 + /// 深度反思提示词。已反思过仍循环或连续失败远超阈值时使用。 /// public const string ReflectionDeep = """ - ## 深度反思(结构化分析) - - 你已多次遇到问题,需要深入分析当前状况。 - - [深度反思框架] - 1. **结果评估**:上一步操作的具体结果是什么?与预期的差距有多大?结果是否可靠? - 2. **错误识别**: - - 我的推理链条中哪一步可能出错了? - - 我是否做出了错误的假设? - - 是否遗漏了关键信息或约束条件? - 3. **策略调整**: - - 当前策略为什么无效?根本原因是什么? - - 有哪些替代方案?各自的优缺点是什么? - - 应该坚持、修正还是彻底改变路径? - 4. **知识更新**: - - 从这次失败中我学到了什么? - - 这个新认识如何影响后续的行动计划? - - [输出要求] - 先输出结构化反思(以"反思:"开头),包含上述 4 个维度的分析。 - 再输出下一步具体行动计划(以"思考:"开头),必须包含: - - 明确的目标(这次要做什么) - - 具体的步骤(怎么做) - - 预期的结果(应该看到什么) - - 验证方法(如何确认成功) + 多次纠偏无效,说明当前策略整体不可行。按此结构重建方案: + - 目标:要达成的可验证结果 + - 已排除:试过哪些路径、分别为何失败 + - 新路径:下一步的具体动作与预期观察信号 + 输出以「反思:」开头,随后以「思考:」开头给出新方案。 """; /// - /// 视觉 + 脚本联合 ReAct 循环提示词(含反思机制)。 - /// 当对话同时涉及视觉观察和脚本执行时追加(替换单独的 Vision 和 ScriptExecution)。 + /// 视觉 + 脚本联合 ReAct 循环提示词(无技能体系时的回退路径)。 /// public const string ReActLoop = """ - ## 视觉驱动的自动执行模式 - - 你同时具备视觉观察(get_frame)和脚本执行(run_script / stop_script)能力。 - 通过 ReAct 循环完成用户的任务:观察 → 分析 → 行动 → 验证 → 反思。 - - ### 循环流程 - - 每一轮严格遵循以下模式之一: - - **观察轮**(只做一件事): - - 调用 get_frame → 收到画面 → 分析内容 → 回复分析结果和下一步计划 - - 禁止同时调用其他工具 - - **行动轮**(不获取画面): - - 根据上一轮的分析结果执行操作(编写脚本、编译验证、运行脚本、停止脚本等) - - 行动完成后说明执行结果 - - **验证轮**(行动后的观察): - - 调用 get_frame 验证行动结果 → 分析是否达成目标 → 决定继续或结束 - - **反思轮**(验证后的自我评估,当遇到问题时触发): - - 评估:上一步是否成功?结果是否可靠? - - 识别:推理是否有漏洞?是否遗漏关键信息? - - 调整:应该坚持、修正还是彻底改变路径? - - 更新:是否需要修正对问题的理解? - - ### 脚本编写铁律 - - 1. **严格遵循 ECScript 语法**:只能使用语法说明中明确列出的关键字、函数、语法结构,绝对禁止自创语法、臆造函数或编造不存在的 API - 2. **写完必须编译**:每次 write_script 后必须调用 compile_script 验证,编译不通过则查看 get_logs 定位错误并修复,直到编译成功后才能 run_script - 3. **完整流程**:write_script → compile_script → (失败则修复重编)→ run_script - 4. **不确定的语法宁可不用**:如果对某个语法结构没有把握,用更简单确定的写法替代,或先向用户确认 - - ### 主动结束条件 - - 满足以下任一条件时,停止调用工具并回复用户: - - 用户的目标已明确达成 - - 确认无法完成任务(设备异常、画面无变化等) - - 连续两次操作结果相同,说明策略无效 - - 剩余轮次不足 5 轮 + ## 视觉驱动的自动执行 - ### 注意事项 + 按 ReAct 循环推进,每轮只做一件事: + - 观察轮:调用 get_frame → 当轮只分析画面并给出下一步计划 + - 行动轮:write_script → compile_script → run_script(编译不过必须先修复) + - 验证轮:行动后调用 get_frame 验证;脚本异步执行,不要假设已生效 - - 不要假设操作一定成功,每次行动后必须 get_frame 验证 - - 脚本运行是异步的,run_script 后需等待再 get_frame - - 发现异常时立即 stop_script 并报告 - - 轮次有限,每一步都要有明确目的 - - 遇到连续失败时,先反思再行动,避免盲目重试 + 以下情况停止调用工具、直接总结回复用户:目标已达成;确认无法完成(设备异常、连续验证结果相同、剩余轮次不足);脚本行为异常(先 stop_script)。 """; } \ No newline at end of file diff --git a/src/EasyCon.Core/Logging/CoreLog.cs b/src/EasyCon.Core/Logging/CoreLog.cs new file mode 100644 index 00000000..493605ea --- /dev/null +++ b/src/EasyCon.Core/Logging/CoreLog.cs @@ -0,0 +1,33 @@ +namespace EasyCon.Core.Logging; + +/// +/// 库层诊断日志转发器:Core/Device/Capture 等叶子库不依赖具体日志实现, +/// 通过本静态汇点把诊断交给宿主(GUI 接 LogService、CLI 接 Serilog 文件 logger)。 +/// 未接宿主时回落 Debug.WriteLine(Release 编译为 no-op,与既有行为一致)。 +/// 宿主应在启动时赋值 Sink;Dispose 时置回 null。 +/// +public static class CoreLog +{ + /// 宿主注入的日志汇点(null = 未接宿主)。必须线程安全。 + public static Action? Sink; + + public static void Info(string message) => Write("INFO", message); + + public static void Warn(string message) => Write("WARN", message); + + public static void Error(string message) => Write("ERROR", message); + + static void Write(string level, string message) + { + System.Diagnostics.Debug.WriteLine($"[EasyCon:{level}] {message}"); + var sink = Sink; + try + { + sink?.Invoke($"[{level}] {message}"); + } + catch + { + // 日志汇点自身的异常不再反噬库层调用方 + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/ControllerProtocol.cs b/src/EasyCon.Core/Maa/ControllerProtocol.cs new file mode 100644 index 00000000..ffbd9df0 --- /dev/null +++ b/src/EasyCon.Core/Maa/ControllerProtocol.cs @@ -0,0 +1,65 @@ +#nullable enable +using EasyDevice; +using EasyScript; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EasyCon.Core.Maa; + +internal sealed record ControllerReport(ushort Buttons = 0, byte Hat = 8, + byte Lx = 128, byte Ly = 128, byte Rx = 128, byte Ry = 128) +{ + public SwitchReport ToReport() => new() { Button = Buttons, HAT = Hat, LX = Lx, LY = Ly, RX = Rx, RY = Ry }; + public static ControllerReport FromReport(SwitchReport report) + => new(report.Button, report.HAT, report.LX, report.LY, report.RX, report.RY); +} + +/// 同时保存编号和 Down 后的参数,避免仅传编号丢失组合键/摇杆坐标。 +internal sealed record ControllerKey(int Code, ControllerReport State) +{ + public static ControllerKey FromKey(ECKey key) + { + SwitchReport report = new(); + key.Down(report); + return new(key.KeyCode, ControllerReport.FromReport(report)); + } + + public ECKey ToKey() => Code switch + { + >= 0 and <= 15 when State.Buttons != 0 => ECKeyUtil.Button((SwitchButton)State.Buttons), + >= 16 and <= 24 when State.Hat <= 8 => ECKeyUtil.HAT((SwitchHAT)State.Hat), + 32 => ECKeyUtil.LStick(State.Lx, State.Ly), + 33 => ECKeyUtil.RStick(State.Rx, State.Ry), + _ => throw new ControllerOperationException("InvalidKey", $"不支持的按键编号 {Code}"), + }; +} + +internal sealed record ControllerCommand +{ + public string Protocol { get; init; } = "easycon.controller/1"; + public string RequestId { get; init; } = Guid.NewGuid().ToString("N"); + public string? RunId { get; init; } + public long Epoch { get; init; } + public required string Op { get; init; } + public ControllerKey? Key { get; init; } + public ControllerReport? Report { get; init; } + public int Duration { get; init; } + public DelayType Timing { get; init; } + public DirectionKey Direction { get; init; } + public bool Down { get; init; } + public byte Index { get; init; } + public byte[]? Data { get; init; } + public string? Port { get; init; } +} + +internal sealed record ControllerReply(string RequestId, bool Accepted, string? ErrorCode = null, + InputCompletion Completion = InputCompletion.Accepted, string? Value = null); + +internal static class ControllerJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter() }, + }; +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/ControllerRunLease.cs b/src/EasyCon.Core/Maa/ControllerRunLease.cs new file mode 100644 index 00000000..12aed9a9 --- /dev/null +++ b/src/EasyCon.Core/Maa/ControllerRunLease.cs @@ -0,0 +1,52 @@ +#nullable enable +using EasyCon.Core.Input; + +namespace EasyCon.Core.Maa; + +/// 一次设备写运行。取消立即到达回调;完成清理后才归还代次。 +public sealed class ControllerRunLease : IDisposable +{ + private readonly object _gate = new(); + private Task? _completion; + private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal MaaControllerSession Owner { get; } + internal InputOwner InputOwner { get; } + internal MaaControllerBinding Binding { get; } + public string RunId { get; } + public long Epoch { get; } + public CancellationToken Token => InputOwner.Token; + public RunCancellationReason? CancellationReason => InputOwner.Reason; + public CleanupStatus Cleanup { get; private set; } = CleanupStatus.NotRequired; + public Task Completion => _finished.Task; + + internal ControllerRunLease(MaaControllerSession owner, InputOwner inputOwner, MaaControllerBinding binding) + { + Owner = owner; + InputOwner = inputOwner; + Binding = binding; + RunId = inputOwner.RunId!; + Epoch = inputOwner.Epoch; + } + + public void Cancel(RunCancellationReason reason = RunCancellationReason.UserStop) + { + if (!Completion.IsCompleted) InputOwner.Cancel(reason); + } + + public Task StopAsync() + { + Cancel(); + lock (_gate) return _completion ??= Task.Run(() => + { + try + { + Cleanup = Owner.EndRun(this); + _finished.TrySetResult(Cleanup); + return Cleanup; + } + catch (Exception ex) { _finished.TrySetException(ex); throw; } + }); + } + + public void Dispose() => StopAsync().GetAwaiter().GetResult(); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/FrameHub.cs b/src/EasyCon.Core/Maa/FrameHub.cs new file mode 100644 index 00000000..76bc8780 --- /dev/null +++ b/src/EasyCon.Core/Maa/FrameHub.cs @@ -0,0 +1,272 @@ +#nullable enable +using EasyCon.Capture; +using EasyCon.Core.Capabilities; +using OpenCvSharp; +using System.Diagnostics; + +namespace EasyCon.Core.Maa; + +public interface IFrameHub +{ + bool CaptureConnected { get; } + bool FrameReady { get; } + long SourceEpoch { get; } + FrameLease? AcquireLatest(); + Task WaitForFrameAfterAsync(long frameId, long capturedAtTicks, CancellationToken token); +} + +public enum CaptureFailure { Disconnected, SourceChanged, TimedOut, HostShutdown } + +/// 独立采集模块。连接级唯一生产者;重连保留同一个 FrameStore。 +public sealed class FrameHub : IFrameHub, IDisposable +{ + private readonly object _lifecycle = new(); + private readonly object _consumersGate = new(); + private readonly HashSet _consumers = new(); + private readonly System.Threading.Timer _health; + private FrameProducer? _producer; + private bool _connected; + private bool _disposed; + private long _openedAt; + + public FrameHub() => _health = new System.Threading.Timer(_ => CheckHealth(), null, 250, 250); + public FrameStore Store { get; } = new(); + public TimeSpan FrameTimeout { get; init; } = TimeSpan.FromSeconds(5); + public bool CaptureConnected => Volatile.Read(ref _connected); + public bool FrameReady + { + get + { + using FrameLease? frame = AcquireLatest(); + return frame != null && frame.Metadata.SourceEpoch == SourceEpoch && + Stopwatch.GetElapsedTime(frame.Metadata.CapturedAtTicks) <= FrameTimeout; + } + } + public long SourceEpoch => _producer?.SourceEpoch ?? 0; + public string? SourceId { get; private set; } + public VideoCaptureAPIs? Backend { get; private set; } + public CaptureSourceDescriptor? Selection { get; private set; } + public int? DeviceIndex { get; private set; } + public bool HeldByUser { get; private set; } + public bool IsStopping => _producer?.IsStopping == true; + public string? LastError => _producer?.LastError; + public int ConsumerCount { get { lock (_consumersGate) return _consumers.Count; } } + public event Action? Unavailable; + public FrameLease? AcquireLatest() + { + if (!CaptureConnected) return null; + FrameLease? frame = Store.AcquireLatest(); + if (frame != null && (frame.Metadata.SourceEpoch != SourceEpoch || Stopwatch.GetElapsedTime(frame.Metadata.CapturedAtTicks) > FrameTimeout)) + { + frame.Dispose(); + return null; + } + return frame; + } + public Task WaitForFrameAfterAsync(long frameId, long capturedAtTicks, CancellationToken token) + => Store.WaitForFrameAfterAsync(frameId, capturedAtTicks, token); + + public CaptureUsageLease AcquireUsage() + { + lock (_consumersGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!CaptureConnected) throw new InvalidOperationException("采集源未连接"); + CaptureUsageLease lease = new(this, SourceEpoch); + _consumers.Add(lease); + return lease; + } + } + + internal void Return(CaptureUsageLease lease) + { + lock (_consumersGate) _consumers.Remove(lease); + } + + private CaptureUsageLease[] CancelConsumers(CaptureFailure reason) + { + CaptureUsageLease[] consumers; + lock (_consumersGate) + { + _connected = false; + consumers = _consumers.ToArray(); + } + foreach (CaptureUsageLease lease in consumers) lease.Cancel(reason); + Unavailable?.Invoke(reason); + return consumers; + } + + private void DrainConsumers(CaptureFailure reason) + { + foreach (CaptureUsageLease lease in CancelConsumers(reason)) lease.Completion.GetAwaiter().GetResult(); + } + + private void CheckHealth() + { + if (!CaptureConnected) return; + try + { + using FrameLease? frame = Store.AcquireLatest(); + long arrival = frame?.Metadata.CapturedAtTicks ?? _openedAt; + if (_producer?.IsOpened == false || Stopwatch.GetElapsedTime(arrival) > FrameTimeout) + CancelConsumers(CaptureFailure.TimedOut); + } + catch (ObjectDisposedException) { } + } + + public bool Connect(CaptureSourceDescriptor selection, FrameNormalization? normalization = null) + { + CaptureSourceDescriptor resolved = CaptureSourceCatalog.Resolve(selection, CaptureSourceCatalog.Enumerate(selection.Backend)); + return ConnectCore(resolved.Index, resolved.Backend, resolved.Id, resolved, normalization); + } + + /// CLI 的临时数字索引按明确后端解释,不使用其他后端的枚举映射。 + public bool Connect(int deviceId, VideoCaptureAPIs api = VideoCaptureAPIs.ANY, FrameNormalization? normalization = null) + { + if (deviceId < 0) throw new ArgumentOutOfRangeException(nameof(deviceId)); + VideoCaptureAPIs backend = CaptureSourceCatalog.ResolveBackend(api); + return ConnectCore(deviceId, backend, $"temporary:{backend}:{deviceId}", null, normalization); + } + + private bool ConnectCore(int deviceId, VideoCaptureAPIs backend, string sourceId, + CaptureSourceDescriptor? selection, FrameNormalization? normalization) + { + lock (_lifecycle) + { + ObjectDisposedException.ThrowIf(_disposed, this); + DrainConsumers(CaptureFailure.SourceChanged); + StopPrevious(); + SourceOwnership ownership = new($"camera:{deviceId}"); + OpenCVCapture capture = new(); + try + { + if (!capture.Open(deviceId, (int)backend)) + { + capture.Dispose(); ownership.Dispose(); + return false; + } + if (capture.Backend != backend) throw new IOException($"采集实际后端 {capture.Backend} 与选择 {backend} 不符"); + capture.SetProperties(FrameNormalization.Width, FrameNormalization.Height); + _producer = new FrameProducer(new OwnedSource(capture, ownership), Store, normalization, sourceId); + _producer.Start(); + SourceId = sourceId; + Backend = backend; + Selection = selection; + DeviceIndex = deviceId; + _openedAt = Stopwatch.GetTimestamp(); + HeldByUser = true; + _connected = true; + return true; + } + catch { capture.Dispose(); ownership.Dispose(); throw; } + } + } + + internal void Connect(IFrameSource source, string sourceId, FrameNormalization? normalization = null) + { + lock (_lifecycle) + { + ObjectDisposedException.ThrowIf(_disposed, this); + DrainConsumers(CaptureFailure.SourceChanged); + StopPrevious(); + _producer = new FrameProducer(source, Store, normalization, sourceId); + _producer.Start(); + SourceId = sourceId; + _openedAt = Stopwatch.GetTimestamp(); + HeldByUser = true; + _connected = true; + } + } + + private void StopPrevious() + { + if (_producer == null) return; + if (!_producer.Stop()) throw new InvalidOperationException("busy: 旧采集 Read 尚未退出,仍在停止中"); + _producer.Dispose(); + _producer = null; + } + + public bool Disconnect() + { + lock (_lifecycle) + { + HeldByUser = false; + DrainConsumers(CaptureFailure.Disconnected); + if (_producer == null) return true; + if (!_producer.Stop()) return false; + _producer.Dispose(); + _producer = null; + return true; + } + } + + public void Dispose() + { + lock (_lifecycle) + { + if (_disposed) return; + DrainConsumers(CaptureFailure.HostShutdown); + _disposed = true; + _health.Dispose(); + _producer?.Dispose(); + Store.ReleaseCurrent(); + } + } + + private sealed class OwnedSource(IFrameSource source, SourceOwnership ownership) : IFrameSource + { + public bool IsOpened => source.IsOpened; + public Mat GetMatFrame() => source.GetMatFrame(); + public void SetProperties(int width, int height) => source.SetProperties(width, height); + public void Dispose() + { + try { source.Dispose(); } + finally { ownership.Dispose(); } + } + } +} + +/// Flow 等端口也经过帧门面的代次与新鲜度检查;不另读全局帧号作为图像身份。 +public sealed class FrameHubCaptureSource(FrameHub hub) : ICaptureSource +{ + public long? FrameIndex => hub.Store.FrameCount; + public string? CaptureFrame(int x, int y, int width, int height) + { + using FrameLease? frame = hub.AcquireLatest(); + if (frame == null) return null; + if (x < 0 || y < 0 || width < 0 || height < 0) return Convert.ToBase64String(frame.EncodedPng.Span); + return FrameDelegateFactory.CropToBase64(frame.Mat, x, y, width, height); + } +} + +public sealed class CaptureUsageLease : IDisposable +{ + private readonly FrameHub _hub; + private readonly CancellationTokenSource _cancellation = new(); + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _returned; + internal CaptureUsageLease(FrameHub hub, long epoch) + { + _hub = hub; + SourceEpoch = epoch; + Token = _cancellation.Token; + } + public long SourceEpoch { get; } + public CancellationToken Token { get; } + public CaptureFailure? Failure { get; private set; } + public Task Completion => _completion.Task; + internal void Cancel(CaptureFailure reason) + { + if (Volatile.Read(ref _returned) != 0) return; + Failure ??= reason; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + } + public void Dispose() + { + if (Interlocked.Exchange(ref _returned, 1) != 0) return; + _hub.Return(this); + _completion.TrySetResult(); + _cancellation.Dispose(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/GenerationLease.cs b/src/EasyCon.Core/Maa/GenerationLease.cs new file mode 100644 index 00000000..8e4f640b --- /dev/null +++ b/src/EasyCon.Core/Maa/GenerationLease.cs @@ -0,0 +1,56 @@ +#nullable enable +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +/// 派生目录的活跃引用;旧运行退出前不删除其版本。只清理有转换器标记的缓存。 +internal sealed class GenerationLease : IDisposable +{ + private static readonly object Gate = new(); + private static readonly Dictionary References = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private readonly string _path; + private bool _disposed; + public static int ActiveVersions { get { lock (Gate) return References.Count; } } + + public GenerationLease(string path) + { + _path = Path.GetFullPath(path); + lock (Gate) References[_path] = References.GetValueOrDefault(_path) + 1; + } + + public static void Prune(string root, string current, int retain) + { + lock (Gate) + { + int kept = 0; + foreach (string path in Directory.GetDirectories(root).OrderByDescending(Directory.GetLastWriteTimeUtc)) + { + string name = Path.GetFileName(path); + if (name.Length != 64 || !name.All(char.IsAsciiHexDigit) || name == current || References.ContainsKey(Path.GetFullPath(path))) continue; + string manifest = Path.Combine(path, "easycon", "manifest.json"); + if (!File.Exists(manifest)) continue; + try + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(manifest)); + if (!document.RootElement.TryGetProperty("converter", out JsonElement converter) || converter.GetString() != MaaLabelConverter.Version) continue; + if (++kept < retain) continue; + Directory.Delete(path, true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + catch (JsonException) { } + } + } + } + + public void Dispose() + { + lock (Gate) + { + if (_disposed) return; + _disposed = true; + if (References[_path] == 1) References.Remove(_path); + else References[_path]--; + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/HostRunCoordinator.cs b/src/EasyCon.Core/Maa/HostRunCoordinator.cs new file mode 100644 index 00000000..5ef81b7c --- /dev/null +++ b/src/EasyCon.Core/Maa/HostRunCoordinator.cs @@ -0,0 +1,248 @@ +#nullable enable +using EasyCon.Capture; +using EasyCon.Core.Capabilities; +using EasyScript; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EasyCon.Core.Maa; + +public enum RunCancellationReason { UserStop, Deadline, DeviceLost, CaptureLost, HostShutdown } +public sealed record EffectiveRunConfig(string? InputEndpoint, long? InputEpoch, string? CaptureSource, + string? CaptureBackend, long? SourceEpoch, bool RequiresInput, bool RequiresCapture); +public sealed record HostRunResult(string RunId, string EntryKind, string? Source, + string BusinessStatus, string NativeStatus, string? ErrorCode, TimeSpan Elapsed, + CleanupStatus CleanupStatus, EffectiveRunConfig EffectiveConfig, + string CompatibilityProfile = "EasyCon.Legacy", string? ResourceGeneration = null, + string? InvocationId = null, string? ErrorNode = null, string? DiagnosticPath = null); + +/// 顶层运行的统一停止域;模块按需登记依赖,不从其他模块的状态推断连接。 +public sealed class HostRunCoordinator(IControllerSession controller, FrameHub? frames = null) +{ + public HostRunResult? LastResult { get; private set; } + public string DiagnosticDirectory { get; init; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EasyCon", "run-reports"); + public event Action? Completed; + + public void Run(string entryKind, string? source, bool requiresInput, CancellationToken token, + Action execute, bool requiresCapture = false, string compatibilityProfile = "EasyCon.Legacy", string? resourceGeneration = null) + { + using HostRunScope scope = new(controller, frames, token, requiresInput, requiresCapture); + Stopwatch elapsed = Stopwatch.StartNew(); + string status = "Succeeded"; + string? error = null; + try + { + scope.Token.ThrowIfCancellationRequested(); + if (requiresInput) scope.RequireInput(); + if (requiresCapture) scope.RequireCapture(waitForFrame: true); + execute(scope); + scope.Token.ThrowIfCancellationRequested(); + if (scope.BusinessError != null) { status = "Failed"; error = scope.BusinessError; } + } + catch (OperationCanceledException) + { + RunCancellationReason reason = scope.Reason ?? RunCancellationReason.UserStop; + status = reason == RunCancellationReason.UserStop ? "Cancelled" : "Failed"; + error = reason.ToString(); + throw; + } + catch (Exception ex) + { + status = "Failed"; + error = ex is ControllerOperationException controllerError ? controllerError.ErrorCode : ex.GetType().Name; + throw; + } + finally + { + CleanupStatus cleanup = scope.Finish(); + if (cleanup == CleanupStatus.ReleaseUnconfirmed) { status = "Failed"; error ??= "ReleaseUnconfirmed"; } + HostRunResult result = new(scope.RunId, entryKind, source, status, scope.NativeStatus, error, + elapsed.Elapsed, cleanup, scope.EffectiveConfig, CompatibilityProfile: compatibilityProfile, + ResourceGeneration: resourceGeneration, InvocationId: entryKind == "ecs" ? scope.RunId + ":ecs" : null, ErrorNode: scope.ErrorNode); + try + { + Directory.CreateDirectory(DiagnosticDirectory); + string path = Path.Combine(DiagnosticDirectory, "run-" + scope.RunId + ".json"); + result = result with { DiagnosticPath = path }; + File.WriteAllText(path, JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + })); + foreach (FileInfo old in new DirectoryInfo(DiagnosticDirectory).GetFiles("run-*.json").OrderByDescending(f => f.LastWriteTimeUtc).Skip(128)) + old.Delete(); + } + catch (IOException ex) { Logging.CoreLog.Warn($"运行报告未能写入: {ex.Message}"); } + LastResult = result; + Completed?.Invoke(result); + if (cleanup == CleanupStatus.ReleaseUnconfirmed && error == "ReleaseUnconfirmed") + throw new ControllerOperationException("ReleaseUnconfirmed", "输入释放未确认;请检查连接并重新连接手柄"); + } + } +} + +/// 一次运行按需借用两个模块。纯计算不取得硬件租约;已有图片 OCR 不登记采集依赖。 +public sealed class HostRunScope : IDisposable +{ + private readonly IControllerSession _controller; + private readonly FrameHub? _frames; + private readonly CancellationTokenSource _cancellation; + private readonly CancellationTokenRegistration _externalCancellation; + private readonly object _gate = new(); + private ControllerRunLease? _input; + private CaptureUsageLease? _capture; + private CancellationTokenRegistration _inputCancellation; + private CancellationTokenRegistration _captureCancellation; + private bool _finishing; + private bool _finished; + private CleanupStatus _cleanup; + private RunCancellationReason? _reason; + private readonly long _initialSourceEpoch; + private readonly long _initialInputConnection; + public string RunId { get; } = Guid.NewGuid().ToString("N"); + public CancellationToken Token { get; } + public RunCancellationReason? Reason => _reason; + public string? BusinessError { get; private set; } + public string? ErrorNode { get; private set; } + public void MarkFailed(string code, string? node = null) { BusinessError ??= code; ErrorNode ??= node; } + public string NativeStatus => _input?.Binding.LastNativeStatus ?? "NotRequired"; + public EffectiveRunConfig EffectiveConfig { get; private set; } = new(null, null, null, null, null, false, false); + + internal HostRunScope(IControllerSession controller, FrameHub? frames, CancellationToken token, bool requiresInput = false, bool requiresCapture = false) + { + _controller = controller; + EffectiveConfig = EffectiveConfig with { RequiresInput = requiresInput, RequiresCapture = requiresCapture }; + _frames = frames; + _initialSourceEpoch = frames?.SourceEpoch ?? 0; + _initialInputConnection = controller.ConnectionEpoch; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + Token = _cancellation.Token; + _externalCancellation = token.Register(() => Cancel(RunCancellationReason.UserStop)); + } + + public void Cancel(RunCancellationReason reason) + { + lock (_gate) + { + if (_finished || _finishing) return; + _reason ??= reason; + } + _cancellation.Cancel(); + } + + public ControllerRunLease RequireInput() + { + lock (_gate) + { + Token.ThrowIfCancellationRequested(); + if (_input != null) return _input; + if (_controller.ConnectionEpoch != _initialInputConnection) + throw new ControllerOperationException("InputChanged", "本次运行选定的手柄连接已更换"); + _input = _controller.BeginRun(RunId, Token, _initialInputConnection); + _inputCancellation = _input.Token.Register(() => Cancel(_input.CancellationReason ?? RunCancellationReason.DeviceLost)); + EffectiveConfig = EffectiveConfig with + { + InputEndpoint = _controller.InputEndpoint, + InputEpoch = _input.Epoch, + RequiresInput = true, + }; + return _input; + } + } + + public void RequireCapture(bool waitForFrame = false) + { + if (_frames == null) return; + lock (_gate) + { + Token.ThrowIfCancellationRequested(); + if (_capture == null) + { + if (_frames.SourceEpoch != _initialSourceEpoch) + throw new ControllerOperationException("CaptureChanged", "本次运行选定的采集源已更换"); + _capture = _frames.AcquireUsage(); + _captureCancellation = _capture.Token.Register(() => Cancel(_capture.Failure == CaptureFailure.HostShutdown + ? RunCancellationReason.HostShutdown : RunCancellationReason.CaptureLost)); + EffectiveConfig = EffectiveConfig with + { + CaptureSource = _frames.SourceId, + CaptureBackend = _frames.Backend?.ToString(), + SourceEpoch = _capture.SourceEpoch, + RequiresCapture = true, + }; + } + } + if (waitForFrame && !_frames.FrameReady) + { + using CancellationTokenSource deadline = CancellationTokenSource.CreateLinkedTokenSource(Token); + deadline.CancelAfter(_frames.FrameTimeout); + long freshAfter = Stopwatch.GetTimestamp() - (long)(_frames.FrameTimeout.TotalSeconds * Stopwatch.Frequency); + try + { + using FrameLease frame = _frames.WaitForFrameAfterAsync(0, freshAfter, deadline.Token).GetAwaiter().GetResult(); + if (frame.Metadata.SourceEpoch != _capture!.SourceEpoch) + throw new ControllerOperationException("CaptureChanged", "等待期间采集代次已更换"); + } + catch (OperationCanceledException) when (!Token.IsCancellationRequested) + { + Cancel(RunCancellationReason.CaptureLost); + throw new ControllerOperationException("FrameUnavailable", "等待当前采集源的首帧超时"); + } + } + } + + public IPadInput CreatePad(DelayType timing = DelayType.LowCPU) => new RunPad(this, timing); + public ICaptureSource WrapCapture(ICaptureSource capture) => new RunCapture(this, capture); + public FrameLease? AcquireFrame(Func fallback) + { + if (_frames?.CaptureConnected == true) RequireCapture(); + Token.ThrowIfCancellationRequested(); + return _frames != null ? _frames.AcquireLatest() : fallback(); + } + + internal CleanupStatus Finish() + { + lock (_gate) + { + if (_finished) return _cleanup; + _finishing = true; + } + _cleanup = _input?.StopAsync().GetAwaiter().GetResult() ?? CleanupStatus.NotRequired; + _inputCancellation.Dispose(); + _captureCancellation.Dispose(); + _capture?.Dispose(); + lock (_gate) _finished = true; + return _cleanup; + } + + public void Dispose() + { + Finish(); + _externalCancellation.Dispose(); + _cancellation.Dispose(); + } + + private sealed class RunPad(HostRunScope scope, DelayType timing) : IPadInput + { + private IPadInput Pad => scope._controller.CreatePad(timing, scope.RequireInput()); + public void ClickButtons(GamePadKey key, int duration, CancellationToken token) => Pad.ClickButtons(key, duration, token); + public void PressButtons(GamePadKey key) => Pad.PressButtons(key); + public void ReleaseButtons(GamePadKey key) => Pad.ReleaseButtons(key); + public void ClickStick(GamePadKey key, byte x, byte y, int duration, CancellationToken token) => Pad.ClickStick(key, x, y, duration, token); + public void SetStick(GamePadKey key, byte x, byte y) => Pad.SetStick(key, x, y); + public void ChangeAmiibo(uint index) => Pad.ChangeAmiibo(index); + } + + private sealed class RunCapture(HostRunScope scope, ICaptureSource capture) : ICaptureSource + { + public long? FrameIndex { get { scope.RequireCapture(); return capture.FrameIndex; } } + public string? CaptureFrame(int x, int y, int width, int height) + { + scope.RequireCapture(); + scope.Token.ThrowIfCancellationRequested(); + return capture.CaptureFrame(x, y, width, height); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/IControllerSession.cs b/src/EasyCon.Core/Maa/IControllerSession.cs new file mode 100644 index 00000000..1289ba2f --- /dev/null +++ b/src/EasyCon.Core/Maa/IControllerSession.cs @@ -0,0 +1,61 @@ +#nullable enable +using EasyCon.Core.Capabilities; +using EasyDevice; +using EasyDevice.Connection; +using EasyScript; + +namespace EasyCon.Core.Maa; + +public enum ControllerConnectResult { None, Success, InvalidArgument, Timeout, Error, Busy, ReleaseUnconfirmed } +public enum InputCompletion { Accepted, Written, Acknowledged } +public enum CleanupStatus { NotRequired, Written, ReleaseUnconfirmed } + +/// 宿主设备端口;协议对象仅存在于 Maa 回调内部。 +public interface IControllerSession : IReporter, IDisposable +{ + bool InputConnected { get; } + bool SupportsLegacyCommands => true; + string? FirmwareName => null; + string? FirmwareVersion => null; + bool? ConsoleConnected => null; + string? LastConnectionError => null; + string? InputEndpoint { get; } + long InputEpoch { get; } + long ConnectionEpoch { get; } + bool ControllerConnected { get; } + bool CanAcceptManualInput { get; } + bool IsConnected(); + event LogHandler Log; + event BytesTransferedHandler BytesSent; + event BytesTransferedHandler BytesReceived; + event StatusChangedHandler StatusChanged; + ControllerConnectResult TryConnect(string port); + void Disconnect(); + ControllerRunLease BeginRun(string runId, CancellationToken token, long? expectedConnectionEpoch = null); + IPadInput CreatePad(DelayType timing, ControllerRunLease? run = null); + void Down(ECKey key); + void Up(ECKey key); + void LeftDirection(DirectionKey key, bool down); + void RightDirection(DirectionKey key, bool down); + void HatDirection(DirectionKey key, bool down); + void ApplyReport(SwitchReport report); + void Reset(); + bool Flash(byte[] bytes); + bool RemoteStart(); + bool RemoteStop(); + int GetVersion(); + bool UnPair(); + bool ChangeAmiiboIndex(byte index); + bool ChangeControllerMode(byte mode); + bool ChangeControllerColor(byte[] color); + bool SaveAmiibo(byte index, byte[] data); + void StartRecord(); + void StopRecord(); + void PauseRecord(); + string GetRecordScript(); +} + +public sealed class ControllerOperationException(string errorCode, string message) : InvalidOperationException(message) +{ + public string ErrorCode { get; } = errorCode; +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/LegacyLabelCatalog.cs b/src/EasyCon.Core/Maa/LegacyLabelCatalog.cs new file mode 100644 index 00000000..fb6a2c39 --- /dev/null +++ b/src/EasyCon.Core/Maa/LegacyLabelCatalog.cs @@ -0,0 +1,60 @@ +#nullable enable +using EasyCon.Capture; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +public sealed record LabelDiagnostic(string Severity, string Code, string Source, string? Label, string Message); +public sealed record LegacyLabelSource(string Name, string Path, string Content, string Digest, int Priority) +{ + public ImgLabel Read() + { + ImgLabel label = JsonSerializer.Deserialize(Content) ?? throw new InvalidDataException("标签内容为空"); + label.name = Name; + label.path = System.IO.Path.GetDirectoryName(Path) ?? ""; + return label; + } +} + +public sealed record LabelDiscovery(IReadOnlyList Selected, IReadOnlyList Diagnostics, int TotalFiles); + +public static class LegacyLabelCatalog +{ + public static LabelDiscovery FromParents(params string[] parents) + => Scan(parents.Select(parent => Path.Combine(parent, ECCore.ImgDir))); + + /// 直接接受 ImgLabel 目录。按有序来源,第一个成功解析的同名标签胜出。 + public static LabelDiscovery Scan(IEnumerable directories, bool recursive = false) + { + Dictionary selected = new(StringComparer.Ordinal); + List diagnostics = []; + int priority = 0, total = 0; + foreach (string directory in directories) + { + if (!Directory.Exists(directory)) { priority++; continue; } + foreach (string file in Directory.GetFiles(directory, "*.IL", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly).Order(StringComparer.Ordinal)) + { + total++; + string name = Path.GetFileNameWithoutExtension(file); + try + { + byte[] bytes = File.ReadAllBytes(file); + string content = Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'); + LegacyLabelSource source = new(name, Path.GetFullPath(file), content, + Convert.ToHexStringLower(SHA256.HashData(bytes)), priority); + _ = source.Read(); + if (!selected.TryAdd(name, source)) + diagnostics.Add(new("warning", "shadowed", file, name, $"同名标签由更早来源胜出: {selected[name].Path}")); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or InvalidDataException or NotSupportedException) + { + diagnostics.Add(new("error", "parse-error", file, name, ex.Message)); + } + } + priority++; + } + return new(selected.Values.ToArray(), diagnostics, total); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaControllerBinding.cs b/src/EasyCon.Core/Maa/MaaControllerBinding.cs new file mode 100644 index 00000000..4c308105 --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaControllerBinding.cs @@ -0,0 +1,267 @@ +#nullable enable +using EasyCon.Capture; +using EasyCon.Core.Input; +using MaaFramework.Binding; +using MaaFramework.Binding.Buffers; +using MaaFramework.Binding.Custom; +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +/// 独立停止域的 Maa 原生句柄。只借用所绑定模块,不连接/销毁另一模块。 +public sealed class MaaControllerBinding : IDisposable +{ + private static int _activeNativeBindings; + public static int ActiveNativeBindings => Volatile.Read(ref _activeNativeBindings); + private readonly PadSession? _pad; + private readonly InputOwner? _owner; + private readonly IFrameHub? _frames; + private readonly long _sourceEpoch; + private readonly object _gate = new(); + private readonly object _lifecycle = new(); + private readonly Queue _recentRequests = new(); + private readonly HashSet _seenRequests = new(StringComparer.Ordinal); + private readonly string _bindingId = Guid.NewGuid().ToString("N"); + private readonly Lazy _native; + private MaaResource? _resource; + private MaaTasker? _tasker; + private CancellationToken _operationToken; + private bool _disposed; + private bool _stopped; + private CaptureUsageLease? _captureUsage; + private CancellationTokenRegistration _captureCancellation; + + internal MaaControllerBinding(PadSession? pad, InputOwner? owner, IFrameHub? frames = null, + string? resourceDirectory = null) + { + _pad = pad; + _owner = owner; + _frames = frames; + _sourceEpoch = frames?.SourceEpoch ?? 0; + if ((pad == null) != (owner == null)) throw new ArgumentException("输入端口和所有者必须同时绑定"); + if (pad == null && frames == null) throw new ArgumentException("无硬件任务不创建控制器绑定"); + _native = new(() => + { + _ = MaaEnvironment.CacheDirectory; + MaaCustomController controller = new(new Callbacks(this), LinkOption.None, CheckStatusOption.None); + try + { + if (!controller.SetOption(ControllerOption.ScreenshotUseRawSize, true)) + throw new InvalidOperationException("Maa ScreenshotUseRawSize 设置失败"); + _resource = MaaEnvironment.LoadBootstrap(resourceDirectory); + _tasker = new MaaTasker + { + Controller = controller, + Resource = _resource, + DisposeOptions = DisposeOptions.None, + }; + if (BindingReady && !_tasker.IsInitialized) throw new InvalidOperationException("Maa Tasker 未初始化"); + Interlocked.Increment(ref _activeNativeBindings); + return controller; + } + catch { _tasker?.Dispose(); _resource?.Dispose(); controller.Dispose(); throw; } + }); + } + + public static MaaControllerBinding CaptureOnly(IFrameHub frames) + { + CaptureUsageLease? usage = (frames as FrameHub)?.AcquireUsage(); + try + { + MaaControllerBinding binding = new(null, null, frames) { _captureUsage = usage }; + if (usage != null) + binding._captureCancellation = usage.Token.Register(() => _ = Task.Run(binding.Dispose)); + return binding; + } + catch { usage?.Dispose(); throw; } + } + internal MaaCustomController Native => _native.Value; + public bool RequiresInput => _pad != null; + public bool RequiresCapture => _frames != null; + internal bool HasNative => _native.IsValueCreated; + public string LastNativeStatus { get; private set; } = "NotStarted"; + public bool IsInitialized => _native.IsValueCreated && _tasker?.IsInitialized == true; + public bool BindingReady + { + get + { + if (_disposed || _stopped) return false; + if (_pad != null) + { + try { _pad.Validate(_owner!, cleanup: true); } + catch (Exception ex) when (ex is ControllerOperationException or OperationCanceledException or ObjectDisposedException) { return false; } + if (!_pad.IsConnected) return false; + } + return _frames == null || (_frames.CaptureConnected && _frames.SourceEpoch == _sourceEpoch); + } + } + + internal ControllerReply Send(ControllerCommand command, CancellationToken token) + { + if (_pad == null || _owner == null) throw new ControllerOperationException("UnsupportedInput", "此绑定只提供截图"); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _pad.Validate(_owner); + token.ThrowIfCancellationRequested(); + command = command with { RunId = _owner.RunId, Epoch = _owner.Epoch }; + _operationToken = token; + try + { + MaaJobStatus status = Native.Shell(JsonSerializer.Serialize(command, ControllerJson.Options), -1).Wait(); + LastNativeStatus = status.ToString(); + token.ThrowIfCancellationRequested(); + _owner.Token.ThrowIfCancellationRequested(); + if (status != MaaJobStatus.Succeeded || !Native.GetShellOutput(out string? output) || output == null) + throw new ControllerOperationException("NativeFailure", "Maa Shell 回调未完成"); + ControllerReply reply = JsonSerializer.Deserialize(output, ControllerJson.Options) + ?? throw new ControllerOperationException("InvalidReply", "控制器响应为空"); + if (reply.RequestId != command.RequestId) throw new ControllerOperationException("InvalidReply", "requestId 不一致"); + if (!reply.Accepted) throw new ControllerOperationException(reply.ErrorCode ?? "InputFailed", reply.Value ?? "控制器拒绝操作"); + return reply; + } + finally { _operationToken = default; } + } + } + + /// 不跨队列传 Mat;同一租约的 PNG 编码仅保留在其帧槽内。 + public byte[] Screenshot() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!BindingReady) throw new ControllerOperationException("BindingNotReady", "截图绑定已失效或来源未连接"); + if (Native.Screencap().Wait() != MaaJobStatus.Succeeded) + throw new ControllerOperationException("FrameUnavailable", "当前代次尚无有效画面"); + using MaaImageBuffer buffer = new(); + if (!Native.GetCachedImage(buffer) || !buffer.TryGetEncodedData(out byte[]? png) || png == null || png.Length == 0) + throw new InvalidDataException("Maa 截图返回空图像"); + return png; + } + } + + internal bool ReleaseInput() + { + if (_pad == null) return true; + lock (_gate) return Native.Inactive().Wait() == MaaJobStatus.Succeeded; + } + + public void Stop() + { + lock (_lifecycle) + { + if (_disposed || _stopped) return; + if (!_native.IsValueCreated) { _stopped = true; return; } + // Stop 先通知原生队列,再等已进入的回调退出;不在回调中等待自身。 + _tasker?.Stop().Wait(); + lock (_gate) { } + _stopped = true; + } + } + + public void Dispose() + { + lock (_lifecycle) + { + if (_disposed) return; + Stop(); + lock (_gate) + { + if (_disposed) return; + if (_native.IsValueCreated) + { + _tasker?.Dispose(); + _resource?.Dispose(); + _native.Value.Dispose(); + Interlocked.Decrement(ref _activeNativeBindings); + } + _disposed = true; + _captureCancellation.Dispose(); + _captureUsage?.Dispose(); + } + } + } + + internal bool InactiveForOwner() + { + if (_pad == null) return true; + try { return _pad.Release(_owner!); } + catch (Exception ex) when (ex is ControllerOperationException or ObjectDisposedException) { return false; } + } + + internal bool KeyForOwner(int code, bool down) + { + try { return _pad?.StandardKey(_owner!, code, down) ?? false; } + catch (Exception ex) when (ex is ControllerOperationException or OperationCanceledException or ObjectDisposedException) { return false; } + } + + private sealed class Callbacks(MaaControllerBinding binding) : IMaaCustomController + { + public string Name { get; set; } = "EasyCon.Controller"; + public bool Connect() => binding.BindingReady; + public bool Connected() => binding.BindingReady; + public ControllerFeatures GetFeatures() => ControllerFeatures.UseKeyboardDownAndUpInsteadOfClick; + public bool RequestUuid(IMaaStringBuffer buffer) => buffer.TrySetValue("EasyCon.Binding/" + binding._bindingId); + public bool GetInfo(IMaaStringBuffer buffer) => buffer.TrySetValue(JsonSerializer.Serialize(new + { + inputConnected = binding._pad?.IsConnected, + captureConnected = binding._frames?.CaptureConnected, + bindingReady = binding.BindingReady, + frameReady = binding._frames?.FrameReady ?? false, + binding.RequiresInput, + binding.RequiresCapture, + inputEndpoint = binding._pad?.Endpoint, + inputEpoch = binding._owner?.Epoch, + sourceEpoch = binding._sourceEpoch, + sourceId = (binding._frames as FrameHub)?.SourceId, + })); + public bool Screencap(IMaaImageBuffer buffer) + { + if (binding._frames == null || !binding.BindingReady || !binding._frames.FrameReady) return false; + using FrameLease? frame = binding._frames.AcquireLatest(); + return frame != null && frame.Metadata.SourceEpoch == binding._sourceEpoch + && buffer.TrySetEncodedData(frame.EncodedPng.Span); + } + public bool Shell(string cmd, long millisecondsTimeout, IMaaStringBuffer buffer) + { + string requestId = ""; + ControllerReply reply; + try + { + ControllerCommand command = JsonSerializer.Deserialize(cmd, ControllerJson.Options) + ?? throw new ControllerOperationException("InvalidCommand", "空控制器命令"); + requestId = command.RequestId; + if (command.Protocol != "easycon.controller/1" || string.IsNullOrWhiteSpace(requestId)) + throw new ControllerOperationException("InvalidProtocol", "无效协议或 requestId"); + if (binding._pad == null) throw new ControllerOperationException("UnsupportedInput", "此绑定只提供截图"); + if (!binding._seenRequests.Add(requestId)) throw new ControllerOperationException("DuplicateRequest", "重复请求不会再次执行"); + binding._recentRequests.Enqueue(requestId); + if (binding._recentRequests.Count > 1024) binding._seenRequests.Remove(binding._recentRequests.Dequeue()); + reply = binding._pad.Execute(command, binding._owner!, binding._operationToken); + } + catch (OperationCanceledException) { reply = new(requestId, false, "Cancelled"); } + catch (ControllerOperationException ex) { reply = new(requestId, false, ex.ErrorCode, Value: ex.Message); } + catch (Exception ex) { reply = new(requestId, false, "CallbackFailure", Value: ex.Message); } + return buffer.TrySetValue(JsonSerializer.Serialize(reply, ControllerJson.Options)); + } + public bool Inactive() => binding.InactiveForOwner(); + private bool Key(int code, bool down) + { + return binding.KeyForOwner(code, down); + } + public bool KeyDown(int keycode) => Key(keycode, true); + public bool KeyUp(int keycode) => Key(keycode, false); + public bool ClickKey(int keycode) => false; + public bool TouchDown(int contact, int x, int y, int pressure) => false; + public bool TouchMove(int contact, int x, int y, int pressure) => false; + public bool TouchUp(int contact) => false; + public bool StartApp(string intent) => false; + public bool StopApp(string intent) => false; + public bool Click(int x, int y) => false; + public bool Swipe(int x1, int y1, int x2, int y2, int duration) => false; + public bool InputText(string text) => false; + public bool Scroll(int dx, int dy) => false; + public bool RelativeMove(int dx, int dy) => false; + public void Dispose() { } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaControllerSession.cs b/src/EasyCon.Core/Maa/MaaControllerSession.cs new file mode 100644 index 00000000..d711e464 --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaControllerSession.cs @@ -0,0 +1,242 @@ +#nullable enable +using EasyCon.Core.Capabilities; +using EasyCon.Core.Input; +using EasyDevice; +using EasyDevice.Connection; +using EasyScript; +using MaaFramework.Binding; + +namespace EasyCon.Core.Maa; + +/// 手柄模块公开门面;只接收输入配置,不引用采集模块。每个运行拥有独立 Maa 绑定。 +public sealed class MaaControllerSession : IControllerSession +{ + private readonly PadSession _pad; + private readonly object _transition = new(); + private readonly object _stateGate = new(); + private readonly object _lifecycleGate = new(); + private readonly ManualInputQueue _manualQueue = new(); + private MaaControllerBinding _manual; + private ControllerRunLease? _active; + private bool _transitioning; + private bool _disposed; + + public MaaControllerSession() : this(new PadSession()) { } + internal MaaControllerSession(NintendoSwitch device) : this(new PadSession(device)) { } + private MaaControllerSession(PadSession pad) + { + _pad = pad; + _manual = new MaaControllerBinding(_pad, _pad.CurrentOwner); + } + + public bool InputConnected => _pad.IsConnected; + public bool SupportsLegacyCommands => _pad.SupportsLegacyCommands; + public string? FirmwareName => _pad.FirmwareName; + public string? FirmwareVersion => _pad.FirmwareVersion; + public bool? ConsoleConnected => _pad.ConsoleConnected; + public string? LastConnectionError => _pad.LastConnectionError; + public string? InputEndpoint => _pad.Endpoint; + public long InputEpoch => _pad.InputEpoch; + public long ConnectionEpoch => _pad.ConnectionEpoch; + public bool ControllerConnected => _manual.BindingReady; + public bool IsConnected() => InputConnected; + public bool CanAcceptManualInput { get { lock (_stateGate) return !_disposed && !_transitioning && _active == null && InputConnected; } } + public CleanupStatus LastCleanup => _pad.LastCleanup; + public event LogHandler Log { add => _pad.Log += value; remove => _pad.Log -= value; } + public event BytesTransferedHandler BytesSent { add => _pad.BytesSent += value; remove => _pad.BytesSent -= value; } + public event BytesTransferedHandler BytesReceived { add => _pad.BytesReceived += value; remove => _pad.BytesReceived -= value; } + public event StatusChangedHandler? StatusChanged { add => _pad.StatusChanged += value; remove => _pad.StatusChanged -= value; } + public SwitchReport GetReport() => _pad.GetReport(); + + private void DrainActive(RunCancellationReason reason) + { + ControllerRunLease? run; + lock (_stateGate) { _transitioning = true; run = _active; } + if (run == null) return; + run.Cancel(reason); + run.Completion.GetAwaiter().GetResult(); + } + + private void RenewManual() + { + _manualQueue.Drain(); + InputOwner previous = _pad.CurrentOwner; + MaaControllerBinding previousBinding = _manual; + InputOwner owner = _pad.Transfer(null, default); + _manual = new MaaControllerBinding(_pad, owner); + previousBinding.Dispose(); + previous.Dispose(); + } + + public ControllerConnectResult TryConnect(string port) + { + lock (_lifecycleGate) + { + if (string.IsNullOrWhiteSpace(port)) return ControllerConnectResult.InvalidArgument; + DrainActive(RunCancellationReason.DeviceLost); + lock (_transition) + { + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + RenewManual(); + ControllerReply reply = _manual.Send(new() { Op = "device.connect", Port = port }, default); + ControllerConnectResult result = Enum.Parse(reply.Value!); + if (result == ControllerConnectResult.Success && + (_manual.Native.LinkStart().Wait() != MaaJobStatus.Succeeded || !_manual.IsInitialized)) + return ControllerConnectResult.Error; + return result; + } + catch (ControllerOperationException ex) when (ex.ErrorCode == "Busy") { return ControllerConnectResult.Busy; } + catch (ControllerOperationException ex) when (ex.ErrorCode == "CallbackFailure") + { + Logging.CoreLog.Warn($"手柄端点打开失败: {port}: {ex.Message}"); + return ControllerConnectResult.Error; + } + finally { lock (_stateGate) _transitioning = false; } + } + } + } + + public void Disconnect() + { + lock (_lifecycleGate) + { + DrainActive(RunCancellationReason.DeviceLost); + lock (_transition) + { + try + { + if (_disposed || _pad.Endpoint == null) return; + _manualQueue.Drain(); + _manual.Send(new() { Op = "device.disconnect" }, default); + RenewManual(); + } + finally { lock (_stateGate) _transitioning = false; } + } + } + } + + public ControllerRunLease BeginRun(string runId, CancellationToken token, long? expectedConnectionEpoch = null) + { + lock (_lifecycleGate) + lock (_transition) + { + ObjectDisposedException.ThrowIf(_disposed, this); + token.ThrowIfCancellationRequested(); + if (expectedConnectionEpoch != null && expectedConnectionEpoch != ConnectionEpoch) + throw new ControllerOperationException("InputChanged", "运行准备期间手柄连接已更换"); + lock (_stateGate) + { + if (_active != null || _transitioning) throw new ControllerOperationException("Busy", "busy: 设备已有运行或正在收尾"); + if (!InputConnected) throw new ControllerOperationException("InputDisconnected", "手柄未连接或中性报告尚未确认"); + _transitioning = true; + } + try + { + // 手动输入交接先写出中性包。旧句柄失效后销毁,不能释放新所有者的按键。 + _manualQueue.Drain(); + if ((_manual.HasNative || LastCleanup != CleanupStatus.Written) && !_manual.ReleaseInput()) + throw new ControllerOperationException("ReleaseUnconfirmed", "手动输入中性报告未确认"); + InputOwner previous = _pad.CurrentOwner; + InputOwner owner = _pad.Transfer(runId, token); + _manual.Dispose(); + previous.Dispose(); + ControllerRunLease run = new(this, owner, new MaaControllerBinding(_pad, owner)); + lock (_stateGate) _active = run; + return run; + } + finally { lock (_stateGate) _transitioning = false; } + } + } + + public IPadInput CreatePad(DelayType timing, ControllerRunLease? run = null) + { + if (run != null && run.Owner != this) throw new ControllerOperationException("InvalidOwner", "运行租约不属于此控制器"); + return new MaaPadInput(this, timing, run); + } + + internal ControllerReply Send(ControllerCommand command, ControllerRunLease? run = null, CancellationToken token = default) + { + if (run != null) + { + lock (_stateGate) + if (_active != run) throw new ControllerOperationException("StaleEpoch", "运行代次已失效"); + return run.Binding.Send(command, token); + } + lock (_transition) + { + lock (_stateGate) + if (_active != null || _transitioning) throw new ControllerOperationException("Busy", "busy: 设备正在运行或切换连接"); + _manualQueue.Drain(); + return _manual.Send(command, token); + } + } + + internal CleanupStatus EndRun(ControllerRunLease run) + { + run.Cancel(); + run.Binding.Stop(); + bool written = !run.Binding.HasNative && LastCleanup == CleanupStatus.Written || run.Binding.ReleaseInput(); + run.Binding.Dispose(); + lock (_transition) + { + InputOwner owner = _pad.Transfer(null, default); + _manual = new MaaControllerBinding(_pad, owner); + lock (_stateGate) _active = null; + run.InputOwner.Dispose(); + } + return written ? CleanupStatus.Written : CleanupStatus.ReleaseUnconfirmed; + } + + private void PostManual(ControllerCommand command) + { + if (!Monitor.TryEnter(_transition)) throw new ControllerOperationException("Busy", "设备正在切换输入所有者"); + try + { + if (!CanAcceptManualInput) throw new ControllerOperationException("Busy", "设备正由其他运行使用或尚未连接"); + _manualQueue.Post(_manual, command); + } + finally { Monitor.Exit(_transition); } + } + public void Down(ECKey key) => PostManual(new() { Op = "input.down", Key = ControllerKey.FromKey(key) }); + public void Up(ECKey key) => PostManual(new() { Op = "input.up", Key = ControllerKey.FromKey(key) }); + public void LeftDirection(DirectionKey key, bool down) => PostManual(new() { Op = "input.left", Direction = key, Down = down }); + public void RightDirection(DirectionKey key, bool down) => PostManual(new() { Op = "input.right", Direction = key, Down = down }); + public void HatDirection(DirectionKey key, bool down) => PostManual(new() { Op = "input.hat", Direction = key, Down = down }); + public void ApplyReport(SwitchReport report) => PostManual(new() { Op = "input.report", Report = ControllerReport.FromReport(report) }); + public void Reset() { if (CanAcceptManualInput) PostManual(new() { Op = "input.reset" }); } + public bool Flash(byte[] bytes) => Command("device.flash", data: bytes); + public bool RemoteStart() => Command("device.start"); + public bool RemoteStop() => Command("device.stop"); + public int GetVersion() => int.Parse(Send(new() { Op = "device.version" }).Value!); + public bool UnPair() => Command("device.unpair"); + public bool ChangeAmiiboIndex(byte index) => Command("amiibo.index", index); + public bool ChangeControllerMode(byte mode) => Command("device.mode", mode); + public bool ChangeControllerColor(byte[] color) => Command("device.color", data: color); + public bool SaveAmiibo(byte index, byte[] data) => Command("amiibo.save", index, data); + public void StartRecord() => Command("record.start"); + public void StopRecord() => Command("record.stop"); + public void PauseRecord() => Command("record.pause"); + public string GetRecordScript() => Send(new() { Op = "record.script" }).Value ?? ""; + private bool Command(string op, byte index = 0, byte[]? data = null) + => Send(new() { Op = op, Index = index, Data = data?.ToArray() }).Accepted; + + public void Dispose() + { + lock (_lifecycleGate) + { + if (_disposed) return; + DrainActive(RunCancellationReason.HostShutdown); + Disconnect(); + lock (_transition) + { + if (_disposed) return; + _manualQueue.Dispose(); + _manual.Dispose(); + _pad.Dispose(); + _disposed = true; + } + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaEnvironment.cs b/src/EasyCon.Core/Maa/MaaEnvironment.cs new file mode 100644 index 00000000..1ca85a96 --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaEnvironment.cs @@ -0,0 +1,56 @@ +#nullable enable +using MaaFramework.Binding; + +namespace EasyCon.Core.Maa; + +/// 固定版本原生运行环境;只在第一次 Maa 操作时加载。 +public static class MaaEnvironment +{ + private static readonly Lazy Root = new(() => + { + string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "EasyCon", "maa", "5.14.3"); + string bootstrap = Path.Combine(root, "host-bootstrap-v1", "pipeline"); + Directory.CreateDirectory(bootstrap); + string temporary = Path.Combine(bootstrap, Guid.NewGuid().ToString("N") + ".tmp"); + File.WriteAllText(temporary, "{\"EasyCon.HostReady\":{\"recognition\":\"DirectHit\",\"action\":\"DoNothing\"}}"); + File.Move(temporary, Path.Combine(bootstrap, "host.json"), true); +#pragma warning disable CS0618 + string version = MaaGlobal.Shared.Version; + if (version.TrimStart('v') != "5.14.3") throw new InvalidOperationException($"Maa runtime 版本不符: {version},要求 5.14.3"); + MaaGlobal.Shared.SetOption(GlobalOption.LogDir, Path.Combine(root, "logs")); + MaaGlobal.Shared.SetOption(GlobalOption.StdoutLevel, 0); +#pragma warning restore CS0618 + return root; + }); + + public static string CacheDirectory => Root.Value; + public static string BootstrapResource => Path.Combine(CacheDirectory, "host-bootstrap-v1"); + internal static MaaResource LoadBootstrap(string? resourceDirectory = null) + { + _ = CacheDirectory; + MaaResource resource = new(); + try + { + // 空宿主节点不使用推理。避免每个短运行触发自动 GPU 探测/设备初始化。 + if (!resource.SetOption(ResourceOption.InferenceDevice, InferenceDevice.CPU) || + !resource.SetOption(ResourceOption.InferenceExecutionProvider, InferenceExecutionProvider.CPU)) + throw new InvalidOperationException("Maa bootstrap CPU 配置失败"); + string path = resourceDirectory ?? BootstrapResource; + if (resource.AppendBundle(path).Wait() != MaaJobStatus.Succeeded || !resource.IsLoaded) + throw new InvalidDataException($"Maa 宿主资源加载失败: {path}"); + return resource; + } + catch { resource.Dispose(); throw; } + } + public static string RuntimeVersion + { + get + { + _ = CacheDirectory; +#pragma warning disable CS0618 + return MaaGlobal.Shared.Version; +#pragma warning restore CS0618 + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaHostDiagnostics.cs b/src/EasyCon.Core/Maa/MaaHostDiagnostics.cs new file mode 100644 index 00000000..49a2cd6f --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaHostDiagnostics.cs @@ -0,0 +1,59 @@ +#nullable enable +using EasyCon.Core.Capabilities; +using MaaFramework.Binding; +using MaaFramework.Binding.Buffers; +using OpenCvSharp; +using System.Diagnostics; + +namespace EasyCon.Core.Maa; + +public sealed record MaaRuntimeCheck(string Runtime, string? Binding, bool ResourceLoaded, + bool TaskerInitialized, string RecognitionStatus, bool PixelRoundTrip, IReadOnlyDictionary NativeLibraries); +public sealed record MaaOcrRuntimeCheck(string ModelGeneration, string Expected, string Actual, double RawConfidence, + int LegacyConfidence, string Compatibility); + +/// 无硬件的发行目录自检。加载真实原生资源、显式图像识别与两套 OpenCV 的 PNG 桥接。 +public static class MaaHostDiagnostics +{ + public static MaaOcrRuntimeCheck CheckOcr(string profilePath) + { + MaaOcrProfile profile = MaaOcrProfile.Load(profilePath); + using MaaOcrService ocr = new(profile); + if (!ocr.Init(new OcrConfig { Language = "eng" })) throw new InvalidOperationException("中英试点 OCR 模型初始化失败"); + using Mat image = new(64, 256, MatType.CV_8UC3, Scalar.All(255)); + Cv2.PutText(image, "12345", new Point(12, 46), HersheyFonts.HersheySimplex, 1.3, Scalar.Black, 2, LineTypes.AntiAlias); + string actual = ocr.Recognize(ImageRef.FromBase64(Convert.ToBase64String(image.ToBytes(".png"))), new OcrQuery { Language = "eng" }); + if (actual != "12345" || ocr.LastConfidence <= 0) throw new InvalidOperationException($"OCR 显式图片自检失败: [{actual}], confidence={ocr.LastConfidence}"); + return new(profile.Digest, "12345", actual, ocr.LastRawConfidence, ocr.LastConfidence, profile.Compatibility); + } + + public static MaaRuntimeCheck CheckRuntime() + { + string version = MaaEnvironment.RuntimeVersion; + using MaaResource resource = MaaEnvironment.LoadBootstrap(); + // binding 的 required 属性通过官方 Null 哨兵表达“不绑定控制器”,不创建假硬件句柄。 + using MaaTasker tasker = new() { Resource = resource, Controller = MaaController.Null, DisposeOptions = DisposeOptions.None }; + if (!tasker.IsInitialized) throw new InvalidOperationException("无控制器的 Maa Tasker 未初始化"); + using Mat source = new(18, 32, MatType.CV_8UC3, new Scalar(7, 51, 209)); + using MaaImageBuffer buffer = new(); + if (!buffer.TrySetEncodedData(source.ToBytes(".png")) || !buffer.TryGetEncodedData(out byte[]? png) || png == null) + throw new InvalidDataException("Maa PNG 图像桥接失败"); + using Mat roundTrip = Cv2.ImDecode(png, ImreadModes.Color); + bool equal = source.Size() == roundTrip.Size() && Cv2.Norm(source, roundTrip) == 0; + if (!equal) throw new InvalidDataException("Maa/OpenCvSharp PNG 往返像素不一致"); + MaaJobStatus status = tasker.AppendRecognition("DirectHit", "{}", buffer).Wait(); + if (status != MaaJobStatus.Succeeded) throw new InvalidOperationException($"Maa 显式图片 DirectHit 自检失败: {status}"); + tasker.ClearCache(); + Dictionary libraries = new(); + if (OperatingSystem.IsWindows()) + { + using Process process = Process.GetCurrentProcess(); + foreach (ProcessModule module in process.Modules) + if (module.ModuleName.StartsWith("Maa", StringComparison.OrdinalIgnoreCase) || + module.ModuleName.Contains("OpenCvSharpExtern", StringComparison.OrdinalIgnoreCase)) + libraries[module.ModuleName] = module.FileName; + } + return new(version, typeof(MaaCustomController).Assembly.GetName().Version?.ToString(), + resource.IsLoaded, tasker.IsInitialized, status.ToString(), equal, libraries); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaHostSession.cs b/src/EasyCon.Core/Maa/MaaHostSession.cs new file mode 100644 index 00000000..b28ead9a --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaHostSession.cs @@ -0,0 +1,22 @@ +#nullable enable +namespace EasyCon.Core.Maa; + +/// 应用级组合根。采集与输入独立连接,GUI/CLI/Flow 共用相同宿主库。 +public sealed class MaaHostSession : IDisposable +{ + public FrameHub Frames { get; } = new(); + public IControllerSession Controller { get; } + public HostRunCoordinator Runs { get; } + + public MaaHostSession() + { + Controller = new MaaControllerSession(); + Runs = new HostRunCoordinator(Controller, Frames); + } + + public void Dispose() + { + Controller.Dispose(); + Frames.Dispose(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaLabelConverter.cs b/src/EasyCon.Core/Maa/MaaLabelConverter.cs new file mode 100644 index 00000000..67bfef6b --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaLabelConverter.cs @@ -0,0 +1,265 @@ +#nullable enable +using EasyCon.Capture; +using MaaFramework.Binding; +using OpenCvSharp; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EasyCon.Core.Maa; + +public sealed record MaaLabelEntry(string Name, string Source, string SourceDigest, string SourceJson, SearchMethod Method, + int[] Roi, string? TemplateDigest, string? NativeNode, string? NativeType, string? NativeParameters, + string CompatNode, string CompatType, string CompatParameters, string RoutingPolicy, + string ScoreTransform, string Rounding, string Preprocessing, string ReplayCoverage = "pending", string? Error = null) +{ + public ImgLabel ReadLabel() + { + ImgLabel label = JsonSerializer.Deserialize(SourceJson) ?? throw new InvalidDataException("标签内容为空"); + label.name = Name; + return label; + } +} + +public sealed record LabelConversionReport(string Converter, int TotalFiles, int SelectedLabels, int ConvertedLabels, + IReadOnlyList Diagnostics) +{ + public int ErrorCount => Diagnostics.Count(item => item.Severity == "error"); +} + +public sealed record LabelGeneration(string Id, string Directory, LegacyCompatibilityProfile Profile, + IReadOnlyDictionary Labels, LabelConversionReport Report) +{ + public void Preflight(IEnumerable requiredLabels) + { + foreach (string name in requiredLabels) + if (!Labels.TryGetValue(name, out MaaLabelEntry? label) || label.Error != null) + throw new InvalidDataException($"标签 [{name}] 资源预检失败: {label?.Error ?? "没有该标签"}"); + } +} + +public sealed record LabelConversionResult(LabelGeneration? Generation, LabelConversionReport Report, bool Published); + +/// 只写派生资源。临时版本通过真实 Maa 装载后才发布,失败保留原 current.json。 +public static class MaaLabelConverter +{ + public const string Version = "easycon.labels/3"; + private static readonly object PublicationGate = new(); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static string ScoreCondition(MaaLabelEntry entry, string comparison, int threshold, LegacyCompatibilityProfile profile) + { + if (!Enum.IsDefined(profile)) throw new InvalidDataException($"未知兼容 profile {profile}"); + _ = LegacyAlgorithmAdapter.Compare(0, comparison, threshold); + return JsonSerializer.Serialize(new + { + custom_recognition = "EasyCon.LegacyScoreCondition", + roi = entry.Roi, + custom_recognition_param = new { label_id = entry.Name, comparison, threshold, compatibility = profile.ToString() }, + }); + } + + public static string CacheRoot(string projectDirectory) + { + string key = Digest(Encoding.UTF8.GetBytes(Path.GetFullPath(projectDirectory))); + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EasyCon", "maa-cache", key); + } + + public static LabelConversionResult Build(LabelDiscovery discovery, string cacheRoot, LegacyCompatibilityProfile profile, + bool strict = false, string normalizationId = "BGR8/1920x1080", string modelDigest = "none", MaaOcrProfile? ocrProfile = null) + { + lock (PublicationGate) + { + if (!Enum.IsDefined(profile)) throw new InvalidDataException($"未知兼容 profile {profile}"); + List diagnostics = [.. discovery.Diagnostics]; + Dictionary entries = new(StringComparer.Ordinal); + Dictionary images = new(StringComparer.Ordinal); + JsonObject nativeNodes = new(), compatNodes = new(); + foreach (LegacyLabelSource source in discovery.Selected) + { + ImgLabel label = source.Read(); + string identity = Digest(Encoding.UTF8.GetBytes(source.Name)); + string nativeName = "EC.Legacy.Native." + identity; + string compatName = "EC.Legacy.Compat." + identity; + int[] roi = [label.RangeX, label.RangeY, label.RangeWidth, label.RangeHeight]; + string compatParameters = JsonSerializer.Serialize(new { custom_recognition = "EasyCon.LegacyTemplateMatch", custom_recognition_param = new { label_id = label.name }, roi }); + try + { + ValidateRoi(label); + if (label.ImgBase64 == null) throw new InvalidDataException("模板或文本内容为 null"); + if (label.searchMethod == SearchMethod.TesserDetect) + { + MaaOcrModel model = (ocrProfile ?? throw new NotSupportedException("文字标签需要显式 OCR 模型 profile")) + .Resolve(new Capabilities.OcrConfig { Language = "chi_sim" }); + string ocrParameters = JsonSerializer.Serialize(new { roi, model = model.Id, only_rec = model.OnlyRecognition, order_by = model.OrderBy, threshold = 0 }); + nativeNodes[nativeName] = Node("OCR", JsonNode.Parse(ocrParameters)!.AsObject()); + compatNodes[compatName] = Node("Custom", JsonNode.Parse(compatParameters)!.AsObject()); + entries.Add(source.Name, new(source.Name, source.Path, source.Digest, source.Content, label.searchMethod, roi, null, + nativeName, "OCR", ocrParameters, compatName, "Custom", compatParameters, "ocr-profile-pilot", + "100*text-similarity*confidence", profile == LegacyCompatibilityProfile.LegacyCli ? "Ceiling" : "Truncate", "legacy-text-trim/1")); + continue; + } + if (label.searchMethod is not (SearchMethod.SqDiffNormed or SearchMethod.CCorrNormed or SearchMethod.CCoeffNormed or + SearchMethod.MaskedSqDiffNormed or SearchMethod.EdgeDetectXY or SearchMethod.EdgeDetectLaplacian)) + throw new NotSupportedException($"不支持算法 {label.searchMethod}"); + if (label.TargetWidth > label.RangeWidth || label.TargetHeight > label.RangeHeight) + throw new InvalidDataException("声明的目标区域大于搜索范围"); + using Mat original = Cv2.ImDecode(Convert.FromBase64String(label.ImgBase64), ImreadModes.Unchanged); + if (original.Empty() || original.Depth() != MatType.CV_8U) + throw new InvalidDataException("模板为空或位深不是 8 位"); + if (original.Width > roi[2] || original.Height > roi[3]) + throw new InvalidDataException($"实际模板 {original.Width}×{original.Height} 大于 ROI {roi[2]}×{roi[3]}"); + using Mat color = new(); + if (original.Channels() == 4) Cv2.CvtColor(original, color, ColorConversionCodes.BGRA2BGR); + else if (original.Channels() == 1) Cv2.CvtColor(original, color, ColorConversionCodes.GRAY2BGR); + else if (original.Channels() == 3) original.CopyTo(color); + else throw new InvalidDataException("模板通道数不支持"); + bool mask = label.searchMethod == SearchMethod.MaskedSqDiffNormed; + bool nativeAvailable = label.searchMethod is SearchMethod.SqDiffNormed or SearchMethod.CCorrNormed or SearchMethod.CCoeffNormed; + if (mask) + { + if (original.Channels() != 4) throw new InvalidDataException("透明匹配需要四通道模板"); + bool visible = false, collision = false; + int height = original.Height, width = original.Width; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + Vec4b pixel = original.At(y, x); + if (pixel.Item3 == 0) color.Set(y, x, new Vec3b(0, 255, 0)); + else + { + visible = true; + collision |= pixel.Item0 == 0 && pixel.Item1 == 255 && pixel.Item2 == 0; + } + } + if (!visible) throw new InvalidDataException("模板完全透明"); + nativeAvailable = !collision; + if (collision) diagnostics.Add(new("warning", "green-mask-collision", source.Path, source.Name, "不导出有损的原生 green_mask 节点;使用兼容识别")); + } + byte[] png = color.ToBytes(".png"); + string imageDigest = Digest(png); + images.TryAdd(imageDigest, png); + JsonObject parameters = new() + { + ["template"] = "legacy/" + imageDigest + ".png", + ["roi"] = JsonSerializer.SerializeToNode(roi), + ["method"] = mask ? 1 : (int)label.searchMethod, + ["green_mask"] = mask, + ["threshold"] = label.searchMethod is SearchMethod.SqDiffNormed or SearchMethod.MaskedSqDiffNormed ? 1e12 : -1.1, + ["order_by"] = "Score", + }; + string route = mask ? "mask-semantics" : nativeAvailable ? "native-with-integer-verification" : "preprocess-semantics"; + if (nativeAvailable) nativeNodes[nativeName] = Node("TemplateMatch", parameters); + JsonObject compat = JsonNode.Parse(compatParameters)!.AsObject(); + compatNodes[compatName] = Node("Custom", compat); + entries.Add(source.Name, new(source.Name, source.Path, source.Digest, source.Content, label.searchMethod, roi, imageDigest, + nativeAvailable ? nativeName : null, nativeAvailable ? "TemplateMatch" : null, nativeAvailable ? parameters.ToJsonString() : null, + compatName, "Custom", compatParameters, route, + label.searchMethod is SearchMethod.SqDiffNormed or SearchMethod.MaskedSqDiffNormed ? "100*(1-r)" : label.searchMethod == SearchMethod.CCorrNormed ? "100*r" : "50*(r+1)", + profile == LegacyCompatibilityProfile.LegacyCli ? "Ceiling" : "Truncate", "legacy/1")); + } + catch (Exception ex) when (ex is FormatException or InvalidDataException or OpenCVException or NotSupportedException) + { + diagnostics.Add(new("error", "invalid-label", source.Path, source.Name, ex.Message)); + entries.Add(source.Name, new(source.Name, source.Path, source.Digest, source.Content, label.searchMethod, roi, null, null, null, null, + compatName, "Custom", compatParameters, "invalid", "unavailable", profile.ToString(), "legacy/1", Error: ex.Message)); + } + } + LabelConversionReport report = new(Version, discovery.TotalFiles, entries.Count, entries.Values.Count(entry => entry.Error == null), diagnostics); + if (strict && report.ErrorCount != 0) return new(null, report, false); + string signature = JsonSerializer.Serialize(new + { + converter = Version, + runtime = "5.14.3", + binding = "5.10.0", + nativeLoading = "short-bundle+managed-images/1", + profile, + normalizationId, + modelDigest = ocrProfile?.Digest ?? modelDigest, + // 来源路径也持久化在不可变索引中,迁移目录必须得到新的 generation。 + sources = discovery.Selected.Select(source => new { source.Name, source.Path, source.Digest, source.Priority }), + diagnostics, + }); + string id = Digest(Encoding.UTF8.GetBytes(signature)); + string root = Path.GetFullPath(cacheRoot); + Directory.CreateDirectory(root); + string destination = Path.Combine(root, id); + string indexJson = JsonSerializer.Serialize(entries, JsonOptions); + string reportJson = JsonSerializer.Serialize(report, JsonOptions); + bool hasOcr = entries.Values.Any(entry => entry.Method == SearchMethod.TesserDetect && entry.Error == null); + if (Directory.Exists(destination)) + { + if (File.ReadAllText(Path.Combine(destination, "easycon", "manifest.json")) != signature || + images.Any(image => Digest(File.ReadAllBytes(Path.Combine(destination, "image", "legacy", image.Key + ".png"))) != image.Key) || + File.ReadAllText(Path.Combine(destination, "pipeline", "legacy.native.json")) != nativeNodes.ToJsonString(JsonOptions) || + File.ReadAllText(Path.Combine(destination, "pipeline", "legacy.compat.json")) != compatNodes.ToJsonString(JsonOptions) || + File.ReadAllText(Path.Combine(destination, "easycon", "labels.index.json")) != indexJson || + File.ReadAllText(Path.Combine(destination, "easycon", "conversion.report.json")) != reportJson) + throw new InvalidDataException("已生成的标签缓存已损坏,请清理该缓存版本后重建"); + if (hasOcr) ocrProfile!.VerifyModels(destination); + MaaRecognitionSession.ValidateResource(destination, entries); + } + if (!Directory.Exists(destination)) + { + string stage = Path.Combine(root, ".building-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(stage, "pipeline")); + Directory.CreateDirectory(Path.Combine(stage, "image", "legacy")); + Directory.CreateDirectory(Path.Combine(stage, "easycon")); + try + { + foreach ((string digest, byte[] png) in images) File.WriteAllBytes(Path.Combine(stage, "image", "legacy", digest + ".png"), png); + File.WriteAllText(Path.Combine(stage, "pipeline", "legacy.native.json"), nativeNodes.ToJsonString(JsonOptions)); + File.WriteAllText(Path.Combine(stage, "pipeline", "legacy.compat.json"), compatNodes.ToJsonString(JsonOptions)); + File.WriteAllText(Path.Combine(stage, "pipeline", "bootstrap.json"), "{\"EC.Legacy.HostReady\":{\"recognition\":\"DirectHit\",\"action\":\"DoNothing\"}}"); + File.WriteAllText(Path.Combine(stage, "easycon", "labels.index.json"), indexJson); + File.WriteAllText(Path.Combine(stage, "easycon", "conversion.report.json"), reportJson); + File.WriteAllText(Path.Combine(stage, "easycon", "manifest.json"), signature); + if (ocrProfile != null && hasOcr) + { + string models = ocrProfile.Materialize(); + foreach (MaaOcrModel model in ocrProfile.Models) + { + string target = Path.Combine(stage, "model", "ocr", model.Id); + Directory.CreateDirectory(target); + foreach (string file in model.Sha256.Keys) File.Copy(Path.Combine(models, "model", "ocr", model.Id, file), Path.Combine(target, file)); + } + ocrProfile.VerifyModels(stage); + } + MaaRecognitionSession.ValidateResource(stage, entries); + Directory.Move(stage, destination); + } + finally { if (Directory.Exists(stage)) Directory.Delete(stage, true); } + } + string current = Path.Combine(root, "current-" + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + File.WriteAllText(current, JsonSerializer.Serialize(new { generation = id, compatibility = profile.ToString() })); + File.Move(current, Path.Combine(root, "current.json"), true); + } + finally { if (File.Exists(current)) File.Delete(current); } + GenerationLease.Prune(root, id, 8); + return new(new(id, destination, profile, entries, report), report, true); + } + } + + private static JsonObject Node(string type, JsonObject parameters) + { + JsonObject result = (JsonObject)parameters.DeepClone(); + result["recognition"] = type; + result["action"] = "DoNothing"; + result["pre_delay"] = 0; + result["post_delay"] = 0; + return result; + } + + private static void ValidateRoi(ImgLabel label) + { + if (label.RangeX < 0 || label.RangeY < 0 || label.RangeWidth <= 0 || label.RangeHeight <= 0 || + (long)label.RangeX + label.RangeWidth > FrameNormalization.Width || + (long)label.RangeY + label.RangeHeight > FrameNormalization.Height) + throw new InvalidDataException("ROI 必须位于 1920×1080 规范图像范围内"); + } + + private static string Digest(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaModelInstaller.cs b/src/EasyCon.Core/Maa/MaaModelInstaller.cs new file mode 100644 index 00000000..3bd89cb3 --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaModelInstaller.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +/// 显式安装经摘要固定的官方中英试点模型;普通运行不访问网络。 +public static class MaaModelInstaller +{ + public const string ModelSource = "https://raw.githubusercontent.com/MaaXYZ/MaaCommonAssets/dabcd4681ac990dc4361de26416d986abd80e4aa/OCR/ppocr_v4/zh_cn/"; + public static IReadOnlyDictionary ModelHashes { get; } = new Dictionary + { + ["det.onnx"] = "767e663dded50813dca0abc9a49859e345e0f74a48b387661188f096a9211d74", + ["rec.onnx"] = "da4815ea386ee8083be319da8d20e70f4fdb0b6404ba0d2da7f675831e1b37fa", + ["keys.txt"] = "28b2362ad4ab2dc38769aa72feb535e3a9ddb3fd2a7585a05920e6393b1dc7f7", + }; + + public static async Task InstallAsync(string directory, CancellationToken token) + { + string root = Path.GetFullPath(directory); + string destination = Path.Combine(root, "model", "ocr", "ppocr-v4-zh"); + Directory.CreateDirectory(destination); + using HttpClient http = new() { Timeout = TimeSpan.FromMinutes(3) }; + foreach ((string name, string hash) in ModelHashes) + { + string target = Path.Combine(destination, name); + if (File.Exists(target) && Convert.ToHexStringLower(SHA256.HashData(await File.ReadAllBytesAsync(target, token))) == hash) continue; + byte[] bytes = await http.GetByteArrayAsync(ModelSource + name, token); + if (Convert.ToHexStringLower(SHA256.HashData(bytes)) != hash) throw new InvalidDataException($"模型 {name} 摘要不符"); + string temporary = target + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { await File.WriteAllBytesAsync(temporary, bytes, token); File.Move(temporary, target, true); } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + MaaOcrProfile profile = new() + { + Resource = ".", + Compatibility = "pilot-unverified", + Models = [new() { Id = "ppocr-v4-zh", Languages = ["chi_sim", "eng"], PathAliases = ["Tessdata"], Sha256 = new(ModelHashes) }], + }; + string path = Path.Combine(root, "ocr-profile.json"); + if (!File.Exists(path)) + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(profile, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }), token); + else _ = MaaOcrProfile.Load(path); + await File.WriteAllTextAsync(Path.Combine(root, "MODEL-SOURCE.txt"), + "PP-OCR v4 Chinese/English (PaddleOCR / Apache-2.0), distributed by MaaXYZ/MaaCommonAssets (MIT).\n" + ModelSource + + "\nThis is a pilot model; text and confidence equivalence with legacy Tesseract is not asserted.\n", token); + foreach ((string name, string url) in new[] + { + ("MAA-ASSETS-LICENSE.txt", "https://raw.githubusercontent.com/MaaXYZ/MaaCommonAssets/dabcd4681ac990dc4361de26416d986abd80e4aa/LICENSE"), + ("PADDLE-OCR-LICENSE.txt", "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/v2.7.3/LICENSE"), + }) + if (!File.Exists(Path.Combine(root, name))) + await File.WriteAllTextAsync(Path.Combine(root, name), await http.GetStringAsync(url, token), token); + return path; + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaOcrProfile.cs b/src/EasyCon.Core/Maa/MaaOcrProfile.cs new file mode 100644 index 00000000..090711bc --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaOcrProfile.cs @@ -0,0 +1,130 @@ +#nullable enable +using EasyCon.Core.Capabilities; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EasyCon.Core.Maa; + +public sealed record MaaOcrModel +{ + public string Id { get; init; } = ""; + public string[] Languages { get; init; } = []; + public string[] PathAliases { get; init; } = []; + public string[] EngineModes { get; init; } = ["DEFAULT"]; + public string[] PageModes { get; init; } = ["SINGLE_LINE"]; + public bool OnlyRecognition { get; init; } = true; + public string OrderBy { get; init; } = "Vertical"; + public Dictionary Sha256 { get; init; } = new(StringComparer.Ordinal); +} + +/// 显式迁移映射;traineddata 路径仅作别名,绝不当作 Maa 模型加载。 +public sealed class MaaOcrProfile +{ + public string Schema { get; init; } = "easycon.maa-ocr/1"; + public string Resource { get; init; } = ""; + public string DefaultLanguage { get; init; } = "chi_sim"; + public string Compatibility { get; init; } = "pilot-unverified"; + public MaaOcrModel[] Models { get; init; } = []; + [JsonIgnore] public string Digest { get; private set; } = ""; + [JsonIgnore] public string SourcePath { get; private set; } = ""; + + public static MaaOcrProfile Load(string path) + { + string fullPath = Path.GetFullPath(path); + string json = File.ReadAllText(fullPath); + MaaOcrProfile profile = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("OCR profile 为空"); + if (profile.Schema != "easycon.maa-ocr/1" || profile.Models.Length == 0) + throw new InvalidDataException("OCR profile schema 或模型列表无效"); + profile.SourcePath = fullPath; + profile.Digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(json))); + foreach (MaaOcrModel model in profile.Models) + { + if (model.Id.Length == 0 || model.Id != Path.GetFileName(model.Id) || model.Id is "." or ".." || + model.Id.IndexOfAny(['/', '\\', ':']) >= 0 || model.Id.Any(char.IsControl) || + model.Languages.Length == 0 || model.PageModes.Length == 0 || model.EngineModes.Length == 0 || + model.OrderBy is not ("Horizontal" or "Vertical" or "Area" or "Length" or "Score" or "Random")) + throw new InvalidDataException("OCR 模型 ID、语言或 PSM 映射无效"); + if (model.Sha256.Keys.Any(file => file is not ("det.onnx" or "rec.onnx" or "keys.txt"))) + throw new InvalidDataException("OCR 模型文件名无效"); + foreach (string file in new[] { "det.onnx", "rec.onnx", "keys.txt" }) + if (!model.Sha256.TryGetValue(file, out string? digest) || digest.Length != 64 || !digest.All(char.IsAsciiHexDigit)) + throw new InvalidDataException($"OCR 模型 [{model.Id}] 缺少 {file} 的 SHA-256"); + } + return profile; + } + + public MaaOcrModel Resolve(OcrConfig config) + { + if (config.Options.Keys.Any(key => key is not ("tess:engineMode" or "tess:psmode"))) + throw new InvalidDataException("OCR profile 未声明该选项的等价映射"); + string language = string.IsNullOrEmpty(config.Language) ? DefaultLanguage : config.Language; + string engine = config.Options.GetValueOrDefault("tess:engineMode", "DEFAULT"); + string psm = config.Options.GetValueOrDefault("tess:psmode", "SINGLE_LINE"); + MaaOcrModel[] matches = Models.Where(model => model.Languages.Contains(language, StringComparer.OrdinalIgnoreCase) && + model.EngineModes.Contains(engine, StringComparer.OrdinalIgnoreCase) && model.PageModes.Contains(psm, StringComparer.OrdinalIgnoreCase) && + (string.IsNullOrEmpty(config.ModelPath) || model.PathAliases.Any(alias => + string.Equals(alias, config.ModelPath, StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, Path.GetFileName(config.ModelPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)), StringComparison.OrdinalIgnoreCase)))) + .ToArray(); + return matches.Length == 1 ? matches[0] : throw new InvalidDataException($"OCR 配置没有唯一模型映射: lang={language}, engine={engine}, psm={psm}, path={config.ModelPath}"); + } + + [JsonIgnore] public string ResourceDirectory => Path.GetFullPath(Resource, Path.GetDirectoryName(SourcePath)!); + + /// 复制经校验的模型到不可变版本,避免运行中编辑模型污染当前会话。 + public string Materialize() + { + string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EasyCon", "maa-ocr-cache"); + string destination = Path.Combine(root, Digest); + lock (MaterializationGate) + { + foreach (MaaOcrModel model in Models) + foreach ((string file, string hash) in model.Sha256) + { + if (file is not ("det.onnx" or "rec.onnx" or "keys.txt")) throw new InvalidDataException("OCR 模型文件名无效"); + string path = Path.Combine(ResourceDirectory, "model", "ocr", model.Id, file); + if (!File.Exists(path) || !string.Equals(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))), hash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"OCR 模型缺失或摘要不符: {path}"); + } + if (!Directory.Exists(destination)) + { + Directory.CreateDirectory(root); + string stage = Path.Combine(root, ".building-" + Guid.NewGuid().ToString("N")); + try + { + foreach (MaaOcrModel model in Models) + { + string target = Path.Combine(stage, "model", "ocr", model.Id); + Directory.CreateDirectory(target); + foreach (string file in model.Sha256.Keys) File.Copy(Path.Combine(ResourceDirectory, "model", "ocr", model.Id, file), Path.Combine(target, file)); + foreach ((string file, string hash) in model.Sha256) + if (!string.Equals(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(Path.Combine(target, file)))), hash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("OCR 模型在复制期间发生变化"); + } + Directory.CreateDirectory(Path.Combine(stage, "pipeline")); + File.WriteAllText(Path.Combine(stage, "pipeline", "bootstrap.json"), "{\"EC.Ocr.Ready\":{\"recognition\":\"DirectHit\",\"action\":\"DoNothing\"}}"); + Directory.Move(stage, destination); + } + finally { if (Directory.Exists(stage)) Directory.Delete(stage, true); } + } + VerifyModels(destination); + return destination; + } + } + + private static readonly object MaterializationGate = new(); + + internal void VerifyModels(string directory) + { + foreach (MaaOcrModel model in Models) + foreach ((string file, string hash) in model.Sha256) + { + string path = Path.Combine(directory, "model", "ocr", model.Id, file); + if (!File.Exists(path) || !string.Equals(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))), hash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"OCR 派生模型缓存缺失或已损坏: {path}"); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaOcrService.cs b/src/EasyCon.Core/Maa/MaaOcrService.cs new file mode 100644 index 00000000..b1becd3c --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaOcrService.cs @@ -0,0 +1,112 @@ +#nullable enable +using EasyCon.Core.Capabilities; +using EasyCon.Core.Logging; +using OpenCvSharp; +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +/// 显式模型 profile 的 Maa OCR 试点;已有图像不借用采集或输入,旧失败返回契约不变。 +public sealed class MaaOcrService(MaaOcrProfile profile, CancellationToken token = default) : IOcrService +{ + private readonly object _gate = new(); + private readonly Dictionary _initialized = new(StringComparer.OrdinalIgnoreCase); + private MaaRecognitionSession? _session; + private bool _disposed; + private static int _activeModelResources; + public string Backend => "maa"; + public int LastConfidence { get; private set; } + public double LastRawConfidence { get; private set; } + public string ModelGeneration => profile.Digest; + public string Compatibility => profile.Compatibility; + public static int ActiveModelResources => Volatile.Read(ref _activeModelResources); + public MaaOcrService CreateInvocation(CancellationToken cancellationToken) => new(profile, cancellationToken); + + public bool Init(OcrConfig config) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + token.ThrowIfCancellationRequested(); + string language = string.IsNullOrEmpty(config.Language) ? profile.DefaultLanguage : config.Language; + try + { + MaaOcrModel model = profile.Resolve(config); + if (_session == null) + { + _session = new(profile.Materialize()); + Interlocked.Increment(ref _activeModelResources); + } + _initialized[language] = model; + return true; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _initialized.Remove(language); + CoreLog.Error($"Maa OCR 初始化失败: {ex.Message}"); + return false; + } + } + } + + public string Recognize(ImageRef image, OcrQuery query) => Recognize(image, query, default); + + public string Recognize(ImageRef image, OcrQuery query, CancellationToken operationToken) + { + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(token, operationToken); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellation.Token.ThrowIfCancellationRequested(); + try + { + using Mat source = Cv2.ImDecode(Convert.FromBase64String(image.Base64), ImreadModes.Color); + if (source.Empty()) return ""; + int x = Math.Clamp(query.X, 0, source.Width), y = Math.Clamp(query.Y, 0, source.Height); + int width = query.Width > 0 ? Math.Clamp(query.Width, 0, source.Width - x) : source.Width - x; + int height = query.Height > 0 ? Math.Clamp(query.Height, 0, source.Height - y) : source.Height - y; + if (width == 0 || height == 0) return "OCR ARGS ERR!"; + string language = string.IsNullOrEmpty(query.Language) ? profile.DefaultLanguage : query.Language; + if (!_initialized.TryGetValue(language, out MaaOcrModel? model)) + { + if (!Init(new OcrConfig { Language = language })) return ""; + model = _initialized[language]; + } + using Mat roi = new(source, new Rect(x, y, width, height)); + string parameters = JsonSerializer.Serialize(new { model = model.Id, only_rec = model.OnlyRecognition, threshold = 0, order_by = model.OrderBy }); + MaaRecognitionResult result = _session!.RecognizeAsync(new("OCR", parameters, roi.ToBytes(".png"), Guid.NewGuid().ToString("N"), null, profile.Digest), cancellation.Token).GetAwaiter().GetResult(); + using JsonDocument detail = JsonDocument.Parse(result.Detail ?? throw new InvalidDataException("Maa OCR 没有返回结果")); + JsonElement[] lines = detail.RootElement.GetProperty("all").EnumerateArray().ToArray(); + if (lines.Length == 0) + { + LastRawConfidence = 0; + LastConfidence = 0; + return ""; + } + double confidence = lines.Average(line => line.GetProperty("score").GetDouble()); + if (!double.IsFinite(confidence)) throw new InvalidDataException("OCR 置信度无效"); + LastRawConfidence = confidence; + LastConfidence = (int)(confidence * 100); + return string.Join('\n', lines.Select(line => line.GetProperty("text").GetString())); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { CoreLog.Error($"Maa OCR 识别失败: {ex.Message}"); return ""; } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + if (_session != null) + { + try { _session.Dispose(); } + finally { Interlocked.Decrement(ref _activeModelResources); } + } + _initialized.Clear(); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaPadInput.cs b/src/EasyCon.Core/Maa/MaaPadInput.cs new file mode 100644 index 00000000..a9002367 --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaPadInput.cs @@ -0,0 +1,43 @@ +#nullable enable +using EasyCon.Core.Capabilities; +using EasyDevice; +using EasyScript; + +namespace EasyCon.Core.Maa; + +/// ECS 六个输入操作的 Maa 适配;延时与释放在同一次可取消 Shell 回调内。 +public sealed class MaaPadInput : IPadInput +{ + private readonly MaaControllerSession _session; + private readonly ControllerRunLease? _run; + private readonly DelayType _timing; + + internal MaaPadInput(MaaControllerSession session, DelayType timing, ControllerRunLease? run) + { + _session = session; + _run = run; + _timing = timing; + } + + private void Send(string op, ECKey key, int duration = 0, CancellationToken token = default) + => _session.Send(new ControllerCommand + { + Op = op, + Key = ControllerKey.FromKey(key), + Duration = duration, + Timing = _timing, + }, _run, token); + + public void ClickButtons(GamePadKey key, int duration, CancellationToken token) + => Send("input.pulse", key.ToECKey(), duration, token); + public void PressButtons(GamePadKey key) => Send("input.down", key.ToECKey()); + public void ReleaseButtons(GamePadKey key) => Send("input.up", key.ToECKey()); + public void ClickStick(GamePadKey key, byte x, byte y, int duration, CancellationToken token) + => Send("input.pulse", key.ToECKey(x, y), duration, token); + public void SetStick(GamePadKey key, byte x, byte y) + => Send(x == SwitchStick.STICK_CENTER && y == SwitchStick.STICK_CENTER ? "input.up" : "input.down", + key.ToECKey(x, y)); + public void ChangeAmiibo(uint index) + => _session.Send(new ControllerCommand { Op = "amiibo.index", Index = (byte)(index & 0x0F) }, _run); + internal void Reset() => _session.Send(new ControllerCommand { Op = "input.reset" }, _run); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaRecognitionSession.cs b/src/EasyCon.Core/Maa/MaaRecognitionSession.cs new file mode 100644 index 00000000..604d636e --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaRecognitionSession.cs @@ -0,0 +1,307 @@ +#nullable enable +using EasyCon.Capture; +using MaaFramework.Binding; +using MaaFramework.Binding.Buffers; +using MaaFramework.Binding.Custom; +using OpenCvSharp; +using System.Security.Cryptography; +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +/// 专用识别 Tasker;显式图片、不绑定手柄,每个完成请求取出托管结果后清理原生历史。 +public sealed class MaaRecognitionSession : IMaaRecognitionSession, IDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly MaaResource _resource; + private readonly MaaTasker _tasker; + private readonly LegacyRecognition _legacy; + private readonly LegacyRecognition _condition; + private readonly MaaResourceBundleLease? _bundle; + private readonly Dictionary _templates = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _lifetime = new(); + private volatile bool _disposed; + private int _queued; + private int _liveImages; + private static int _activeSessions; + public int QueuedRequests => Volatile.Read(ref _queued); + public int LiveImages => Volatile.Read(ref _liveImages); + public static int ActiveSessions => Volatile.Read(ref _activeSessions); + public long CompletedRequests { get; private set; } + public int RetainedResults { get; private set; } + public int ValidatedTemplates => _templates.Count; + public string NativeResourceDirectory { get; } + + public MaaRecognitionSession(string? resourceDirectory = null, IReadOnlyDictionary? labels = null) + { + _ = MaaEnvironment.RuntimeVersion; + _legacy = new(labels ?? new Dictionary()); + _condition = new(labels ?? new Dictionary(), true); + string directory = resourceDirectory ?? MaaEnvironment.BootstrapResource; + _bundle = labels == null ? null : new(directory); + NativeResourceDirectory = _bundle?.Directory ?? directory; + try { _resource = CreateResource(NativeResourceDirectory, _legacy, _condition); } + catch { _bundle?.Dispose(); throw; } + MaaTasker? tasker = null; + try + { + tasker = new() { Resource = _resource, Controller = MaaController.Null, DisposeOptions = DisposeOptions.None }; + if (!tasker.IsInitialized) + throw new InvalidOperationException("Maa 独立识别 Tasker 未初始化"); + _tasker = tasker; + LoadAndValidateTemplates(directory, labels); + Interlocked.Increment(ref _activeSessions); + } + catch { tasker?.Dispose(); _resource.Dispose(); _bundle?.Dispose(); throw; } + } + + internal static void ValidateResource(string directory, IReadOnlyDictionary labels) + { + using MaaRecognitionSession session = new(directory, labels); + } + + private void LoadAndValidateTemplates(string directory, IReadOnlyDictionary? labels) + { + if (labels == null) return; + foreach (string digest in labels.Values.Where(entry => entry.Error == null && entry.NativeType == "TemplateMatch") + .Select(entry => entry.TemplateDigest!).Distinct(StringComparer.Ordinal)) + { + string name = "legacy/" + digest + ".png"; + string path = Path.Combine(directory, "image", "legacy", digest + ".png"); + try + { + byte[] png = File.ReadAllBytes(path); + if (Convert.ToHexStringLower(SHA256.HashData(png)) != digest) + throw new InvalidDataException("模板摘要不符"); + using MaaImageBuffer image = new(); + if (!image.TrySetEncodedData(png) || !_resource.OverrideImage(name, image)) + throw new InvalidDataException("Maa 模板内存装载失败"); + // 避免原生 imread 的长路径限制;同时以真实 TemplateMatch 验证缓冲区已被资源持有。 + using Mat dimensions = Cv2.ImDecode(png, ImreadModes.Color); + string parameters = JsonSerializer.Serialize(new + { + template = name, + roi = new[] { 0, 0, dimensions.Width, dimensions.Height }, + method = 1, + threshold = 2, + green_mask = false, + }); + MaaTaskJob probe = _tasker.AppendRecognition("TemplateMatch", parameters, image); + MaaJobStatus status = probe.Wait(); + using RecognitionDetail? result = probe.QueryRecognitionDetail(); + if (status != MaaJobStatus.Succeeded || result == null) + throw new InvalidDataException($"原生模板探针失败({status})"); + ValidateTemplateDetail(result.Detail); + using JsonDocument detail = JsonDocument.Parse(result.Detail); + if (!result.Hit || detail.RootElement.GetProperty("best").ValueKind != JsonValueKind.Object) + throw new InvalidDataException("原生模板探针没有命中自身图像"); + _templates.Add(name, dimensions.Size()); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or JsonException or OpenCVException) + { + throw new InvalidDataException($"Maa 模板资源不可用: {path}", ex); + } + finally + { + if (!_tasker.ClearCache()) throw new InvalidOperationException("Maa 模板探针历史清理失败"); + } + } + } + + private void ValidateTemplateRequest(string parameters) + { + using JsonDocument document = JsonDocument.Parse(parameters); + if (!document.RootElement.TryGetProperty("template", out JsonElement value)) + throw new InvalidDataException("Maa TemplateMatch 没有指定模板资源"); + string?[] names = value.ValueKind == JsonValueKind.String ? [value.GetString()] : + value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.GetString()).ToArray() : []; + if (names.Length == 0 || names.Any(name => name == null || !_templates.ContainsKey(name))) + throw new InvalidDataException("Maa TemplateMatch 引用了未通过原生装载验证的模板资源"); + if (document.RootElement.TryGetProperty("roi", out JsonElement roi) && roi.ValueKind == JsonValueKind.Array) + { + int[] bounds = roi.EnumerateArray().Select(item => item.GetInt32()).ToArray(); + if (bounds.Length != 4 || names.Any(name => bounds[2] < _templates[name!].Width || bounds[3] < _templates[name!].Height)) + throw new InvalidDataException("Maa 模板 ROI 小于已装载的模板,不能视为正常无候选"); + } + } + + private static void ValidateTemplateDetail(string detail, bool allowEmpty = false) + { + using JsonDocument document = JsonDocument.Parse(detail); + if (!document.RootElement.TryGetProperty("all", out JsonElement all) || all.ValueKind != JsonValueKind.Array || (!allowEmpty && all.GetArrayLength() == 0)) + throw new InvalidDataException("Maa 原生模板未产生计算结果(资源不可用或计算失败),不能视为正常无候选"); + } + + private static MaaResource CreateResource(string directory, LegacyRecognition legacy, LegacyRecognition condition) + { + _ = MaaEnvironment.RuntimeVersion; + MaaResource resource = new(); + try + { + if (!resource.SetOption(ResourceOption.InferenceDevice, InferenceDevice.CPU) || + !resource.SetOption(ResourceOption.InferenceExecutionProvider, InferenceExecutionProvider.CPU) || + !resource.Register("EasyCon.LegacyTemplateMatch", legacy) || !resource.Register("EasyCon.LegacyScoreCondition", condition)) + throw new InvalidOperationException("Maa 识别资源配置或扩展注册失败"); + if (resource.AppendBundle(directory).Wait() != MaaJobStatus.Succeeded || !resource.IsLoaded) + throw new InvalidDataException($"Maa 识别资源加载失败: {directory}"); + return resource; + } + catch { resource.Dispose(); throw; } + } + + public async Task RecognizeAsync(MaaRecognitionRequest request, CancellationToken token) + { + ObjectDisposedException.ThrowIf(_disposed, this); + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(token, _lifetime.Token); + token = cancellation.Token; + if (Interlocked.Increment(ref _queued) > 32) + { + Interlocked.Decrement(ref _queued); + throw new InvalidOperationException("Maa 识别队列已满(32 个等待请求)"); + } + try { await _gate.WaitAsync(token).ConfigureAwait(false); } + finally { Interlocked.Decrement(ref _queued); } + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + return await Task.Run(() => Recognize(request, token), CancellationToken.None).ConfigureAwait(false); + } + finally { _gate.Release(); } + } + + private MaaRecognitionResult Recognize(MaaRecognitionRequest request, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (request.Type == "TemplateMatch") ValidateTemplateRequest(request.Parameters); + _legacy.Error = null; + _legacy.Token = token; + _condition.Error = null; + _condition.Token = token; + try + { + using MaaImageBuffer image = new(); + Volatile.Write(ref _liveImages, 1); + if (!image.TrySetEncodedData(request.Png.Span)) throw new InvalidDataException("Maa 输入图像解码失败"); + MaaTaskJob? cancellationStop = null; + // 取消调用方(包括 GUI 线程)只发出 Stop;当前工作线程负责排空后再清历史。 + using CancellationTokenRegistration registration = token.Register(() => cancellationStop = _tasker.Stop()); + try + { + token.ThrowIfCancellationRequested(); + MaaTaskJob job = _tasker.AppendRecognition(request.Type, request.Parameters, image); + MaaJobStatus status = job.Wait(); + token.ThrowIfCancellationRequested(); + if (_legacy.Error != null) throw new InvalidOperationException("Maa 兼容识别失败", _legacy.Error); + if (_condition.Error != null) throw new InvalidOperationException("Maa 旧分数条件识别失败", _condition.Error); + using RecognitionDetail? result = job.QueryRecognitionDetail(); + if (result == null) throw new InvalidOperationException($"Maa {request.Type} 未提供识别结果({status})"); + if (status != MaaJobStatus.Succeeded) throw new InvalidOperationException($"Maa {request.Type} 识别作业失败({status})"); + // 负相关等正常候选过滤也可能返回 all=[];模板已在同一原生资源中逐一自匹配验证。 + if (request.Type == "TemplateMatch") ValidateTemplateDetail(result.Detail, allowEmpty: true); + RetainedResults = 1; + return new(request.RequestId, status.ToString(), result.Detail, null, result.Hit); + } + finally + { + registration.Dispose(); + cancellationStop?.Wait(); + } + } + finally + { + Volatile.Write(ref _liveImages, 0); + if (!_tasker.ClearCache()) throw new InvalidOperationException("Maa 识别历史缓存清理失败"); + RetainedResults = 0; + CompletedRequests++; + } + } + + private void RequestStop() + { + try { if (!_disposed) _tasker.Stop().Wait(); } + catch (ObjectDisposedException) { } + } + + public async Task StopAsync() + { + _lifetime.Cancel(); + await Task.Run(RequestStop).ConfigureAwait(false); + await _gate.WaitAsync().ConfigureAwait(false); + _gate.Release(); + } + + public void Dispose() + { + if (_disposed) return; + _lifetime.Cancel(); + RequestStop(); + _gate.Wait(); + try + { + if (_disposed) return; + _disposed = true; + _tasker.Dispose(); + _resource.Dispose(); + _bundle?.Dispose(); + _lifetime.Dispose(); + Interlocked.Decrement(ref _activeSessions); + } + finally { _gate.Release(); } + } + + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + + private sealed class LegacyRecognition(IReadOnlyDictionary labels, bool condition = false) : IMaaCustomRecognition + { + public string Name { get; set; } = condition ? "EasyCon.LegacyScoreCondition" : "EasyCon.LegacyTemplateMatch"; + public Exception? Error { get; set; } + public CancellationToken Token { get; set; } + + public bool Analyze(T context, in AnalyzeArgs args, in AnalyzeResults results) where T : IMaaContext + { + try + { + Token.ThrowIfCancellationRequested(); + using JsonDocument parameters = JsonDocument.Parse(args.RecognitionParam); + string name = parameters.RootElement.GetProperty("label_id").GetString() ?? ""; + if (!labels.TryGetValue(name, out MaaLabelEntry? entry) || entry.Error != null) + throw new InvalidDataException($"标签 [{name}] 不可用"); + RawMatchResult raw; + if (entry.Method == SearchMethod.TesserDetect) + { + using RecognitionDetail result = context.RunRecognition(entry.NativeNode!, args.Image) + ?? throw new InvalidOperationException("Maa 文字标签没有返回识别结果"); + using JsonDocument textDetail = JsonDocument.Parse(result.Detail); + JsonElement[] lines = textDetail.RootElement.GetProperty("all").EnumerateArray().ToArray(); + string text = string.Join('\n', lines.Select(line => line.GetProperty("text").GetString())); + double confidence = lines.Length == 0 ? 0 : lines.Average(line => line.GetProperty("score").GetDouble()); + raw = new(LegacyAlgorithmAdapter.TextSimilarity(text, entry.ReadLabel().ImgBase64) * confidence, + new Rect(entry.Roi[0], entry.Roi[1], entry.Roi[2], entry.Roi[3]), entry.Method, "maa-ocr/5.14.3"); + } + else + { + if (!args.Image.TryGetEncodedData(out byte[]? png) || png == null) throw new InvalidDataException("Maa 回调图像编码失败"); + using Mat image = Cv2.ImDecode(png, ImreadModes.Color); + raw = LegacyTemplateMatcher.Match(entry.ReadLabel(), image); + } + Token.ThrowIfCancellationRequested(); + string detail = JsonSerializer.Serialize(new + { + raw = raw.Score, + method = (int)raw.Method, + box = new[] { raw.Box.X, raw.Box.Y, raw.Box.Width, raw.Box.Height }, + algorithm_version = raw.AlgorithmVersion, + }); + if (!results.Box.TrySetValues(raw.Box.X, raw.Box.Y, raw.Box.Width, raw.Box.Height) || !results.Detail.TrySetValue(detail)) + throw new InvalidOperationException("Maa 兼容识别结果写回失败"); + // true 表示计算有效;业务阈值仍由旧 ECS 比较返回分数。 + if (!condition) return true; + JsonElement config = parameters.RootElement; + LegacyCompatibilityProfile profile = Enum.Parse(config.GetProperty("compatibility").GetString()!, false); + return LegacyAlgorithmAdapter.Compare(LegacyAlgorithmAdapter.ToLegacyInteger(raw, profile), + config.GetProperty("comparison").GetString()!, config.GetProperty("threshold").GetInt32()); + } + catch (Exception ex) { Error = ex; return false; } + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaResourceBundleLease.cs b/src/EasyCon.Core/Maa/MaaResourceBundleLease.cs new file mode 100644 index 00000000..78efcd6e --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaResourceBundleLease.cs @@ -0,0 +1,52 @@ +#nullable enable +namespace EasyCon.Core.Maa; + +/// 原生会话拥有的短路径元数据/模型副本;图像经内存装载,不复制到此目录。 +internal sealed class MaaResourceBundleLease : IDisposable +{ + private readonly string _parent; + private readonly string _name = "ec-maa-" + Guid.NewGuid().ToString("N"); + private bool _disposed; + public string Directory { get; } + + public MaaResourceBundleLease(string source) + { + _parent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())); + Directory = Path.Combine(_parent, _name); + if (System.IO.Directory.Exists(Directory)) throw new IOException("Maa 会话临时目录已存在"); + System.IO.Directory.CreateDirectory(Directory); + try + { + foreach (string area in new[] { "pipeline", "model" }) + { + string input = Path.Combine(source, area); + if (!System.IO.Directory.Exists(input)) continue; + foreach (string file in System.IO.Directory.GetFiles(input, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + { + string target = Path.Combine(Directory, area, Path.GetRelativePath(input, file)); + if (OperatingSystem.IsWindows() && target.Length >= 240) + throw new InvalidDataException("Maa 原生会话目录过长,请将 TEMP 配置到较短的用户目录"); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + } + } + catch { Dispose(); throw; } + } + + public void Dispose() + { + if (_disposed) return; + // 只递归删除本次分配的直接子目录,不删除来源 generation 或其他 TEMP 内容。 + string target = Path.GetFullPath(Directory); + if (Path.GetDirectoryName(target) != _parent || Path.GetFileName(target) != _name) + throw new InvalidOperationException("Maa 会话目录释放边界不符"); + if (System.IO.Directory.Exists(target)) + { + if ((File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("Maa 会话目录被替换为重解析点"); + System.IO.Directory.Delete(target, true); + } + _disposed = true; + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/MaaVisionService.cs b/src/EasyCon.Core/Maa/MaaVisionService.cs new file mode 100644 index 00000000..0c629b4e --- /dev/null +++ b/src/EasyCon.Core/Maa/MaaVisionService.cs @@ -0,0 +1,131 @@ +#nullable enable +using EasyCon.Capture; +using EasyCon.Core.Capabilities; +using OpenCvSharp; +using System.Text.Json; + +namespace EasyCon.Core.Maa; + +public sealed record LegacyRecognitionTrace(string Label, string Generation, string Route, double? NativeRaw, double Raw, + double LegacyFloat, int LegacyInteger, int[] Box, string ImageDigest, long? FrameId, long? SourceEpoch); + +/// 旧标签查询的 Maa 入口;一次查询固定一帧,原生和兼容结果只经唯一评分适配器。 +public sealed class MaaVisionService : IVisionService, IDisposable +{ + private readonly LabelGeneration _generation; + private readonly Func? _acquire; + private readonly CancellationToken _token; + private readonly MaaRecognitionSession _session; + private readonly GenerationLease _generationLease; + private bool _disposed; + private readonly object _gate = new(); + public LegacyRecognitionTrace? LastTrace { get; private set; } + public event Action? Recognized; + public MaaRecognitionSession Session => _session; + public static int ActiveResourceVersions => GenerationLease.ActiveVersions; + + public MaaVisionService(LabelGeneration generation, Func? acquire = null, CancellationToken token = default) + { + _generation = generation; + _acquire = acquire; + _token = token; + _generationLease = new(generation.Directory); + try { _session = new(generation.Directory, generation.Labels); } + catch { _generationLease.Dispose(); throw; } + } + + public int MatchLabel(string labelName) + { + using FrameLease frame = _acquire?.Invoke() ?? throw new InvalidOperationException("没有可用的实时帧"); + return MatchImage(labelName, frame.EncodedPng, frame.Metadata.FrameId, frame.Metadata.SourceEpoch).LegacyInteger; + } + + public LegacyRecognitionTrace MatchImage(string name, ReadOnlyMemory png, long? frameId = null, long? sourceEpoch = null) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _token.ThrowIfCancellationRequested(); + _generation.Preflight([name]); + MaaLabelEntry entry = _generation.Labels[name]; + using Mat image = Cv2.ImDecode(png.ToArray(), ImreadModes.Color); + if (image.Size() != new Size(FrameNormalization.Width, FrameNormalization.Height)) + throw new InvalidDataException("实时标签识别需要规范的 1920×1080 图像"); + RawMatchResult? native = null; + if (entry.RoutingPolicy == "native-with-integer-verification") + { + string detail = Query(entry.NativeType!, entry.NativeParameters!, png); + using JsonDocument document = JsonDocument.Parse(detail); + if (document.RootElement.TryGetProperty("best", out JsonElement best) && best.ValueKind == JsonValueKind.Object) + { + int[] box = best.GetProperty("box").EnumerateArray().Select(value => value.GetInt32()).ToArray(); + native = new(best.GetProperty("score").GetDouble(), new Rect(box[0], box[1], box[2], box[3]), entry.Method, "maa/5.14.3"); + _ = LegacyAlgorithmAdapter.ToLegacyFloat(native); + } + } + // 无跨平台数值误差上界,必须同帧确认整数和最佳框;不以几个样本的误差代替保证。 + using JsonDocument compatibility = JsonDocument.Parse(Query(entry.CompatType, entry.CompatParameters, png)); + JsonElement rawElement = FindRaw(compatibility.RootElement); + int[] compatibleBox = rawElement.GetProperty("box").EnumerateArray().Select(value => value.GetInt32()).ToArray(); + RawMatchResult legacy = new(rawElement.GetProperty("raw").GetDouble(), + new Rect(compatibleBox[0], compatibleBox[1], compatibleBox[2], compatibleBox[3]), entry.Method); + int integer = LegacyAlgorithmAdapter.ToLegacyInteger(legacy, _generation.Profile); + string route = entry.RoutingPolicy; + if (entry.RoutingPolicy == "native-with-integer-verification") + route = native == null ? "native-no-candidate" : + LegacyAlgorithmAdapter.ToLegacyInteger(native, _generation.Profile) != integer ? "integer-boundary" : + native.Box != legacy.Box ? "best-box-semantics" : "native-verified"; + LegacyRecognitionTrace trace = new(name, _generation.Id, route, native?.Score, legacy.Score, + LegacyAlgorithmAdapter.ToLegacyFloat(legacy), integer, compatibleBox, + Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(png.Span)), frameId, sourceEpoch); + LastTrace = trace; + Recognized?.Invoke(trace); + return trace; + } + } + + private string Query(string type, string parameters, ReadOnlyMemory png) + { + MaaRecognitionResult result = _session.RecognizeAsync(new(type, parameters, png, Guid.NewGuid().ToString("N"), null, _generation.Id), _token).GetAwaiter().GetResult(); + return result.Detail ?? throw new InvalidOperationException("Maa 没有返回识别详情"); + } + + private static JsonElement FindRaw(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty("raw", out _)) return element.Clone(); + foreach (JsonProperty property in element.EnumerateObject()) + { + JsonElement found = FindRaw(property.Value); + if (found.ValueKind != JsonValueKind.Undefined) return found; + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + JsonElement found = FindRaw(item); + if (found.ValueKind != JsonValueKind.Undefined) return found; + } + } + else if (element.ValueKind == JsonValueKind.String && element.GetString() is { } text && text.StartsWith('{')) + { + using JsonDocument nested = JsonDocument.Parse(text); + return FindRaw(nested.RootElement); + } + return default; + } + + public string? Crop(string imageBase64, int x, int y, int width, int height) => MatExtensions.CropBase64(imageBase64, x, y, width, height); + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + try { _session.Dispose(); } + finally { _generationLease.Dispose(); } + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/ManualInputQueue.cs b/src/EasyCon.Core/Maa/ManualInputQueue.cs new file mode 100644 index 00000000..802f0579 --- /dev/null +++ b/src/EasyCon.Core/Maa/ManualInputQueue.cs @@ -0,0 +1,64 @@ +#nullable enable +using EasyCon.Core.Logging; +using System.Threading.Channels; + +namespace EasyCon.Core.Maa; + +/// 手动输入接受后在后台串行等待 Maa;绑定身份随入队固定,UI/SDL 不等待原生作业。 +internal sealed class ManualInputQueue : IDisposable +{ + private sealed record Item(MaaControllerBinding? Binding, ControllerCommand? Command, TaskCompletionSource? Barrier = null); + private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) + { + SingleReader = true, + FullMode = BoundedChannelFullMode.Wait, + }); + private readonly Task _worker; + private int _overflow; + public ManualInputQueue() => _worker = Task.Run(Consume); + + public void Post(MaaControllerBinding binding, ControllerCommand command) + { + if (_queue.Writer.TryWrite(new(binding, command))) return; + Interlocked.Exchange(ref _overflow, 1); + CoreLog.Warn("手动输入队列已满;将丢弃待处理输入并确认中性报告"); + } + + public void Drain() + { + TaskCompletionSource barrier = new(TaskCreationOptions.RunContinuationsAsynchronously); + _queue.Writer.WriteAsync(new(null, null, barrier)).AsTask().GetAwaiter().GetResult(); + barrier.Task.GetAwaiter().GetResult(); + } + + private async Task Consume() + { + await foreach (Item item in _queue.Reader.ReadAllAsync().ConfigureAwait(false)) + { + if (Interlocked.Exchange(ref _overflow, 0) != 0) + { + List barriers = []; + MaaControllerBinding? binding = item.Binding; + if (item.Barrier != null) barriers.Add(item.Barrier); + while (_queue.Reader.TryRead(out Item? discarded)) + { + binding ??= discarded.Binding; + if (discarded.Barrier != null) barriers.Add(discarded.Barrier); + } + try { binding?.ReleaseInput(); } + catch (Exception ex) { CoreLog.Warn($"手动输入队列中性报告失败: {ex.Message}"); } + foreach (TaskCompletionSource barrier in barriers) barrier.TrySetResult(); + continue; + } + if (item.Barrier != null) { item.Barrier.TrySetResult(); continue; } + try { item.Binding!.Send(item.Command!, default); } + catch (Exception ex) { CoreLog.Warn($"手动输入被拒绝: {ex.Message}"); } + } + } + + public void Dispose() + { + _queue.Writer.TryComplete(); + _worker.GetAwaiter().GetResult(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/RecognitionContracts.cs b/src/EasyCon.Core/Maa/RecognitionContracts.cs new file mode 100644 index 00000000..9e86ff2b --- /dev/null +++ b/src/EasyCon.Core/Maa/RecognitionContracts.cs @@ -0,0 +1,14 @@ +#nullable enable +namespace EasyCon.Core.Maa; + +/// P2 接入点。显式图像始终是调用方提供的 PNG;识别实现不得在内部重新截图。 +public sealed record MaaRecognitionRequest(string Type, string Parameters, ReadOnlyMemory Png, + string RequestId, string? RunId, string? ResourceGeneration); + +public sealed record MaaRecognitionResult(string RequestId, string NativeStatus, string? Detail, string? ErrorCode, bool? Hit = null); + +public interface IMaaRecognitionSession : IAsyncDisposable +{ + Task RecognizeAsync(MaaRecognitionRequest request, CancellationToken token); + Task StopAsync(); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Maa/SourceOwnership.cs b/src/EasyCon.Core/Maa/SourceOwnership.cs new file mode 100644 index 00000000..95270581 --- /dev/null +++ b/src/EasyCon.Core/Maa/SourceOwnership.cs @@ -0,0 +1,29 @@ +#nullable enable +using System.Security.Cryptography; +using System.Text; + +namespace EasyCon.Core.Maa; + +/// 进程间独占来源。句柄随实际读取/写入所有者释放,不使用有线程亲和性的 Mutex。 +internal sealed class SourceOwnership : IDisposable +{ + private readonly FileStream _file; + + public SourceOwnership(string source) + { + string root = Path.Combine(Path.GetTempPath(), "EasyCon", "ownership"); + Directory.CreateDirectory(root); + string name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))); + try + { + _file = new FileStream(Path.Combine(root, name + ".lock"), FileMode.OpenOrCreate, + FileAccess.ReadWrite, FileShare.None); + } + catch (IOException ex) + { + throw new IOException($"busy: 来源已由另一进程占用 ({source})", ex); + } + } + + public void Dispose() => _file.Dispose(); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Notifications/AlertService.cs b/src/EasyCon.Core/Notifications/AlertService.cs new file mode 100644 index 00000000..cb4d4e51 --- /dev/null +++ b/src/EasyCon.Core/Notifications/AlertService.cs @@ -0,0 +1,115 @@ +#nullable enable + +using EasyCon.Core.Config; +using EasyCon.Core.Services; + +namespace EasyCon.Core.Notifications; + +/// 统一桌面提醒入口:通用 Webhook 与 QQ 图片通知均在后台发送。 +public sealed class AlertService : IAlertService, IDisposable +{ + private readonly object _sync = new(); + private readonly Action? _resultLogger; + private readonly AlertDispatcher _dispatcher; + private readonly HashSet _pending = []; + private readonly CancellationTokenSource _shutdown = new(); + private readonly CancellationToken _shutdownToken; + private bool _disposed; + + public AlertService(Action? resultLogger = null, Func? imageProvider = null, + AlertDispatcher? dispatcher = null) + { + _resultLogger = resultLogger; + _shutdownToken = _shutdown.Token; + if (dispatcher == null) + { + AlertConfig config = ConfigManager.LoadAlert(); + _dispatcher = new AlertDispatcher(config); + if (config.load_error.Length > 0) + _resultLogger?.Invoke(config.load_error); + } + else + { + _dispatcher = dispatcher; + } + _dispatcher.ImageProvider = imageProvider; + _dispatcher.OnResult += OnResult; + ConfigManager.AlertConfigChanged += OnConfigurationChanged; + } + + public void Dispatch(string content, string title = "伊机控消息", byte[]? image = null) + { + lock (_sync) + { + if (_disposed) + return; + Task task = DispatchAsync(content, title, image); + if (task.IsCompletedSuccessfully) + return; + _pending.Add(task); + _ = task.ContinueWith(completed => + { + lock (_sync) + _pending.Remove(completed); + }, TaskScheduler.Default); + } + } + + public async Task FlushAsync(CancellationToken cancellationToken = default) + { + Task[] pending; + lock (_sync) + pending = [.. _pending]; + await Task.WhenAll(pending).WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task DispatchAsync(string content, string title, byte[]? image) + { + try + { + await _dispatcher.DispatchAsync(content, title, _shutdownToken, image).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_shutdownToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + if (!_disposed) + _resultLogger?.Invoke("推送失败:" + ex.Message); + } + } + + private void OnResult(object? sender, string result) => _resultLogger?.Invoke(result); + + private void OnConfigurationChanged(AlertConfig config) + { + lock (_sync) + { + if (_disposed) + return; + try + { + _dispatcher.UpdateConfiguration(config); + } + catch (Exception ex) + { + _resultLogger?.Invoke("推送配置更新失败:" + ex.Message); + } + } + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + return; + _disposed = true; + _shutdown.Cancel(); + ConfigManager.AlertConfigChanged -= OnConfigurationChanged; + _dispatcher.OnResult -= OnResult; + _dispatcher.Dispose(); + _ = Task.WhenAll(_pending).ContinueWith(_ => _shutdown.Dispose(), TaskScheduler.Default); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Notifications/NotificationImage.cs b/src/EasyCon.Core/Notifications/NotificationImage.cs new file mode 100644 index 00000000..79cefd2a --- /dev/null +++ b/src/EasyCon.Core/Notifications/NotificationImage.cs @@ -0,0 +1,69 @@ +#nullable enable + +using OpenCvSharp; + +namespace EasyCon.Core.Notifications; + +/// 将最新视频帧转换为通知图片;无视频帧时使用内嵌 EasyCon Logo。 +public static class NotificationImage +{ + private static readonly Lazy _logo = new(CreateLogo); + + public static byte[] FromFrame(Mat? frame) + { + if (frame == null || frame.Empty()) + return _logo.Value; + + if (Math.Max(frame.Width, frame.Height) <= 1600) + return Encode(frame); + + double scale = 1600.0 / Math.Max(frame.Width, frame.Height); + using Mat resized = new(); + Cv2.Resize(frame, resized, new Size(), scale, scale, InterpolationFlags.Area); + return Encode(resized); + } + + private static byte[] Encode(Mat frame) + { + if (!Cv2.ImEncode(".jpg", frame, out byte[] image, [new ImageEncodingParam(ImwriteFlags.JpegQuality, 88)]) || image.Length == 0) + throw new InvalidOperationException("无法转换 QQ 通知图片。"); + return image; + } + + private static byte[] CreateLogo() + { + using Stream stream = typeof(NotificationImage).Assembly.GetManifestResourceStream("EasyCon.Core.NotificationLogo.ico") + ?? throw new InvalidOperationException("QQ 通知图片不可用,请检查 EasyCon Logo 资源。"); + using BinaryReader reader = new(stream); + if (reader.ReadUInt16() != 0 || reader.ReadUInt16() != 1) + throw new InvalidOperationException("EasyCon Logo 资源格式无效。"); + + int count = reader.ReadUInt16(); + byte[]? png = null; + int largestArea = 0; + for (int i = 0; i < count; i++) + { + stream.Position = 6 + (16 * i); + int width = reader.ReadByte(); + int height = reader.ReadByte(); + stream.Position += 6; + uint length = reader.ReadUInt32(); + uint offset = reader.ReadUInt32(); + int area = (width == 0 ? 256 : width) * (height == 0 ? 256 : height); + if (area <= largestArea || length < 8 || (long)offset + length > stream.Length) + continue; + + stream.Position = offset; + byte[] entry = reader.ReadBytes(checked((int)length)); + if (!entry.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 })) + continue; + png = entry; + largestArea = area; + } + + if (png == null) + throw new InvalidOperationException("EasyCon Logo 中缺少通知所需的 PNG 图片。"); + using Mat logo = Cv2.ImDecode(png, ImreadModes.Color); + return Encode(logo); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Notifications/QQNotificationClient.cs b/src/EasyCon.Core/Notifications/QQNotificationClient.cs new file mode 100644 index 00000000..b59c7580 --- /dev/null +++ b/src/EasyCon.Core/Notifications/QQNotificationClient.cs @@ -0,0 +1,435 @@ +#nullable enable + +using EasyCon.Core.Config; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace EasyCon.Core.Notifications; + +/// QQ 开放平台机器人客户端。每个实例只处理一条顺序网络通路。 +public sealed class QQNotificationClient : IDisposable +{ + private const string ApiBase = "https://api.sgroup.qq.com"; + private const string TokenEndpoint = "https://bots.qq.com/app/getAppAccessToken"; + + private readonly HttpClient _http; + private readonly bool _ownsHttp; + private string _appId = ""; + private string _secret = ""; + private string _token = ""; + private DateTimeOffset _tokenExpires; + private bool _disposed; + + public QQNotificationClient(HttpClient? http = null) + { + _ownsHttp = http == null; + _http = http ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(20), + }; + } + + public void Configure(string appId, string secret) + { + ThrowIfDisposed(); + appId = appId.Trim(); + secret = secret.Trim(); + if (appId.Length == 0 || secret.Length == 0) + throw new InvalidOperationException("请填写 QQ AppID 和 AppSecret。"); + + if (_appId != appId || _secret != secret) + { + _token = ""; + _tokenExpires = default; + } + _appId = appId; + _secret = secret; + } + + public async Task VerifyCredentialsAsync(CancellationToken cancellationToken = default) + { + _token = ""; + _tokenExpires = default; + _ = await GetTokenAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task BindAsync( + QQNotificationTargetKind kind, + Action? codeChanged = null, + CancellationToken cancellationToken = default) + { + string token = await GetTokenAsync(cancellationToken).ConfigureAwait(false); + using JsonDocument gateway = await SendJsonAsync( + HttpMethod.Get, ApiBase + "/gateway", token, null, cancellationToken).ConfigureAwait(false); + string url = gateway.RootElement.TryGetProperty("url", out JsonElement urlValue) + ? urlValue.GetString() ?? "" : ""; + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? gatewayUri) || + (gatewayUri.Scheme != Uri.UriSchemeWs && gatewayUri.Scheme != Uri.UriSchemeWss)) + throw new InvalidOperationException("QQ 接口未返回有效的 WebSocket 网关地址。"); + + using ClientWebSocket socket = new(); + using CancellationTokenSource handshake = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + handshake.CancelAfter(TimeSpan.FromSeconds(20)); + await socket.ConnectAsync(gatewayUri, handshake.Token).ConfigureAwait(false); + using JsonDocument hello = await ReceiveJsonAsync(socket, handshake.Token).ConfigureAwait(false); + if (!hello.RootElement.TryGetProperty("op", out JsonElement helloOp) || helloOp.GetInt32() != 10) + throw new InvalidOperationException("QQ 网关未返回 Hello 事件。"); + int heartbeatMs = hello.RootElement.TryGetProperty("d", out JsonElement helloData) + && helloData.TryGetProperty("heartbeat_interval", out JsonElement interval) + ? ReadInteger(interval) : 0; + if (heartbeatMs <= 0) + throw new InvalidOperationException("QQ 网关未返回有效的心跳间隔。"); + await SendJsonAsync(socket, new + { + op = 2, + d = new { token = "QQBot " + token, intents = 1 << 25, shard = new[] { 0, 1 } }, + }, handshake.Token).ConfigureAwait(false); + handshake.CancelAfter(Timeout.InfiniteTimeSpan); + + // 保持唯一的 ReceiveAsync;取消一次接收会中止 ClientWebSocket,不能用它刷新绑定码。 + using CancellationTokenSource session = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task receive = ReceiveJsonAsync(socket, session.Token); + Stopwatch clock = Stopwatch.StartNew(); + TimeSpan nextHeartbeat = TimeSpan.FromMilliseconds(heartbeatMs); + TimeSpan codeExpires = TimeSpan.Zero; + bool ready = false; + bool awaitingAck = false; + long? sequence = null; + string code = ""; + int generation = 0; + int lastSeconds = -1; + try + { + while (true) + { + session.Token.ThrowIfCancellationRequested(); + if (!ready && clock.Elapsed > TimeSpan.FromSeconds(20)) + throw new InvalidOperationException("QQ 网关鉴权超时,请重新绑定。"); + + if (ready && clock.Elapsed >= codeExpires) + { + code = NewBindingCode(code); + generation++; + codeExpires = clock.Elapsed + TimeSpan.FromSeconds(60); + lastSeconds = -1; + } + if (ready) + { + int seconds = Math.Max(0, (int)Math.Ceiling((codeExpires - clock.Elapsed).TotalSeconds)); + if (seconds != lastSeconds) + { + codeChanged?.Invoke(new QQBindingCode(kind, code, seconds, generation)); + lastSeconds = seconds; + } + } + + using CancellationTokenSource tick = CancellationTokenSource.CreateLinkedTokenSource(session.Token); + Task delay = Task.Delay(200, tick.Token); + await Task.WhenAny(receive, delay).ConfigureAwait(false); + if (receive.IsCompleted) + { + tick.Cancel(); + using JsonDocument payload = await receive.ConfigureAwait(false); + receive = ReceiveJsonAsync(socket, session.Token); + JsonElement root = payload.RootElement; + if (root.TryGetProperty("s", out JsonElement s) && + s.ValueKind == JsonValueKind.Number && s.TryGetInt64(out long value)) + sequence = value; + int op = root.TryGetProperty("op", out JsonElement opValue) ? opValue.GetInt32() : -1; + if (op == 11) + awaitingAck = false; + else if (op == 1) + { + await SendJsonAsync(socket, new { op = 1, d = sequence }, session.Token).ConfigureAwait(false); + awaitingAck = true; + nextHeartbeat = clock.Elapsed + TimeSpan.FromMilliseconds(heartbeatMs); + } + else if (op is 7 or 9) + throw new InvalidOperationException("QQ 网关要求重连或鉴权失败,请检查机器人权限后重新绑定。"); + else if (op == 0) + { + string eventName = root.TryGetProperty("t", out JsonElement eventValue) + ? eventValue.GetString() ?? "" : ""; + if (eventName == "READY") + ready = true; + else if (ready && IsBindingEvent(kind, eventName) && clock.Elapsed < codeExpires && + root.TryGetProperty("d", out JsonElement data)) + { + string content = data.TryGetProperty("content", out JsonElement contentValue) + ? contentValue.GetString() ?? "" : ""; + if (Regex.IsMatch(content, @"(?= nextHeartbeat) + { + if (awaitingAck) + throw new InvalidOperationException("QQ 网关心跳未响应,请重新绑定。"); + await SendJsonAsync(socket, new { op = 1, d = sequence }, session.Token).ConfigureAwait(false); + awaitingAck = true; + nextHeartbeat = clock.Elapsed + TimeSpan.FromMilliseconds(heartbeatMs); + } + } + } + finally + { + session.Cancel(); + socket.Abort(); + try + { + using JsonDocument pending = await receive.ConfigureAwait(false); + } + catch (Exception) + { + // 清理仍在等待的接收任务,保留绑定操作本身的结果。 + } + } + } + + public async Task SendAsync( + IReadOnlyList targets, + string text, + byte[]? image, + CancellationToken cancellationToken = default) + { + if (targets.Count == 0 || targets.Any(target => string.IsNullOrWhiteSpace(target.OpenId))) + throw new InvalidOperationException("请先绑定勾选的 QQ 接收方。"); + if (string.IsNullOrWhiteSpace(text) && image is not { Length: > 0 }) + throw new InvalidOperationException("通知文字和图片不能同时为空。"); + + List failures = []; + foreach (QQNotificationTarget target in targets) + { + bool textSubmitted = false; + try + { + string token = await GetTokenAsync(cancellationToken).ConfigureAwait(false); + string route = target.Kind == QQNotificationTargetKind.User ? "/v2/users/" : "/v2/groups/"; + string baseUrl = ApiBase + route + Uri.EscapeDataString(target.OpenId.Trim()); + if (!string.IsNullOrWhiteSpace(text)) + { + using JsonDocument response = await SendJsonAsync(HttpMethod.Post, baseUrl + "/messages", token, + new { msg_type = 0, content = text }, cancellationToken).ConfigureAwait(false); + textSubmitted = true; + } + if (image is { Length: > 0 }) + { + string fileInfo = await UploadImageAsync(baseUrl, token, image, cancellationToken).ConfigureAwait(false); + using JsonDocument response = await SendJsonAsync(HttpMethod.Post, baseUrl + "/messages", token, + new { msg_type = 7, media = new { file_info = fileInfo } }, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + string label = target.Kind == QQNotificationTargetKind.User ? "私聊" : "群聊"; + failures.Add(label + ":" + (textSubmitted ? "文字已提交;" : "") + RedactError(ex.Message)); + } + } + if (failures.Count > 0) + throw new InvalidOperationException(string.Join(";", failures) + "。已提交的消息不会撤回,请在 QQ 中核对后再决定是否重发。"); + } + + private async Task UploadImageAsync(string baseUrl, string token, byte[] image, CancellationToken cancellationToken) + { + const string fileName = "notification.jpg"; + using JsonDocument prepared = await SendJsonAsync(HttpMethod.Post, baseUrl + "/upload_prepare", token, new + { + file_type = 1, + file_size = image.Length.ToString(CultureInfo.InvariantCulture), + file_name = fileName, + md5 = HashMd5(image), + sha1 = Convert.ToHexString(SHA1.HashData(image)).ToLowerInvariant(), + md5_10m = HashMd5(image.AsSpan(0, Math.Min(image.Length, 10_002_432))), + }, cancellationToken).ConfigureAwait(false); + JsonElement root = prepared.RootElement; + string uploadId = root.GetProperty("upload_id").GetString() ?? ""; + int blockSize = ReadInteger(root.GetProperty("block_size")); + JsonElement parts = root.GetProperty("parts"); + if (string.IsNullOrWhiteSpace(uploadId) || blockSize <= 0 || parts.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("图片上传接口返回了无效的分片信息。"); + + int expected = ((image.Length - 1) / blockSize) + 1; + List<(int Index, Uri Url)> uploadParts = []; + foreach (JsonElement part in parts.EnumerateArray()) + { + int index = ReadInteger(part.GetProperty("index")); + string url = part.GetProperty("presigned_url").GetString() ?? ""; + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uploadUri) || + (uploadUri.Scheme != Uri.UriSchemeHttp && uploadUri.Scheme != Uri.UriSchemeHttps)) + throw new InvalidOperationException("图片上传接口返回了无效的分片地址。"); + uploadParts.Add((index, uploadUri)); + } + uploadParts.Sort((left, right) => left.Index.CompareTo(right.Index)); + // 兼容从 0 或 1 开始的完整分片列表;字节偏移归零,完成回执保留服务器编号。 + int indexBase = uploadParts.Count > 0 ? uploadParts[0].Index : 0; + if (indexBase is not (0 or 1) || uploadParts.Count != expected || !uploadParts.Select(part => part.Index) + .SequenceEqual(Enumerable.Range(indexBase, expected))) + { + string indexes = string.Join(", ", uploadParts.Take(8).Select(part => part.Index)); + if (uploadParts.Count > 8) + indexes += ", …"; + throw new InvalidOperationException($"图片上传接口返回的分片列表不完整(图片={image.Length} 字节,分片大小={blockSize} 字节,预期={expected} 片,实际={uploadParts.Count} 片,编号=[{indexes}])。"); + } + + foreach ((int index, Uri url) in uploadParts) + { + int offset = (index - indexBase) * blockSize; + int length = Math.Min(blockSize, image.Length - offset); + using HttpRequestMessage upload = new(HttpMethod.Put, url) + { + Content = new ByteArrayContent(image, offset, length), + }; + upload.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + using HttpResponseMessage uploadResponse = await _http.SendAsync(upload, cancellationToken).ConfigureAwait(false); + if (!uploadResponse.IsSuccessStatusCode) + throw new InvalidOperationException($"图片分片上传失败:HTTP {(int)uploadResponse.StatusCode}"); + using JsonDocument finished = await SendJsonAsync(HttpMethod.Post, baseUrl + "/upload_part_finish", token, new + { + upload_id = uploadId, + part_index = index, + block_size = length.ToString(CultureInfo.InvariantCulture), + md5 = HashMd5(image.AsSpan(offset, length)), + }, cancellationToken).ConfigureAwait(false); + } + using JsonDocument result = await SendJsonAsync(HttpMethod.Post, baseUrl + "/files", token, + new { file_type = 1, upload_id = uploadId, file_name = fileName }, cancellationToken).ConfigureAwait(false); + string fileInfo = result.RootElement.TryGetProperty("file_info", out JsonElement fileValue) + ? fileValue.GetString() ?? "" : ""; + return string.IsNullOrWhiteSpace(fileInfo) + ? throw new InvalidOperationException("图片合并接口未返回 file_info。") : fileInfo; + } + + private async Task GetTokenAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + if (!string.IsNullOrWhiteSpace(_token) && DateTimeOffset.UtcNow < _tokenExpires) + return _token; + if (string.IsNullOrWhiteSpace(_appId) || string.IsNullOrWhiteSpace(_secret)) + throw new InvalidOperationException("请填写 QQ AppID 和 AppSecret。"); + using JsonDocument result = await SendJsonAsync(HttpMethod.Post, TokenEndpoint, "", + new { appId = _appId, clientSecret = _secret }, cancellationToken).ConfigureAwait(false); + _token = result.RootElement.TryGetProperty("access_token", out JsonElement tokenValue) + ? tokenValue.GetString() ?? "" : ""; + int lifetime = result.RootElement.TryGetProperty("expires_in", out JsonElement expiresValue) + ? ReadInteger(expiresValue) : 0; + if (string.IsNullOrWhiteSpace(_token)) + throw new InvalidOperationException("QQ 接口未返回 access_token。"); + _tokenExpires = DateTimeOffset.UtcNow.AddSeconds(Math.Max(0, lifetime - Math.Min(60, lifetime / 2))); + return _token; + } + + private async Task SendJsonAsync(HttpMethod method, string url, string token, object? body, CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(method, url); + if (!string.IsNullOrWhiteSpace(token)) + request.Headers.Authorization = new AuthenticationHeaderValue("QQBot", token); + if (body != null) + request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); + string responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string error = RedactError(responseText); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + _token = ""; + _tokenExpires = default; + } + throw new InvalidOperationException($"HTTP {(int)response.StatusCode}:{error}"); + } + JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(responseText) ? "{}" : responseText); + if (document.RootElement.ValueKind != JsonValueKind.Object || + (document.RootElement.TryGetProperty("code", out JsonElement code) && code.ToString() is not ("0" or ""))) + { + document.Dispose(); + throw new InvalidOperationException("QQ API 错误:" + RedactError(responseText)); + } + return document; + } + + private static async Task ReceiveJsonAsync(ClientWebSocket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[16 * 1024]; + using MemoryStream stream = new(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); + if (result.MessageType != WebSocketMessageType.Text) + throw new InvalidOperationException("QQ 网关连接已关闭或返回了无效的消息格式。"); + if (stream.Length + result.Count > 1024 * 1024) + throw new InvalidOperationException("QQ 网关消息超过允许的大小。"); + stream.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + return JsonDocument.Parse(stream.ToArray()); + } + + private static async Task SendJsonAsync(ClientWebSocket socket, object body, CancellationToken cancellationToken) + { + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(body); + await socket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + } + + private static bool IsBindingEvent(QQNotificationTargetKind kind, string eventName) + { + return kind == QQNotificationTargetKind.User + ? eventName == "C2C_MESSAGE_CREATE" : eventName is "GROUP_AT_MESSAGE_CREATE" or "GROUP_MESSAGE_CREATE"; + } + + private static string NewBindingCode(string previous) + { + string code; + do + { + code = RandomNumberGenerator.GetInt32(100000, 1_000_000).ToString(CultureInfo.InvariantCulture); + } while (code == previous); + return code; + } + + private static int ReadInteger(JsonElement value) => int.Parse(value.ToString(), CultureInfo.InvariantCulture); + + private static string HashMd5(ReadOnlySpan data) => Convert.ToHexString(MD5.HashData(data)).ToLowerInvariant(); + + private string RedactError(string detail) + { + foreach (string value in new[] { _secret, _token }) + { + if (value.Length > 0) + detail = detail.Replace(value, "[已隐藏]", StringComparison.Ordinal); + } + detail = detail.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return detail.Length > 500 ? detail[..500] : detail; + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_ownsHttp) + _http.Dispose(); + } +} + +public sealed record QQBindingCode(QQNotificationTargetKind Kind, string Code, int Seconds, int Generation); \ No newline at end of file diff --git a/src/EasyCon.Core/Notifications/QQNotificationService.cs b/src/EasyCon.Core/Notifications/QQNotificationService.cs new file mode 100644 index 00000000..a34e29cb --- /dev/null +++ b/src/EasyCon.Core/Notifications/QQNotificationService.cs @@ -0,0 +1,414 @@ +#nullable enable + +using EasyCon.Core.Config; +using EasyCon.Core.Services; +using System.Threading.Channels; + +namespace EasyCon.Core.Notifications; + +/// QQ 通知配置、绑定和后台发送队列。网络发送不会阻塞脚本执行。 +public sealed class QQNotificationService : IAlertService, IDisposable, IAsyncDisposable +{ + private readonly object _settingsLock = new(); + private readonly ILogService? _log; + private readonly QQNotificationClient _client; + private readonly QQNotificationClient _sender; + private readonly bool _persistSettings; + private readonly Channel _queue = Channel.CreateBounded( + new BoundedChannelOptions(20) { FullMode = BoundedChannelFullMode.Wait }); + private readonly CancellationTokenSource _shutdown = new(); + private readonly Task _worker; + private CancellationTokenSource? _operation; + private CancellationTokenSource? _sending; + private QQNotificationSettings _settings; + private TaskCompletionSource _idle = CompletedSource(); + private int _pendingCount; + private int _generation; + private bool _disposed; + + public event Action? Result; + public event Action? StatusChanged; + public event Action? BindingCodeChanged; + public event Action? Error; + + /// 在 ALERT 触发时读取当前帧,返回 JPEG 字节。无画面时使用内嵌 Logo。 + public Func? ImageProvider { get; set; } + + public QQNotificationService(ILogService? log = null, QQNotificationClient? client = null, + QQNotificationClient? sender = null, QQNotificationSettings? settings = null, bool persistSettings = true) + { + _log = log; + _persistSettings = persistSettings; + _client = client ?? new QQNotificationClient(); + _sender = sender ?? new QQNotificationClient(); + if (ReferenceEquals(_client, _sender)) + throw new ArgumentException("绑定和自动通知必须使用独立的 QQ 客户端。", nameof(sender)); + _settings = settings?.Clone() ?? LoadSettings(); + LastError = _settings.load_error; + _worker = Task.Run(ProcessQueueAsync); + } + + public QQNotificationSettings Settings + { + get + { + lock (_settingsLock) + return _settings.Clone(); + } + } + + public string LastError { get; private set; } = ""; + + public bool IsBusy + { + get + { + lock (_settingsLock) + return _operation != null; + } + } + + public void Update(Action update, bool save = true) + { + lock (_settingsLock) + { + ThrowIfDisposed(); + QQNotificationSettings next = _settings.Clone(); + update(next); + next.app_id = next.app_id?.Trim() ?? ""; + next.secret = next.secret?.Trim() ?? ""; + next.user_openid = next.user_openid?.Trim() ?? ""; + next.group_openid = next.group_openid?.Trim() ?? ""; + bool credentialsChanged = next.app_id != _settings.app_id || next.secret != _settings.secret; + if (credentialsChanged && _operation != null) + throw new InvalidOperationException("请先取消当前操作,再修改 QQ 凭据。"); + if (next.app_id != _settings.app_id) + { + next.user_openid = ""; + next.group_openid = ""; + } + if (credentialsChanged) + { + next.enabled = false; + next.verified = false; + } + if (_persistSettings && next.enabled && !next.IsReady()) + throw new InvalidOperationException("启用 QQ 通知前,请填写凭据并绑定所有勾选的接收方。"); + + if (save && _persistSettings) + { + next.PrepareSecretForSave(); + // 先保存成功,再更新内存。写入失败时保持原来的绑定与配置。 + ConfigManager.SaveQqNotification(next); + } + if (credentialsChanged || (_settings.enabled && !next.enabled)) + { + _generation++; + _sending?.Cancel(); + while (_queue.Reader.TryRead(out PendingNotification? pending)) + CompletePending(pending); + } + _settings = next; + } + } + + public async Task VerifyAsync(CancellationToken cancellationToken = default) + { + await RunOperationAsync(async (settings, token) => + { + lock (_settingsLock) + _settings.verified = false; + StatusChanged?.Invoke("正在验证 QQ 机器人凭据…"); + await _client.VerifyCredentialsAsync(token).ConfigureAwait(false); + lock (_settingsLock) + _settings.verified = true; + LastError = ""; + StatusChanged?.Invoke("凭据验证成功,可以继续绑定接收方。"); + }, cancellationToken).ConfigureAwait(false); + } + + public async Task BindAsync(QQNotificationTargetKind kind, CancellationToken cancellationToken = default) + { + string openId = ""; + await RunOperationAsync(async (settings, token) => + { + if (!settings.verified) + throw new InvalidOperationException("请先验证 QQ 凭据。"); + StatusChanged?.Invoke("正在连接 QQ 网关…"); + int generation = 0; + openId = await _client.BindAsync(kind, code => + { + BindingCodeChanged?.Invoke(code); + if (code.Generation != generation) + { + generation = code.Generation; + string instruction = kind == QQNotificationTargetKind.User ? "私聊机器人" : "在目标群 @机器人"; + StatusChanged?.Invoke($"请{instruction}发送当前绑定码:{code.Code}(60 秒后自动更新)"); + } + }, token).ConfigureAwait(false); + Update(next => + { + if (kind == QQNotificationTargetKind.User) + { + next.user_openid = openId; + next.user_enabled = true; + } + else + { + next.group_openid = openId; + next.group_enabled = true; + } + }); + LastError = ""; + StatusChanged?.Invoke(kind == QQNotificationTargetKind.User ? "QQ 私聊绑定成功。" : "QQ 群聊绑定成功。"); + }, cancellationToken).ConfigureAwait(false); + return openId; + } + + public async Task SendTestAsync(CancellationToken cancellationToken = default) + { + await RunOperationAsync(async (settings, token) => + { + IReadOnlyList targets = settings.Targets(); + byte[] image = GetImage(null); + StatusChanged?.Invoke("正在发送 QQ 图文测试…"); + await _client.SendAsync(targets, + "QQ 通知测试:请确认同时收到这条文字和测试图片。", + image, token).ConfigureAwait(false); + LastError = ""; + StatusChanged?.Invoke("图文测试发送成功,请在 QQ 中确认文字和图片均已收到。"); + }, cancellationToken).ConfigureAwait(false); + } + + public void CancelCurrent() + { + lock (_settingsLock) + _operation?.Cancel(); + } + + public void Dispatch(string content, string title = "伊机控消息", byte[]? image = null) + => Dispatch(content, title, image, CancellationToken.None); + + public void Dispatch(string content, string title, byte[]? image, CancellationToken cancellationToken) + { + _ = DispatchAsync(content, title, image, cancellationToken); + } + + /// 等待本条通知处理完成;未入队的通知立即完成,不等待其他通知。 + public Task DispatchAsync(string content, string title = "伊机控消息", byte[]? image = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + QQNotificationSettings settings; + int generation; + lock (_settingsLock) + { + if (_disposed || !_settings.enabled) + return Task.CompletedTask; + settings = _settings.Clone(); + generation = _generation; + } + try + { + IReadOnlyList targets = settings.Targets(); + if (!settings.IsReady()) + throw new InvalidOperationException("QQ 通知凭据不完整,请重新填写。"); + image = settings.attach_image ? GetImage(image).ToArray() : null; + string text = string.IsNullOrWhiteSpace(title) ? content : title + "\n" + content; + lock (_settingsLock) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_disposed || generation != _generation || !_settings.enabled) + return Task.CompletedTask; + PendingNotification pending = new(settings.app_id, settings.secret, targets, text, image, generation, cancellationToken, + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + if (!_queue.Writer.TryWrite(pending)) + throw new InvalidOperationException("待发送通知过多,当前通知未加入队列。"); + if (_pendingCount++ == 0) + _idle = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return pending.Completion.Task.WaitAsync(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + ReportError("QQ 通知未发送:" + ex.Message); + return Task.CompletedTask; + } + } + + /// CLI 在脚本退出后等待已排队的通知;脚本本身不等待网络。 + public Task FlushAsync(CancellationToken cancellationToken = default) + { + lock (_settingsLock) + return _idle.Task.WaitAsync(cancellationToken); + } + + private byte[] GetImage(byte[]? supplied) + { + if (supplied is { Length: > 0 }) + return supplied; + byte[]? image = ImageProvider?.Invoke(); + return image is { Length: > 0 } ? image : NotificationImage.FromFrame(null); + } + + private async Task ProcessQueueAsync() + { + try + { + await foreach (PendingNotification pending in _queue.Reader.ReadAllAsync(_shutdown.Token).ConfigureAwait(false)) + { + CancellationTokenSource? sending = null; + try + { + lock (_settingsLock) + { + if (_disposed || pending.Generation != _generation || pending.CancellationToken.IsCancellationRequested) + continue; + sending = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token, pending.CancellationToken); + _sending = sending; + } + _sender.Configure(pending.AppId, pending.Secret); + await _sender.SendAsync(pending.Targets, pending.Content, pending.Image, sending.Token).ConfigureAwait(false); + LastError = ""; + ReportResult("QQ 通知发送成功。"); + } + catch (OperationCanceledException) when (sending?.IsCancellationRequested == true) + { + if (!_disposed) + ReportResult("QQ 待发送通知已取消。已提交的消息请在 QQ 中核对。"); + } + catch (Exception ex) + { + ReportError("QQ 通知发送失败:" + ex.Message); + } + finally + { + lock (_settingsLock) + { + if (ReferenceEquals(_sending, sending)) + _sending = null; + CompletePending(pending); + } + sending?.Dispose(); + } + } + } + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + { + } + finally + { + lock (_settingsLock) + { + while (_queue.Reader.TryRead(out PendingNotification? pending)) + CompletePending(pending); + } + } + } + + private async Task RunOperationAsync(Func operation, + CancellationToken cancellationToken) + { + CancellationTokenSource operationCts; + QQNotificationSettings settings; + lock (_settingsLock) + { + ThrowIfDisposed(); + if (_operation != null) + throw new InvalidOperationException("已有 QQ 操作正在进行,请先等待或取消。"); + operationCts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token, cancellationToken); + _operation = operationCts; + settings = _settings.Clone(); + } + try + { + _client.Configure(settings.app_id, settings.secret); + await operation(settings, operationCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + StatusChanged?.Invoke("QQ 通知操作已取消。已提交的消息请在 QQ 中核对。"); + throw; + } + catch (Exception ex) + { + ReportError(ex.Message); + throw; + } + finally + { + lock (_settingsLock) + _operation = null; + operationCts.Dispose(); + } + } + + private static QQNotificationSettings LoadSettings() => ConfigManager.LoadQqNotification(); + + private void CompletePending(PendingNotification pending) + { + pending.Completion.TrySetResult(true); + if (--_pendingCount == 0) + _idle.TrySetResult(true); + } + + private static TaskCompletionSource CompletedSource() + { + TaskCompletionSource source = new(TaskCreationOptions.RunContinuationsAsynchronously); + source.SetResult(true); + return source; + } + + private void ReportResult(string message) + { + if (_disposed) + return; + Result?.Invoke(message); + _log?.AddLog(message); + } + + private void ReportError(string message) + { + if (_disposed) + return; + LastError = message; + Error?.Invoke(message); + _log?.AddLog(message); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + public void Dispose() + { + lock (_settingsLock) + { + if (_disposed) + return; + _disposed = true; + _shutdown.Cancel(); + _operation?.Cancel(); + _queue.Writer.TryComplete(); + } + _client.Dispose(); + _ = _worker.ContinueWith(_ => + { + _sender.Dispose(); + _shutdown.Dispose(); + }, TaskScheduler.Default); + } + + public async ValueTask DisposeAsync() + { + Dispose(); + await _worker.ConfigureAwait(false); + } + + private sealed record PendingNotification( + string AppId, string Secret, IReadOnlyList Targets, + string Content, byte[]? Image, int Generation, CancellationToken CancellationToken, + TaskCompletionSource Completion); +} \ No newline at end of file diff --git a/src/EasyCon.Core/OcrDelegateFactory.cs b/src/EasyCon.Core/OcrDelegateFactory.cs index 17856b0f..364fc350 100644 --- a/src/EasyCon.Core/OcrDelegateFactory.cs +++ b/src/EasyCon.Core/OcrDelegateFactory.cs @@ -1,99 +1,25 @@ -using EasyCon.Capture; -using EasyCon.Capture.Ocr; +using EasyCon.Capture; using EasyScript; using OpenCvSharp; namespace EasyCon.Core; -public static class OcrDelegateFactory +public static class FrameDelegateFactory { - /// - /// 创建 OcrInitDelegate:初始化并缓存 OCR 引擎。 - /// - public static OcrInitDelegate CreateInit(OcrEngineCache cache) - { - return (lang, dataPath, engineMode, psmode) => - cache.Init(lang, dataPath, engineMode, psmode); - } - - /// - /// 创建 OcrDelegate:从缓存获取或自动初始化引擎,执行 OCR 识别。 - /// 不再有 per-call 兜底逻辑——引擎始终通过 OcrEngineCache 管理生命周期。 - /// - public static OcrDelegate Create(Func frameProvider, OcrEngineCache cache) - { - return (x, y, w, h, lang) => - { - using var lease = frameProvider?.Invoke(); - if (lease == null || lease.Mat.Empty()) return "OCR NOT SUPPORT"; - var frame = lease.Mat; - - x = Math.Clamp(x, 0, frame.Width); - y = Math.Clamp(y, 0, frame.Height); - w = Math.Clamp(w, 0, frame.Width - x); - h = Math.Clamp(h, 0, frame.Height - y); - - if (w == 0 || h == 0) return "OCR ARGS ERR!"; - - using var roi = new Mat(frame, new Rect(x, y, w, h)); - var imageBytes = roi.ToBytes(".png"); - - var recognizer = cache.GetOrInit(lang); - var result = recognizer.Recognize(imageBytes); - cache.LastConfidence = (int)(result.Confidence * 100); - return result.Text; - }; - } - - /// - /// 兼容重载:接收返回自有 Mat 的提供者(旧式 WPF 仍按此方式调用)。 - /// - public static OcrDelegate Create(Func frameProvider, OcrEngineCache cache) - { - return (x, y, w, h, lang) => - { - using var frame = frameProvider?.Invoke(); - if (frame == null || frame.Empty()) return "OCR NOT SUPPORT"; - - x = Math.Clamp(x, 0, frame.Width); - y = Math.Clamp(y, 0, frame.Height); - w = Math.Clamp(w, 0, frame.Width - x); - h = Math.Clamp(h, 0, frame.Height - y); - - if (w == 0 || h == 0) return "OCR ARGS ERR!"; + /// 无帧哨兵:旧委托契约以字符串传达失败(新端口语义下归一为 null)。 + public const string NoFrameError = "采集卡检查异常"; - using var roi = new Mat(frame, new Rect(x, y, w, h)); - var imageBytes = roi.ToBytes(".png"); + /// ROI 非法哨兵(旧契约;新端口语义下归一为 null)。 + public const string RoiError = "ROI检查异常"; - var recognizer = cache.GetOrInit(lang); - var result = recognizer.Recognize(imageBytes); - cache.LastConfidence = (int)(result.Confidence * 100); - return result.Text; - }; - } -} - -public static class FrameDelegateFactory -{ public static FrameDelegate CreateFrame(Func frameProvider) { return (x, y, w, h) => { using var lease = frameProvider?.Invoke(); - if (lease == null || lease.Mat.Empty()) return "采集卡检查异常"; - var mat = lease.Mat; - if (x >= 0 && y >= 0 && w >= 0 && h >= 0) - { - x = Math.Clamp(x, 0, mat.Width); - y = Math.Clamp(y, 0, mat.Height); - w = Math.Clamp(w, 0, mat.Width - x); - h = Math.Clamp(h, 0, mat.Height - y); - - using var roi = new Mat(mat, new Rect(x, y, w, h)); - if (w == 0 || h == 0) return "ROI检查异常"; - return Convert.ToBase64String(roi.ToBytes(".png")); - } - return Convert.ToBase64String(mat.ToBytes(".png")); + if (lease != null && (x < 0 || y < 0 || w < 0 || h < 0)) + return Convert.ToBase64String(lease.EncodedPng.Span); + return CropToBase64(lease?.Mat, x, y, w, h) ?? NoFrameError; }; } @@ -105,19 +31,28 @@ public static FrameDelegate CreateFrame(Func frameProvider) return (x, y, w, h) => { using var mat = frameProvider?.Invoke(); - if (mat == null || mat.Empty()) return "采集卡检查异常"; - if (x >= 0 && y >= 0 && w >= 0 && h >= 0) - { - x = Math.Clamp(x, 0, mat.Width); - y = Math.Clamp(y, 0, mat.Height); - w = Math.Clamp(w, 0, mat.Width - x); - h = Math.Clamp(h, 0, mat.Height - y); + return CropToBase64(mat, x, y, w, h) ?? NoFrameError; + }; + } - using var roi = new Mat(mat, new Rect(x, y, w, h)); - if (w == 0 || h == 0) return "ROI检查异常"; - return Convert.ToBase64String(roi.ToBytes(".png")); - } + /// + /// 帧 → Base64 PNG:x/y/w/h 全非负时裁 ROI(越界按帧边界收敛),否则整帧。 + /// 无帧或 ROI 收敛到空返回 null——失败哨兵只由旧委托入口 + /// ()负责转换,跨端口调用方拿 null。 + /// + public static string? CropToBase64(Mat? mat, int x, int y, int w, int h) + { + if (mat == null || mat.Empty()) return null; + if (x < 0 || y < 0 || w < 0 || h < 0) return Convert.ToBase64String(mat.ToBytes(".png")); - }; + + x = Math.Clamp(x, 0, mat.Width); + y = Math.Clamp(y, 0, mat.Height); + w = Math.Clamp(w, 0, mat.Width - x); + h = Math.Clamp(h, 0, mat.Height - y); + if (w == 0 || h == 0) return null; + + using var roi = new Mat(mat, new Rect(x, y, w, h)); + return Convert.ToBase64String(roi.ToBytes(".png")); } } \ No newline at end of file diff --git a/src/EasyCon.Core/ProjectManager.cs b/src/EasyCon.Core/ProjectManager.cs deleted file mode 100644 index 09bfa894..00000000 --- a/src/EasyCon.Core/ProjectManager.cs +++ /dev/null @@ -1,374 +0,0 @@ -using EasyCon.Capture; -using System.Diagnostics; -using System.IO.Compression; -using System.Reflection.Emit; -using System.Reflection.PortableExecutable; - -namespace EasyCon.Core; - -public sealed class ProjectManager : IDisposable -{ - // 工程内容存储 - private string _mainSource = string.Empty; - private readonly Dictionary _libFiles = new(); - private readonly Dictionary _imageFiles = new(); - - // 文件路径管理 - private string _zipFilePath; - private bool _isModified; - - // 公共访问属性 - public string MainSource => _mainSource; - public IReadOnlyDictionary LibFiles => _libFiles; - public IReadOnlyDictionary ImageFiles => _imageFiles; - public string CurrentFilePath => _zipFilePath; - public bool IsModified => _isModified; - public bool IsLoaded => !string.IsNullOrEmpty(_zipFilePath); - - // 文件操作接口 - public void SetMainSource(string content) - { - if (content == null) throw new ArgumentNullException(nameof(content)); - if (_mainSource != content) - { - _mainSource = content; - _isModified = true; - } - } - - public void SetLibFile(string fileName, string content) - { - ValidateFileName(fileName, ".txt"); - if (content == null) throw new ArgumentNullException(nameof(content)); - - if (!_libFiles.TryGetValue(fileName, out var existing) || existing != content) - { - _libFiles[fileName] = content; - _isModified = true; - } - } - - public void SetImageFile(string fileName, byte[] data) - { - ValidateFileName(fileName, ".il"); - if (data == null) throw new ArgumentNullException(nameof(data)); - - // 检查内容是否变化 - bool changed = true; - if (_imageFiles.TryGetValue(fileName, out var existing)) - { - changed = !existing.SequenceEqual(data); - } - - if (changed) - { - _imageFiles[fileName] = data; - _isModified = true; - } - } - - public bool RemoveLibFile(string fileName) - { - if (_libFiles.Remove(fileName)) - { - _isModified = true; - return true; - } - return false; - } - - public bool RemoveImageFile(string fileName) - { - if (_imageFiles.Remove(fileName)) - { - _isModified = true; - return true; - } - return false; - } - - public void RenameLibFile(string oldName, string newName) - { - if (!_libFiles.ContainsKey(oldName)) - throw new FileNotFoundException($"库文件 '{oldName}' 不存在"); - - ValidateFileName(newName, ".txt"); - - if (_libFiles.ContainsKey(newName)) - throw new InvalidOperationException($"目标文件名 '{newName}' 已存在"); - - string content = _libFiles[oldName]; - _libFiles.Remove(oldName); - _libFiles[newName] = content; - _isModified = true; - } - - public void RenameImageFile(string oldName, string newName) - { - if (!_imageFiles.ContainsKey(oldName)) - throw new FileNotFoundException($"图像文件 '{oldName}' 不存在"); - - ValidateFileName(newName, ".il"); - - if (_imageFiles.ContainsKey(newName)) - throw new InvalidOperationException($"目标文件名 '{newName}' 已存在"); - - byte[] data = _imageFiles[oldName]; - _imageFiles.Remove(oldName); - _imageFiles[newName] = data; - _isModified = true; - } - - // 文件验证 - private void ValidateFileName(string fileName, string requiredExtension) - { - if (string.IsNullOrWhiteSpace(fileName)) - throw new ArgumentException("文件名不能为空"); - - if (fileName.Contains('/') || fileName.Contains('\\')) - throw new ArgumentException("文件名不能包含路径分隔符"); - - if (Path.GetInvalidFileNameChars().Any(c => fileName.Contains(c))) - throw new ArgumentException("文件名包含无效字符"); - - if (!fileName.EndsWith(requiredExtension, StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException($"文件扩展名必须是{requiredExtension}"); - } - - // 工程加载与保存 - public void LoadFromZip(string zipFilePath) - { - if (!File.Exists(zipFilePath)) - throw new FileNotFoundException("ZIP文件不存在", zipFilePath); - - ClearProject(); - - using (var fileStream = new FileStream(zipFilePath, FileMode.Open)) - using (var memoryStream = new MemoryStream()) - { - fileStream.CopyTo(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - - using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read, true); - // 验证ZIP结构 - ValidateProjectStructure(archive); - - // 加载main.txt - var mainEntry = archive.GetEntry("main.ecs"); - if (mainEntry != null) - { - using var reader = new StreamReader(mainEntry.Open()); - _mainSource = reader.ReadToEnd(); - } - - // 加载lib目录下的txt文件 - foreach (var entry in archive.Entries - .Where(e => e.FullName.StartsWith("lib/") && !e.FullName.EndsWith("/"))) - { - using (var reader = new StreamReader(entry.Open())) - { - _libFiles[Path.GetFileName(entry.Name)] = reader.ReadToEnd(); - } - } - - // 加载img目录下的il文件 - foreach (var entry in archive.Entries - .Where(e => e.FullName.StartsWith("img/") && !e.FullName.EndsWith("/"))) - { - using (var entryStream = entry.Open()) - using (var ms = new MemoryStream()) - { - entryStream.CopyTo(ms); - _imageFiles[Path.GetFileName(entry.Name)] = ms.ToArray(); - } - - } - } - - _zipFilePath = zipFilePath; - _isModified = false; - } - - public void LoadFromPath(string path) - { - if (!Path.Exists(path)) - throw new FileNotFoundException("ZIP文件不存在", path); - ClearProject(); - - if (File.Exists(path + "/" + "main.ecs")) - _mainSource = File.ReadAllText(path + "/" + "main.ecs"); - - var libpath = path + "/lib/"; - if (Path.Exists(libpath)) - { - foreach (var file in Directory.GetFiles(libpath, "*.ecs")) - { - _libFiles[Path.GetFileName(file)] = File.ReadAllText(file); - } - } - if (Path.Exists(path + "/img/")) - { - foreach (var file in Directory.GetFiles(libpath, "*.IL")) - { - _imageFiles[Path.GetFileName(file)] = File.ReadAllBytes(file); - } - } - - _zipFilePath = path; - _isModified = false; - } - - public void SaveToOriginalZip() - { - if (!IsLoaded) - throw new InvalidOperationException("尚未加载任何工程文件"); - - SaveToZip(_zipFilePath); - } - - public void SaveToZip(string zipFilePath) - { - if (!IsLoaded && string.IsNullOrEmpty(zipFilePath)) - throw new InvalidOperationException("需要指定保存路径"); - - using (var memoryStream = CreateZipStream()) - { - // 确保目录存在 - var directory = Path.GetDirectoryName(zipFilePath); - if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - - using (var fileStream = new FileStream(zipFilePath, FileMode.Create)) - { - memoryStream.CopyTo(fileStream); - } - } - - // 更新当前路径(如果是新路径) - if (!string.Equals(zipFilePath, _zipFilePath, StringComparison.OrdinalIgnoreCase)) - { - _zipFilePath = zipFilePath; - } - - _isModified = false; - } - - // 创建新工程 - public void CreateNewProject(string initialContent = "// 新的工程主文件") - { - ClearProject(); - _mainSource = initialContent; - _isModified = true; - } - - // 清空当前工程 - private void ClearProject() - { - _mainSource = string.Empty; - _libFiles.Clear(); - _imageFiles.Clear(); - _zipFilePath = null; - _isModified = false; - } - - // 创建ZIP内存流 - private MemoryStream CreateZipStream() - { - var memoryStream = new MemoryStream(); - - using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) - { - // 保存main.txt - var mainEntry = archive.CreateEntry("main.txt"); - using (var writer = new StreamWriter(mainEntry.Open())) - { - writer.Write(_mainSource); - } - - // 保存lib目录下的txt文件 - foreach (var file in _libFiles) - { - var entry = archive.CreateEntry($"lib/{file.Key}"); - using (var writer = new StreamWriter(entry.Open())) - { - writer.Write(file.Value); - } - } - - // 保存img目录下的il文件 - foreach (var file in _imageFiles) - { - var entry = archive.CreateEntry($"img/{file.Key}"); - using (var stream = entry.Open()) - { - stream.Write(file.Value, 0, file.Value.Length); - } - } - } - - memoryStream.Seek(0, SeekOrigin.Begin); - return memoryStream; - } - - // 验证项目结构 - private void ValidateProjectStructure(ZipArchive archive) - { - int mainTxtCount = 0; - - foreach (var entry in archive.Entries) - { - // 跳过目录条目 - if (entry.FullName.EndsWith("/")) continue; - - // 检查根目录下的main.txt - if (entry.FullName == "main.txt") - { - mainTxtCount++; - continue; - } - - // 检查lib目录下的txt文件 - if (entry.FullName.StartsWith("lib/")) - { - // 检查是否在lib根目录下 - if (entry.FullName.Count(c => c == '/') != 1) - throw new InvalidDataException($"无效的lib目录结构: {entry.FullName}"); - - // 检查文件扩展名 - if (!entry.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException($"lib目录下发现非txt文件: {entry.Name}"); - - continue; - } - - // 检查img目录下的il文件 - if (entry.FullName.StartsWith("img/")) - { - // 检查是否在img根目录下 - if (entry.FullName.Count(c => c == '/') != 1) - throw new InvalidDataException($"无效的img目录结构: {entry.FullName}"); - - // 检查文件扩展名 - if (!entry.Name.EndsWith(".il", StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException($"img目录下发现非il文件: {entry.Name}"); - - continue; - } - - // 发现不符合要求的条目 - throw new InvalidDataException($"发现未授权的文件或目录: {entry.FullName}"); - } - - // 必须只有一个main.txt文件 - if (mainTxtCount != 1) - { - throw new InvalidDataException(mainTxtCount == 0 ? - "缺少main.txt文件" : "发现多个main.txt文件"); - } - } - - // 资源清理 - public void Dispose() - { - ClearProject(); - } -} \ No newline at end of file diff --git a/src/EasyCon.Core/Projects/ProjectScaffold.cs b/src/EasyCon.Core/Projects/ProjectScaffold.cs new file mode 100644 index 00000000..caf945a0 --- /dev/null +++ b/src/EasyCon.Core/Projects/ProjectScaffold.cs @@ -0,0 +1,157 @@ +#nullable enable +using System.Text; + +namespace EasyCon.Core.Projects; + +/// 脚手架布局路径(相对项目根;与运行时识别规则对齐:docs/Script.md §11、 +/// ModuleGraphBuilder R3 自动导入、ECCore.ImgDir 标签目录、models/ 为脚本相对路径约定)。 +public static class ProjectLayout +{ + /// 主脚本文件名(同目录可并列多个主脚本,各自独立编译)。 + public const string MainFile = "main.ecs"; + + /// 库目录:主脚本同目录 lib/*.ecs 自动导入(R3),无需 IMPORT。 + public const string LibDir = "lib"; + + /// 示例库模块相对路径(lib/ 内模块与 main 之间同目录互见/自动导入)。 + public const string ModuleFile = "lib/mod1.ecs"; + + /// 模型资产目录(运行约定:脚本按项目相对路径引用,如 NET_LOAD("models/..."))。 + public const string ModelsDir = "models"; + + /// 识图标签目录(唯一识别名,见 ;init 生成空目录)。 + public const string LabelsDir = ECCore.ImgDir; +} + +/// init 命令的执行结果。 +public sealed class ScaffoldResult +{ + public required string RootPath { get; init; } + + /// 已创建的目录(相对项目根)。 + public List CreatedDirectories { get; } = []; + + /// 已存在并保留的目录(相对项目根)。 + public List ExistingDirectories { get; } = []; + + /// 已创建的文件(相对项目根)。 + public List CreatedFiles { get; } = []; + + /// 冲突项:已存在且未被覆盖的路径(相对项目根)。 + public List Conflicts { get; } = []; + + /// IO/权限失败信息;非 null 时 为 false。 + public string? Error { get; internal set; } + + public bool Success => Conflicts.Count == 0 && Error == null; +} + +/// +/// 脚本项目脚手架:初始化基础项目框架(main.ecs + lib/mod1.ecs + models/ + ImgLabel/)。 +/// 目录即包(类 Go):main 同目录 lib/*.ecs 自动可见;ImgLabel/ 只识别顶层 *.IL; +/// models/ 只是脚本相对路径约定,无运行时识别。预检发现冲突时不写入;默认不覆盖并发新建的文件。 +/// +public static class ProjectScaffold +{ + static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + public const string MainTemplate = + """ + # main.ecs — 伊机控脚本项目入口 + # 目录约定: + # lib/*.ecs 自动导入,可直接调用其中的 FUNC(本例 ADD 在 lib/mod1.ecs) + # ImgLabel/*.IL 识图标签(可留空) + # models/ 本地模型/字典资产(可留空;脚本按项目相对路径引用) + # + # 运行:在项目根目录执行 + # ezcon run main.ecs --port mock + # 编译:ezcon compile main.ecs + + $sum = ADD(1, 2) + PRINT "欢迎使用伊机控" + PRINT "ADD(1, 2) = " & $sum + """; + + public const string ModuleTemplate = + """ + # lib/mod1.ecs — 项目库模块(main 自动可见,无需 IMPORT) + FUNC ADD($a, $b):INT + RETURN $a + $b + ENDFUNC + """; + + /// + /// 初始化项目框架。rootPath 不存在时自动创建;已存在时只补缺失项。 + /// 预检发现模板文件已存在(force=false),或文件/目录位置被另一种类型占据时,不做任何写入。 + /// 默认不覆盖预检后并发新建的文件;IO 失败可能保留已完成项,记录于结果中。 + /// force 仅覆盖两个模板文件,不删除/清空任何目录。 + /// + public static ScaffoldResult Create(string rootPath, bool force = false) + { + string root = Path.GetFullPath(rootPath); + + // 路径目标是已存在文件(如 init a.txt)→ 直接拒绝 + if (File.Exists(root)) + return new ScaffoldResult { RootPath = root, Error = $"目标路径是文件而非目录: {root}" }; + + string[] dirs = [ProjectLayout.LibDir, ProjectLayout.ModelsDir, ProjectLayout.LabelsDir]; + string[] files = [ProjectLayout.MainFile, ProjectLayout.ModuleFile]; + + var result = new ScaffoldResult { RootPath = root }; + + // 目录占用预检:目录位置被同名文件占据属硬错误(--force 也不删除文件) + foreach (string dir in dirs) + { + string full = Path.Combine(root, dir); + if (File.Exists(full)) + return new ScaffoldResult { RootPath = root, Error = $"目录位置被文件占据: {full}" }; + } + + // 模板位置被目录占据时始终拒绝;force 只允许覆盖文件。 + foreach (string file in files) + { + string full = Path.Combine(root, file); + if (Directory.Exists(full)) + return new ScaffoldResult { RootPath = root, Error = $"文件位置被目录占据: {full}" }; + if (!force && File.Exists(full)) + result.Conflicts.Add(file); + } + if (result.Conflicts.Count > 0) + return result; + + try + { + Directory.CreateDirectory(root); + foreach (string dir in dirs) + { + string full = Path.Combine(root, dir); + if (Directory.Exists(full)) + result.ExistingDirectories.Add(dir); + else + { + Directory.CreateDirectory(full); + result.CreatedDirectories.Add(dir); + } + } + WriteTemplate(Path.Combine(root, ProjectLayout.MainFile), MainTemplate, force); + result.CreatedFiles.Add(ProjectLayout.MainFile); + WriteTemplate(Path.Combine(root, ProjectLayout.ModuleFile), ModuleTemplate, force); + result.CreatedFiles.Add(ProjectLayout.ModuleFile); + return result; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + result.Error = ex.Message; + return result; + } + } + + /// 统一 UTF-8 无 BOM + LF + 结尾换行,避免检出平台 EOL 影响生成结果。 + static void WriteTemplate(string path, string content, bool force) + { + // CreateNew 将不覆盖保证放在写入时,避免预检后并发创建的文件被截断。 + using FileStream stream = new(path, force ? FileMode.Create : FileMode.CreateNew, FileAccess.Write); + using StreamWriter writer = new(stream, Utf8NoBom); + writer.Write(content.Replace("\r\n", "\n") + "\n"); + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/README.md b/src/EasyCon.Core/README.md index ca2e25b7..3234d046 100644 --- a/src/EasyCon.Core/README.md +++ b/src/EasyCon.Core/README.md @@ -2,52 +2,81 @@ ## 模块概述 -Core模块是EasyCon2的业务枢纽,协调Script、Device、Capture等子模块,为上层UI提供统一API。职责包括脚本执行编排、项目管理、配置管理和推送通知。 +Core 是**能力端口 + 组合根**:向上为 GUI/CLI 提供统一的能力装配与脚本执行面,向下引用 +Script/Device/Capture 三个实现库并把它俩装配成可用的宿主。 -## 核心功能 +> ⚠️ 它不是"最底层的抽象层"。四个叶子库(`EasyCon.Script` / `EasyCon.Device` / +> `EasyCon.Capture` / `EzTesseract`)零工程引用且**不得**反向引用 Core。 +> 完整分层图与规则见 [docs/Framework.md §2](../../docs/Framework.md) 与 `AGENTS.md`。 -### 脚本执行引擎 -- **Scripter** — 脚本编排器,负责脚本的解析、编译、执行和停止 -- **IRunner** — 脚本运行器接口,统一不同语言的执行方式 -- 内置三种运行器: - - **EasyRunner** — 运行自研ECS脚本,通过 `Compilation` 管线执行 - - **PyRunner** — 运行Python脚本(基于Python.Runtime) - - **LuaRunner** — 运行Lua脚本(基于NLua) -- **GamePadAdapter** — 将 `NintendoSwitch` 设备适配为 `ICGamePad` 接口,桥接Device模块与Script模块 +## 目录与职责 -### 项目管理 -- **ProjectManager** — 基于ZIP的项目文件管理,打包脚本(main.ecs + lib/*.ecs)和图像标签(img/*.IL) +| 目录 / 文件 | 职责 | +|---|---| +| `Capabilities/` | **能力端口**:`CapabilitySet`(宿主 = 能力实例集合,null = 不可用)+ `IPadInput` / `IConsoleIo` / `IHostEnvironment` / `IFileSystem` / `ICaptureSource` / `IVisionService` / `IOcrService` / `IInference` | +| `Capabilities/*Adapter*.cs`、`DesktopFileSystem`、`DnnInference`、`TesseractOcrService`、`PaddleOnnxOcr` | 端口的默认/过渡实现(委托适配器、桌面文件、OpenCV DNN、两个 OCR 后端) | +| `Hosting/` | **组合根**:`ScriptHostAssembler`(全仓唯一构造 `CapabilitySet` 的地方,返回 `CapabilityLease` 负责 OCR/DNN 释放)+ `ScriptCompileProfiles`(`Desktop` / `Portable` 两个编译档位) | +| `Script/` | 脚本引擎门面:`IScriptEngine`(源码/文件 → 会话)、`IScriptSession`(诊断 + `Run(token, capabilities)`)、默认实现 `EasyScriptEngine` | +| `Runner/` | 桌面 ECX 执行桥:`EcxVm.Run/BuildHost` 把 `CapabilitySet` 编成 `EcxHost` 委托闭包并把 VM 错误码映射回 `ScriptException`;`BuiltinCallable` 为按名分发的内建/采集洞实现;`NativeLoader` 为 FFI 动态库解析 | +| `Config/` | `ConfigManager`(静态 JSON 读写:损坏先备份再降级并上报、Save 原子替换)、`ConfigState`、`KeyMappingConfig`、`GamepadMappingConfig`、`AlertConfig` + `AlertDispatcher`、`AppPaths` | +| `LLM/` | OpenAI 兼容 chat client(流式/非流式、SSE、退避重试、工具协议 DTO)、models/provider 配置、技能体系(`Skills/`)、MCP 服务器配置 | +| `Input/` | `IInputBinder`(输入后端契约;实现位于 `EasyCon.SDLInput`) | +| `IO/` | `CustomSleep`(低占用延时) | +| `Logging/` | `CoreLog` 静态汇点:叶子库把诊断交给宿主(GUI 接 LogService、CLI 接 Serilog) | +| `Services/` | `ILogService` / `IConfigService` 端口 | +| `ECCore.cs`、`GamePadAdapter.cs`、`KeyExt.cs`、`StringExtensions.cs`、`OcrDelegateFactory.cs` | 便捷门面与桥接:采集源/设备枚举、`NintendoSwitch → ICGamePad`、按键转换、`FrameDelegate` 工厂 | -### 配置管理 -- **ConfigManager** — 静态配置管理器,统一JSON读写 -- **ConfigState** — 应用全局设置(采集类型、界面选项、烧录后自动运行等) -- **KeyMappingConfig** — 键盘到Switch按键的映射配置 -- **AlertConfig** — 推送通知配置(支持PushPlus、Bark、自定义Webhook) +## 使用方式 -### 推送通知 -- **AlertDispatcher** — 根据AlertConfig发送HTTP推送,支持模板变量替换 +### 装配并运行一个脚本(宿主视角) -### 远程助手 -- **AssistClient** — WebSocket客户端,连接远程助手服务,支持日志推送和远程指令 +```csharp +IScriptEngine engine = new EasyScriptEngine(); -### 工具方法 -- **ECCore** — 静态入口类,提供采集源枚举、设备列表、图像标签加载等便捷方法 -- **ImgLabelExt** — 图像标签扩展方法,从帧中提取标签指定区域的子图 -- **KeyExt** — 按键类型转换扩展(GamePadKey → ECKey) +// 1) 编译:档位只用 ScriptCompileProfiles,不要手写 CompileOptions +IScriptSession session = engine.LoadFile(path, new ScriptHostOptions +{ + Compile = ScriptCompileProfiles.Desktop(labelNames), // 桌面解释器档 +}); + +// 2) 装配能力:唯一装配点,宿主只提供"原料" +using CapabilityLease lease = ScriptHostAssembler.Assemble(new ScriptHostContext +{ + Pad = hasKeyAction ? new GamePadAdapter(device, highResolution) : null, + Console = new ConsoleIoAdapter(logService), + Frame = frameDelegate, // Base64 PNG,可空 + Roi = MatExtensions.CropBase64, + LabelMatch = labelMatch, // 可空 + Args = args, +}); +// 默认值由装配器补齐:Environment、Files、OCR(Tesseract)、Inference(DNN) + +// 3) 执行(能力可在 Run 时覆盖编译期设置) +session.Run(cancellationToken, lease.Capabilities); +``` + +`CapabilityLease.Dispose()` 释放 OCR 引擎与推理会话,重复调用安全。 + +### 编译档位 + +| 档位 | 槽位 | 缓存 | 产物 | +|---|---|---|---| +| `ScriptCompileProfiles.Desktop` | PC 宽槽位 | 不落盘现编 | 只供 `EcxInterpreter`;含 `PcCode` 时 `EcxWriter`/`EcmFormat` 拒绝序列化 | +| `ScriptCompileProfiles.Portable` | 冻结 8 位槽位 | 启用 `obj/` 内容寻址缓存 | 可落 `.ecx` 交单片机 C VM(MCU 分发唯一合法档位) | ## 配置文件 -所有配置以JSON格式存储在 `%AppData%/easycon/` 目录,由 `AppPaths` 统一管理路径: -- `config.json` — 应用配置 -- `keymapping.json` — 按键映射 -- `alert.json` — 推送通知 +JSON 存储于 `%AppData%/easycon/`(`AppPaths` 统一管理): +`config.json`(应用配置)、`keymapping.json`(按键映射)、`alert.json`(推送通知)、 +`models.json`(LLM 供应商/模型)、`mcp.json`(MCP 服务器)。 ## 依赖项 -- EasyCon.Device — 设备通信 -- EasyCon.Capture — 图像处理 -- EasyCon.Script — 脚本解析 +- EasyCon.Device — 设备通信(HID 报文、串口协议) +- EasyCon.Capture — 采集帧、视觉匹配、OCR 引擎缓存 +- EasyCon.Script — 编译管线、字节码、C# 解释器 +- YamlDotNet(仅 Core 自身的 NuGet 依赖;OpenCV/OpenCvSharp 经 Capture 传递获得) --- -**版本**: 2.0 +**版本**: 3.0(ECX 统一链路 + 组合根收敛) diff --git a/src/EasyCon.Core/Resources/Images/NotificationLogo.ico b/src/EasyCon.Core/Resources/Images/NotificationLogo.ico new file mode 100644 index 00000000..d00a1a41 Binary files /dev/null and b/src/EasyCon.Core/Resources/Images/NotificationLogo.ico differ diff --git a/src/EasyCon.Core/Runner/BuiltinCallable.cs b/src/EasyCon.Core/Runner/BuiltinCallable.cs index 6b28c856..d86842dc 100644 --- a/src/EasyCon.Core/Runner/BuiltinCallable.cs +++ b/src/EasyCon.Core/Runner/BuiltinCallable.cs @@ -3,6 +3,7 @@ using EasyCon.Script.Runtime; using EasyCon.Script.Symbols; using EasyScript; +using OpenCvSharp; using System.Collections.Immutable; using System.Text; using System.Text.Json; @@ -17,6 +18,25 @@ namespace EasyCon.Core.Runner; /// internal static class BuiltinCallable { + public static Value InvokeAudio(string name, ReadOnlySpan args, IAudioSession? session, CancellationToken token) + { + if (session == null) + throw new InvalidOperationException("当前宿主未提供脚本音频能力 AUDIO_LOAD/AUDIO_SCORE/AUDIO_UNLOAD。"); + switch (name) + { + case "AUDIO_LOAD": + session.Load(args[0].AsString(), token); + return Value.Void; + case "AUDIO_SCORE": + return Value.FromInt(session.Score(args[0].AsString(), token)); + case "AUDIO_UNLOAD": + session.Unload(args[0].AsString(), token); + return Value.Void; + default: + throw new InvalidOperationException($"未知脚本音频函数:{name}。"); + } + } + public static Value ImplJq(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { try @@ -88,7 +108,9 @@ public static Value ImplCaptureHole(ReadOnlySpan args, CapabilitySet capa return Value.FromString(result ?? "ERR!!FRAME NOT SUPPORT"); } - public static Value ImplOcrHole(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + /// __TESSERACT__ 洞:保持旧名字,OCR 后端由宿主装配。 + /// ppocr 不经此面——走 NET 族张量原语 + std 配方(StdLib PP_OCR_DIGITS)。 + public static Value ImplTesseractHole(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { IOcrService? ocr = capabilities.Ocr; string? result = ocr switch @@ -97,7 +119,7 @@ public static Value ImplOcrHole(ReadOnlySpan args, CapabilitySet capabili // 旧委托直通:OcrDelegate 自带采集(P3 随洞改造退役) DelegateOcrService legacy => legacy.Ocr?.Invoke( args[0].AsInt(), args[1].AsInt(), args[2].AsInt(), args[3].AsInt(), args[4].AsString()), - // 通用服务:区域帧在采集侧裁好,识别服务收整图(query 缺省区域) + // 通用服务持有 ROI 规则;不能用 FRAME 的零尺寸规则提前裁掉 OCR 输入。 _ => OcrRecognize(ocr, capabilities.Capture, args[0].AsInt(), args[1].AsInt(), args[2].AsInt(), args[3].AsInt(), args[4].AsString()), }; @@ -106,16 +128,21 @@ public static Value ImplOcrHole(ReadOnlySpan args, CapabilitySet capabili static string OcrRecognize(IOcrService ocr, ICaptureSource? capture, int x, int y, int w, int h, string lang) { - var frame = capture?.CaptureFrame(x, y, w, h); + var frame = capture?.CaptureFullFrame(); return frame == null ? "ERR!!OCR NOT SUPPORT" - : ocr.Recognize(ImageRef.FromBase64(frame), new OcrQuery { Language = lang }); + : ocr.Recognize(ImageRef.FromBase64(frame), new OcrQuery { X = x, Y = y, Width = w, Height = h, Language = lang }); } + /// Vision 能力缺省回落桌面参考实现(同 Files → DesktopFileSystem 先例): + /// __ROI__ 与采集路径共享 CropToBase64 裁剪核,桌面零装配即可用。 + static IVisionService Vision(CapabilitySet capabilities) + => capabilities.Vision ?? DesktopVisionService.Instance; + public static Value ImplRoiHole(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { var image = args[0].AsString(); - var result = capabilities.Vision?.Crop(image, args[1].AsInt(), args[2].AsInt(), args[3].AsInt(), args[4].AsInt()); + var result = Vision(capabilities).Crop(image, args[1].AsInt(), args[2].AsInt(), args[3].AsInt(), args[4].AsInt()); return Value.FromString(result ?? "ERR!!ROI NOT SUPPORT"); } @@ -139,105 +166,576 @@ public static Value ImplFClose(ReadOnlySpan args, CapabilitySet capabilit public static Value ImplFEof(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => Value.FromBool(Files(capabilities).Eof(args[0].AsPtr())); - public static Value ImplReadFile(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) - => Value.FromString(Files(capabilities).ReadAllText(args[0].AsString())); - - public static Value ImplWriteFile(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) - { - Files(capabilities).WriteAllText(args[0].AsString(), args[1].AsString()); - return Value.Void; - } - - public static Value ImplAppendFile(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) - { - Files(capabilities).AppendAllText(args[0].AsString(), args[1].AsString()); - return Value.Void; - } - public static Value ImplFileExists(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => Value.FromBool(Files(capabilities).Exists(args[0].AsString())); public static Value ImplOcrConf(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => Value.FromInt(capabilities.Ocr?.LastConfidence ?? 0); - // ============ ONNX 推理实验函数(IInference;Vision 特征位,P5) ============ - // 纯标量协议:原生边界(EcxNativeContext)不携带数组——NET_RUN 返回输出长度, + // ============ ONNX 推理函数(IInference;Vision 特征位) ============ + // 纯标量协议:原生边界(EcxNativeContext)不携带数组——NET_RUNH 返回输出长度, // 结果缓存于线程槽,NET_OUT(i) 逐元素读取(脚本同步语义 = 单线程执行)。 [ThreadStatic] static float[]? _lastNetOutput; + // 最近一次 "letter" 模式 __NET_IMAGE__ 的内容区尺寸(缩放后图像在画布中的 newW×newH)。 + // 与 _lastNetOutput 同一"最近配对"线程槽协议:__YOLO_DECODE__ 用它把端到端框 + // 裁剪回内容区(Ultralytics predict 后处理同约定),无需拓宽原生 ABI 实参窗。 + [ThreadStatic] static int _letterContentW; + [ThreadStatic] static int _letterContentH; + public static Value ImplNetLoad(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) - => Value.FromInt(capabilities.Inference?.Load(args[0].AsString()) ?? -1); + => Value.FromPtr(capabilities.Inference?.Load(args[0].AsString()) ?? -1); + + public static Value ImplNetOut(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var output = _lastNetOutput; + int index = args[0].AsInt(); + if (output == null || index < 0 || index >= output.Length) + return Value.FromDouble(0); + return Value.FromDouble(output[index]); + } + + /// + /// __TESSERACT_INIT__ 洞:旧位置参数签名映射 + /// (lang → Language,dataPath → ModelPath,engineMode/psmode → Options["tess:*"])。 + /// 未装配 OCR 能力返回 false。 + /// + public static Value ImplTesseractInitHole(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var cfg = new OcrConfig + { + Language = args[0].AsString(), + ModelPath = args[1].AsString(), + Options = new Dictionary + { + ["tess:engineMode"] = args[2].AsString(), + ["tess:psmode"] = args[3].AsString(), + }, + }; + return Value.FromBool(capabilities.Ocr?.Init(cfg) ?? false); + } - public static Value ImplNetRun(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + // ---- NET 句柄族(NET_IMAGE / NET_RUNH / NET_SCALE / NET_FREE / NET_UNLOAD)---- + // 大数组驻留宿主(IInference 张量表),脚本只见句柄;out 参数仍是扁平输出, + // 复用 _lastNetOutput 线程槽与 NET_OUT(i) 读取。 + + /// + /// __NET_IMAGE__ 洞:base64 图像 → 缩放 (rw×rh) → 灰度/RGB 平铺 float(0-255 原始值) + /// → 张量句柄。mode: "gray"([1,1,rh,rw])| "rgb"([1,3,rh,rw],CHW)| + /// "letter"(YOLO letterbox:等比缩放至目标内、居中、垫 114 灰,走 RGB 通道), + /// 可加 'c' 后缀("rgbc"/"grayc")改为 cover 缩放 + 居中裁剪(分类模型常规: + /// 不变形不垫边;letter 无裁剪变体)。letter 的缩放/取整公式与 std YOLO_DETECT + /// 逆映射同式(零漂移由 YoloDetectTests 锁定)。失败返回 0。 + /// + public static Value ImplNetImage(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { IInference? inference = capabilities.Inference; if (inference == null) + return Value.FromPtr(0); + + try { - _lastNetOutput = null; - return Value.FromInt(0); + return NetImageFromBytes(Convert.FromBase64String(args[0].AsString()), + args[1].AsInt(), args[2].AsInt(), args[3].AsString(), inference); + } + catch + { + return Value.FromPtr(0); + } + } + + /// + /// __NET_IMAGE_FILE__ 洞:本地图像文件 → 同 __NET_IMAGE__ 管线(mode 词表相同)。 + /// 文件读取走 System.IO——与 DesktopFileSystem 参考实现同一沙箱姿态。失败返回 0。 + /// + public static Value ImplNetImageFile(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + IInference? inference = capabilities.Inference; + if (inference == null) + return Value.FromPtr(0); + + try + { + return NetImageFromBytes(File.ReadAllBytes(args[0].AsString()), + args[1].AsInt(), args[2].AsInt(), args[3].AsString(), inference); + } + catch + { + return Value.FromPtr(0); } + } + /// 图像字节 → 缩放(stretch / letter / crop)→ CHW float(0-255 原始值)→ 张量句柄。 + static Value NetImageFromBytes(byte[] bytes, int rw, int rh, string mode, IInference inference) + { try { - ScriptArray input = args[1].AsArray(); - var floats = new float[input.Length]; - for (int i = 0; i < input.Length; i++) - floats[i] = ToFloat(input[i]); - - float[]? output = inference.Run(args[0].AsInt(), floats); - _lastNetOutput = output; - return Value.FromInt(output?.Length ?? 0); + using var mat = Mat.FromImageData(bytes); + if (mat.Empty() || rw <= 0 || rh <= 0) + return Value.FromPtr(0); + + string baseMode = mode; + bool crop = baseMode.EndsWith('c'); + if (crop) + baseMode = baseMode[..^1]; + if (crop && baseMode == "letter") + return Value.FromPtr(0); // letter 自带垫边,无裁剪变体 + + using var resized = new Mat(); + if (baseMode == "letter") + { + double scale = Math.Min((double)rw / mat.Width, (double)rh / mat.Height); + int newW = (int)(mat.Width * scale + 0.5); + int newH = (int)(mat.Height * scale + 0.5); + using var scaled = new Mat(); + Cv2.Resize(mat, scaled, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); + resized.Create(rh, rw, MatType.CV_8UC3); + resized.SetTo(new Scalar(114, 114, 114)); + scaled.CopyTo(resized[new Rect((rw - newW) / 2, (rh - newH) / 2, newW, newH)]); + _letterContentW = newW; + _letterContentH = newH; + } + else if (crop) + { + double scale = Math.Max((double)rw / mat.Width, (double)rh / mat.Height); + int newW = (int)(mat.Width * scale + 0.5); + int newH = (int)(mat.Height * scale + 0.5); + using var canvas = new Mat(); + Cv2.Resize(mat, canvas, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); + canvas[new Rect((newW - rw) / 2, (newH - rh) / 2, rw, rh)].CopyTo(resized); + _letterContentW = 0; + _letterContentH = 0; + } + else + { + Cv2.Resize(mat, resized, new Size(rw, rh), 0, 0, InterpolationFlags.Linear); + _letterContentW = 0; + _letterContentH = 0; + } + + float[] data; + int[] shape; + if (baseMode == "gray") + { + using var gray = new Mat(); + Cv2.CvtColor(resized, gray, ColorConversionCodes.BGR2GRAY); + data = new float[rw * rh]; + MarshalPlaneCopy(gray, data, 0, rw * rh, 0); + shape = [1, 1, rh, rw]; + } + else + { + // "rgb"(letter / crop 变体亦走 RGB:YOLO 与分类模型的输入约定) + using var rgb = new Mat(); + Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); + data = new float[rw * rh * 3]; + MarshalPlaneCopy(rgb, data, 0, rw * rh, 0); // R + MarshalPlaneCopy(rgb, data, rw * rh, rw * rh, 1); // G + MarshalPlaneCopy(rgb, data, 2 * rw * rh, rw * rh, 2); // B + shape = [1, 3, rh, rw]; + } + + return Value.FromPtr(inference.HoldTensor(data, shape)); } catch + { + return Value.FromPtr(0); + } + } + + /// 把 CV_8UC1/8UC3 Mat 的单个通道拷入目标数组(interleave 布局 → 平面布局)。 + static unsafe void MarshalPlaneCopy(Mat source, float[] target, int targetOffset, int planeLength, int sourceChannel) + { + int channels = source.Channels(); + byte* p = (byte*)source.Data; + for (int i = 0; i < planeLength; i++) + target[targetOffset + i] = p[i * channels + sourceChannel]; + } + + public static Value ImplNetRunH(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + IInference? inference = capabilities.Inference; + if (inference == null) { _lastNetOutput = null; - return Value.FromInt(0); // 实验面:非法输入(非数组/未知会话)→ 空输出 + return Value.FromInt(0); } + + float[]? output = inference.RunHeld((int)args[0].AsPtr(), (int)args[1].AsPtr()); + _lastNetOutput = output; + return Value.FromInt(output?.Length ?? 0); } - /// 数值元素容错转换(数组字面量/变量可能是任意数值标签)。 - static float ToFloat(Value v) => v.ToObject() switch + public static Value ImplNetScale(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + => Value.FromPtr(capabilities.Inference?.TransformTensor( + (int)args[0].AsPtr(), (float)args[1].AsDouble(), (float)args[2].AsDouble()) ?? 0); + + public static Value ImplNetFree(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { - double d => (float)d, - int i => i, - long l => l, - uint ui => ui, - ulong ul => ul, - byte b => b, - bool bo => bo ? 1f : 0f, - _ => throw new InvalidCastException("NET_RUN 输入必须为数值数组"), - }; + capabilities.Inference?.FreeTensor((int)args[0].AsPtr()); + return Value.FromInt(0); + } - public static Value ImplNetOut(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + public static Value ImplNetUnload(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { + capabilities.Inference?.Unload((int)args[0].AsPtr()); + return Value.FromInt(0); + } + + /// + /// NET_ROWS:最近一次推理输出的行数(末维 = 类别维:rows = 前 n-1 维之积)。 + /// [1,T,C] → T;向量输出 [1,N] → 1。无输出历史返回 0。 + /// + public static Value ImplNetRows(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var shape = capabilities.Inference?.LastOutputShape; + if (shape == null || shape.Length < 1 || shape[^1] <= 0) + return Value.FromInt(0); + long rows = 1; + for (int i = 0; i < shape.Length - 1; i++) + { + if (shape[i] <= 0) + return Value.FromInt(0); + rows *= shape[i]; + } + return Value.FromInt(rows > int.MaxValue ? int.MaxValue : (int)rows); + } + + /// + /// NET_ARGMAX(row):输出第 row 行的最大值列下标(分类模型的逐行类别)。 + /// 末维 = 类别维:[1,T,C] 按 T 行 C 列;向量输出 [1,N](或 [N])按 1 行 N 列。 + /// 无输出历史 / 行越界返回 -1。 + /// + public static Value ImplNetArgMax(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var inference = capabilities.Inference; + var shape = inference?.LastOutputShape; var output = _lastNetOutput; - int index = args[0].AsInt(); - if (output == null || index < 0 || index >= output.Length) - return Value.FromDouble(0); - return Value.FromDouble(output[index]); + if (inference == null || shape == null || output == null || shape.Length < 1) + return Value.FromInt(-1); + + int cols = shape[^1]; + long rows = cols > 0 ? output.LongLength / cols : 0; + int row = args[0].AsInt(); + if (cols <= 0 || row < 0 || row >= rows) + return Value.FromInt(-1); + + int best = 0; + float bestVal = float.MinValue; + int baseIdx = row * cols; + for (int c = 0; c < cols; c++) + { + if (output[baseIdx + c] > bestVal) + { + bestVal = output[baseIdx + c]; + best = c; + } + } + return Value.FromInt(best); } /// - /// __OCR_INIT__ 洞:旧位置参数签名映射 - /// (lang → Language,dataPath → ModelPath,engineMode/psmode → Options["tess:*"])。 - /// 未装配 OCR 能力返回 false。 + /// __CTC_FOLD__ 洞:CTC 贪心折叠最近一次 NET_RUNH 输出。宿主不做形状假设—— + /// 按末维约定解释(cols = 末维,rows = 元素数/cols;[1,T,C] 即 T 帧 C 列)。 + /// 逐帧 argmax 后:连续同类别去重、blank 帧重置、charset 窗口 [base, base+len) 外的 + /// 类别跳过(仍参与去重序列);窗口内类别 c 输出 charset[c-base]。一次宿主遍历, + /// 无逐帧 VM 往返。配方语义(ppocr 字典下标、数字子集)由 std CTC_FOLD 传入; + /// 自实现解码策略走 NET_ROWS + NET_ARGMAX 逐帧遍历。 /// - public static Value ImplOcrInitHole(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + public static Value ImplCtcFoldHole(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) { - var cfg = new OcrConfig + var shape = capabilities.Inference?.LastOutputShape; + var output = _lastNetOutput; + if (shape == null || output == null || shape.Length < 1 || shape[^1] <= 0) + return Value.FromString(""); + + int blank = args[0].AsInt(); + var charset = args[1].AsString(); + int baseId = args[2].AsInt(); + int cols = shape[^1]; + long rows = output.LongLength / cols; + + var sb = new StringBuilder(); + int prev = -1; + for (int row = 0; row < rows; row++) { - Language = args[0].AsString(), - ModelPath = args[1].AsString(), - Options = new Dictionary + int best = 0; + float bestVal = float.MinValue; + int baseIdx = row * cols; + for (int c = 0; c < cols; c++) { - ["tess:engineMode"] = args[2].AsString(), - ["tess:psmode"] = args[3].AsString(), - }, - }; - return Value.FromBool(capabilities.Ocr?.Init(cfg) ?? false); + if (output[baseIdx + c] > bestVal) + { + bestVal = output[baseIdx + c]; + best = c; + } + } + if (best == blank) + { + prev = -1; + } + else + { + if (best != prev && best >= baseId && best < baseId + charset.Length) + sb.Append(charset[best - baseId]); + prev = best; + } + } + return Value.FromString(sb.ToString()); + } + + /// + /// NET_NORM(tensor, mean, std):逐通道归一化 (x - mean[c]) / std[c],通道号由宿主按 + /// 张量形状推断(IInference.Normalize)。返回新句柄,失败 -1。/255 不烧进来—— + /// 量程折叠由 NET_SCALE 链式承担,每步都是通用原语(预处理策略留在脚本层)。 + /// + public static Value ImplNetNorm(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var inference = capabilities.Inference; + if (inference == null) + return Value.FromPtr(-1); + try + { + var meanArr = args[1].AsArray(); + var mean = new float[meanArr.Length]; + for (int i = 0; i < meanArr.Length; i++) + mean[i] = (float)meanArr[i].AsDouble(); + var stdArr = args[2].AsArray(); + var std = new float[stdArr.Length]; + for (int i = 0; i < stdArr.Length; i++) + std[i] = (float)stdArr[i].AsDouble(); + return Value.FromPtr(inference.Normalize((int)args[0].AsPtr(), mean, std)); + } + catch + { + return Value.FromPtr(-1); + } } + // ---- 标签表(NET_LABELS / NET_LABEL):分类结果下标 → 类别名。纯字符串处理, + // 不挂 Vision 特征位;表驻宿主静态槽(与 _lastNetOutput 同款会话内共享)---- + + static readonly object _labelGate = new(); + static string[] _labels = []; + + /// + /// NET_LABELS(path):加载标签文件,返回标签数,失败 -1(含读失败与空文件);重复加载整体替换。 + /// 字典级语义:行号即索引——除文件末尾换行的幻影项外逐行原样占位(空行/空白字符行 + /// 也是条目,如 PP-OCR 字典首行的全角空格),仅去行尾 CR。加锁保护。 + /// + public static Value ImplNetLabels(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var text = Files(capabilities).ReadAllText(args[0].AsString()); + if (text.Length == 0) + return Value.FromInt(-1); // 读失败(IFileSystem 契约 = 空串)与空文件同为加载失败 + + var lines = text.Split('\n'); + if (lines.Length > 0 && lines[^1].Length == 0) + lines = lines[..^1]; // 结尾换行的幻影空项(真实空行占位不动) + var labels = new string[lines.Length]; + for (int i = 0; i < lines.Length; i++) + labels[i] = lines[i].EndsWith('\r') ? lines[i][..^1] : lines[i]; + lock (_labelGate) + _labels = labels; + return Value.FromInt(labels.Length); + } + + /// NET_LABEL(index):第 index 个标签;未加载 / 越界返回 ""。 + public static Value ImplNetLabel(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + lock (_labelGate) + { + int i = args[0].AsInt(); + return i >= 0 && i < _labels.Length ? Value.FromString(_labels[i]) : Value.FromString(""); + } + } + + // ---- YOLO26 端到端解码(__YOLO_DECODE__ 洞 + YOLO_* 访问器)---- + // 读最近一次 NET_RUNH 输出(端到端导出 [·,300,6] = x1,y1,x2,y2,conf,cls, + // letterbox 像素空间;argmax 已由模型内部完成——列语义异质,NET_ARGMAX 不适用), + // 逐行按置信度过滤,经 (v - pad)/scale + roi 逆映射回屏幕坐标缓存命中列表。 + // 逆映射参数由 std YOLO_DETECT 传入(配方持语义);自实现解码策略走 + // NET_ROWS + NET_OUT(i*6+j) 扁平寻址。 + + [ThreadStatic] static List? _yoloHits; + + /// + /// __YOLO_DECODE__ 洞:按 (thresh, scale, padX, padY, roiX, roiY) 解码最近输出。 + /// 宿主不做形状假设——按末维约定解释(cols = 末维,rows = 元素数/cols), + /// 逐行按置信度过滤,先裁剪回 letterbox 内容区再逆映射(内容区尺寸取自最近 + /// "letter" __NET_IMAGE__ 线程槽;Ultralytics predict 后处理同约定)。 + /// 端到端 stride 校验(末维 = 6)由 std YOLO_DETECT 承担。返回命中数; + /// 输出缺失或 scale ≤ 0 返回 -1。 + /// + public static Value ImplYoloDecode(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var hits = _yoloHits ??= []; + hits.Clear(); + + var shape = capabilities.Inference?.LastOutputShape; + var output = _lastNetOutput; + if (shape == null || output == null || shape.Length < 1) + return Value.FromInt(-1); + + float thresh = (float)args[0].AsDouble(); + float scale = (float)args[1].AsDouble(); + if (scale <= 0) + return Value.FromInt(-1); + float padX = (float)args[2].AsDouble(); + float padY = (float)args[3].AsDouble(); + float roiX = args[4].AsInt(); + float roiY = args[5].AsInt(); + float contentW = _letterContentW; + float contentH = _letterContentH; + + int cols = shape[^1]; + long rows = cols > 0 ? output.LongLength / cols : 0; + for (long i = 0; i < rows; i++) + { + long b = i * cols; + float score = output[b + 4]; + if (score < thresh) + continue; + float Map(float v, float pad, float roi, float content) + { + if (content > 0) + v = Math.Clamp(v, pad, pad + content); + return (v - pad) / scale + roi; + } + hits.Add( + [ + Map(output[b + 0], padX, roiX, contentW), + Map(output[b + 1], padY, roiY, contentH), + Map(output[b + 2], padX, roiX, contentW), + Map(output[b + 3], padY, roiY, contentH), + score, + output[b + 5], + ]); + } + return Value.FromInt(hits.Count); + } + + static List YoloHits() => _yoloHits ?? []; + + /// YOLO_COUNT():最近一次解码的命中数(未解码过为 0)。 + public static Value ImplYoloCount(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + => Value.FromInt(YoloHits().Count); + + /// YOLO_* 访问器:索引最近命中列表;越界/未解码返回 -1(分数为 -1.0)。 + public static Value ImplYoloCls(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var hits = YoloHits(); + int i = args[0].AsInt(); + return i >= 0 && i < hits.Count ? Value.FromInt((int)hits[i][5]) : Value.FromInt(-1); + } + + public static Value ImplYoloScore(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var hits = YoloHits(); + int i = args[0].AsInt(); + return i >= 0 && i < hits.Count ? Value.FromDouble(hits[i][4]) : Value.FromDouble(-1.0); + } + + static Value YoloCoord(ReadOnlySpan args, int field) + { + var hits = YoloHits(); + int i = args[0].AsInt(); + return i >= 0 && i < hits.Count + ? Value.FromInt((int)MathF.Round(hits[i][field], MidpointRounding.AwayFromZero)) + : Value.FromInt(-1); + } + + public static Value ImplYoloX1(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => YoloCoord(args, 0); + public static Value ImplYoloY1(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => YoloCoord(args, 1); + public static Value ImplYoloX2(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => YoloCoord(args, 2); + public static Value ImplYoloY2(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => YoloCoord(args, 3); + + // ---- PP-OCR det 找框(__DET_BOXES__ 洞 + DET_* 访问器)---- + // 读最近一次 NET_RUNH 输出的扁平概率数据,阈值二值化 + 连通域外轮廓 → 框列表 + // (det 空间滤噪 <3px → 逆映射 screen = det/scale + roi → ±2px unclip 扩张), + // 阅读序(y 优先 x 次之)缓存。宿主不做任何形状假设:概率图几何 (mapW×mapH) + // 由 std PP_DET_FIND 传入(即配方自知的 det 输入尺寸);自实现解码策略走 + // 概率图原语另行遍历。 + + [ThreadStatic] static List? _detBoxes; + + /// + /// __DET_BOXES__ 洞:按 (thresh, scale, roiX, roiY, mapW, mapH) 在最近概率图上找文本框。 + /// 宿主不做任何形状假设——几何参数由配方传入,洞只按参数读取扁平数组; + /// 输出缺失、scale ≤ 0 或数据不足(模型输出与配方几何不符,防越界)返回 -1。 + /// + public static Value ImplDetBoxes(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + { + var boxes = _detBoxes ??= []; + boxes.Clear(); + + var output = _lastNetOutput; + if (output == null) + return Value.FromInt(-1); + + double thresh = args[0].AsDouble(); + double scale = args[1].AsDouble(); + if (scale <= 0) + return Value.FromInt(-1); + int roiX = args[2].AsInt(); + int roiY = args[3].AsInt(); + int w = args[4].AsInt(); + int h = args[5].AsInt(); + if (w <= 0 || h <= 0 || output.LongLength < (long)w * h) + return Value.FromInt(-1); // 数据不足防越界,非布局假设 + + using var prob = Mat.FromPixelData(h, w, MatType.CV_32FC1, output); + using var bin = new Mat(); + Cv2.Threshold(prob, bin, thresh, 255, ThresholdTypes.Binary); + using var bin8 = new Mat(); + bin.ConvertTo(bin8, MatType.CV_8UC1); + // 水平膨胀(1×15 核):同一文字行内的数字/单词间隙愈合,碎框合并为整行框 + // (DB 后处理标准手法;横排 UI 假设——竖排文本行内间隙不愈合,v1 不支持) + using var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(15, 1)); + Cv2.Dilate(bin8, bin8, kernel); + Cv2.FindContours(bin8, out Point[][] contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); + + foreach (var contour in contours) + { + var r = Cv2.BoundingRect(contour); + if (r.Width < 3 || r.Height < 3) // det 空间噪声过滤 + continue; + // unclip(PaddleOCR 同式):offset = area × ratio / perimeter,v1 ratio=1.0—— + // DB 框贴字紧缩,rec 需要留白。ratio=2.0(官方上限)在整页 UI 截图上会把大框 + // 扩成数千像素宽的 rec 输入,DNN 进程崩溃——上调 ratio 前必须先做框尺寸护栏 + double offset = 1.0 * r.Width * r.Height / (2.0 * (r.Width + r.Height)); + int dx = (int)Math.Round(offset / scale, MidpointRounding.AwayFromZero); + boxes.Add( + [ + (int)Math.Round(r.X / scale, MidpointRounding.AwayFromZero) + roiX - dx, + (int)Math.Round(r.Y / scale, MidpointRounding.AwayFromZero) + roiY - dx, + (int)Math.Round(r.Width / scale, MidpointRounding.AwayFromZero) + 2 * dx, + (int)Math.Round(r.Height / scale, MidpointRounding.AwayFromZero) + 2 * dx, + ]); + } + boxes.Sort((a, b) => a[1] != b[1] ? a[1].CompareTo(b[1]) : a[0].CompareTo(b[0])); + return Value.FromInt(boxes.Count); + } + + static List DetBoxes() => _detBoxes ?? []; + + /// DET_COUNT():最近一次找框的框数(未找过为 0)。 + public static Value ImplDetCount(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) + => Value.FromInt(DetBoxes().Count); + + static Value DetField(ReadOnlySpan args, int field) + { + var boxes = DetBoxes(); + int i = args[0].AsInt(); + return i >= 0 && i < boxes.Count ? Value.FromInt(boxes[i][field]) : Value.FromInt(-1); + } + + public static Value ImplDetX(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => DetField(args, 0); + public static Value ImplDetY(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => DetField(args, 1); + public static Value ImplDetW(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => DetField(args, 2); + public static Value ImplDetH(ReadOnlySpan args, CapabilitySet capabilities, CancellationToken token) => DetField(args, 3); + /// 文件能力缺省回落桌面参考实现(FCLOSE 句柄表清理随 DesktopFileSystem.Instance)。 static IFileSystem Files(CapabilitySet capabilities) => capabilities.Files ?? DesktopFileSystem.Instance; @@ -249,22 +747,18 @@ static IFileSystem Files(CapabilitySet capabilities) { return [ - (BuiltinFunctions.StrEncode, new DelegateCallable(ImplStrEncode)), - (BuiltinFunctions.Jq, new DelegateCallable(ImplJq)), + (BuiltinFunctions.EncodeHole, new DelegateCallable(ImplStrEncode)), + (BuiltinFunctions.JqHole, new DelegateCallable(ImplJq)), // 文件 IO (BuiltinFunctions.FOpen, new DelegateCallable(ImplFOpen)), (BuiltinFunctions.FRead, new DelegateCallable(ImplFRead)), (BuiltinFunctions.FWrite, new DelegateCallable(ImplFWrite)), (BuiltinFunctions.FClose, new DelegateCallable(ImplFClose)), (BuiltinFunctions.FEof, new DelegateCallable(ImplFEof)), - (BuiltinFunctions.ReadFile, new DelegateCallable(ImplReadFile)), - (BuiltinFunctions.WriteFile, new DelegateCallable(ImplWriteFile)), - (BuiltinFunctions.AppendFile, new DelegateCallable(ImplAppendFile)), (BuiltinFunctions.FileExists, new DelegateCallable(ImplFileExists)), (BuiltinFunctions.OcrConf, new DelegateCallable(ImplOcrConf)), // ONNX 推理实验 (BuiltinFunctions.NetLoad, new DelegateCallable(ImplNetLoad)), - (BuiltinFunctions.NetRun, new DelegateCallable(ImplNetRun)), (BuiltinFunctions.NetOut, new DelegateCallable(ImplNetOut)), ]; } @@ -277,9 +771,36 @@ static IFileSystem Files(CapabilitySet capabilities) return [ (BuiltinFunctions.CaptureHole, new DelegateCallable(ImplCaptureHole)), - (BuiltinFunctions.OcrHole, new DelegateCallable(ImplOcrHole)), + (BuiltinFunctions.TesseractHole, new DelegateCallable(ImplTesseractHole)), (BuiltinFunctions.RoiHole, new DelegateCallable(ImplRoiHole)), - (BuiltinFunctions.OcrInitHole, new DelegateCallable(ImplOcrInitHole)), + (BuiltinFunctions.TesseractInitHole, new DelegateCallable(ImplTesseractInitHole)), + (BuiltinFunctions.NetImageHole, new DelegateCallable(ImplNetImage)), + (BuiltinFunctions.NetRunH, new DelegateCallable(ImplNetRunH)), + (BuiltinFunctions.NetScale, new DelegateCallable(ImplNetScale)), + (BuiltinFunctions.NetFree, new DelegateCallable(ImplNetFree)), + (BuiltinFunctions.NetUnload, new DelegateCallable(ImplNetUnload)), + (BuiltinFunctions.NetArgMax, new DelegateCallable(ImplNetArgMax)), + (BuiltinFunctions.NetRows, new DelegateCallable(ImplNetRows)), + (BuiltinFunctions.NetNorm, new DelegateCallable(ImplNetNorm)), + (BuiltinFunctions.NetImageFileHole, new DelegateCallable(ImplNetImageFile)), + (BuiltinFunctions.NetLabels, new DelegateCallable(ImplNetLabels)), + (BuiltinFunctions.NetLabel, new DelegateCallable(ImplNetLabel)), + (BuiltinFunctions.CtcFoldHole, new DelegateCallable(ImplCtcFoldHole)), + (BuiltinFunctions.YoloDecodeHole, new DelegateCallable(ImplYoloDecode)), + (BuiltinFunctions.YoloCount, new DelegateCallable(ImplYoloCount)), + (BuiltinFunctions.YoloCls, new DelegateCallable(ImplYoloCls)), + (BuiltinFunctions.YoloScore, new DelegateCallable(ImplYoloScore)), + (BuiltinFunctions.YoloX1, new DelegateCallable(ImplYoloX1)), + (BuiltinFunctions.YoloY1, new DelegateCallable(ImplYoloY1)), + (BuiltinFunctions.YoloX2, new DelegateCallable(ImplYoloX2)), + (BuiltinFunctions.YoloY2, new DelegateCallable(ImplYoloY2)), + (BuiltinFunctions.DetBoxesHole, new DelegateCallable(ImplDetBoxes)), + (BuiltinFunctions.DetCount, new DelegateCallable(ImplDetCount)), + (BuiltinFunctions.DetX, new DelegateCallable(ImplDetX)), + (BuiltinFunctions.DetY, new DelegateCallable(ImplDetY)), + (BuiltinFunctions.DetW, new DelegateCallable(ImplDetW)), + (BuiltinFunctions.DetH, new DelegateCallable(ImplDetH)), + ]; } } \ No newline at end of file diff --git a/src/EasyCon.Core/Runner/EcxVm.cs b/src/EasyCon.Core/Runner/EcxVm.cs index cc9850c0..5fa4a95c 100644 --- a/src/EasyCon.Core/Runner/EcxVm.cs +++ b/src/EasyCon.Core/Runner/EcxVm.cs @@ -37,10 +37,13 @@ public static Value Run(EcxImage image, CapabilitySet capabilities, Cancellation externMap.TryAdd((sym.LibraryName, sym.ExternalName), sym); } NativeLoader? loader = null; + IAudioSession? audio = null; var host = BuildHost(capabilities, token, args, startTicks, rand); host.Native = (name, argv, ctx) => { + if (name is "AUDIO_LOAD" or "AUDIO_SCORE" or "AUDIO_UNLOAD") + return ctx.FromValue(BuiltinCallable.InvokeAudio(name, ToValues(argv, ctx), audio, token)); // 内建/采集洞:复用金标准的 BuiltinCallable 实现(文件 IO/ENCODE/JQ 等) if (BuiltinMap.TryGetValue(name, out var callable)) return ctx.FromValue(callable.Invoke(ToValues(argv, ctx), capabilities, token)); @@ -59,9 +62,19 @@ public static Value Run(EcxImage image, CapabilitySet capabilities, Cancellation try { + token.ThrowIfCancellationRequested(); + if ((image.Features & EcsImageFeatures.Audio) != 0) + { + IAudioService service = capabilities.Audio + ?? throw new InvalidOperationException("当前宿主未提供脚本音频能力 AUDIO_LOAD/AUDIO_SCORE/AUDIO_UNLOAD;请使用已连接游戏音频的桌面 GUI。"); + audio = service.BeginRun(token); + } int code = EcxInterpreter.Run(image, host, out _, out var errorFunc, out var errorPc, out var result, token); if (code == EcxInterpreter.OK) + { + audio?.CheckHealth(token); return result; + } if (code == EcxInterpreter.CANCELLED) throw new OperationCanceledException(token); // Address 语义 = 源码行(1 基,经行号表映射;无表回退 0),pc 保留在消息里供诊断 @@ -70,8 +83,23 @@ public static Value Run(EcxImage image, CapabilitySet capabilities, Cancellation throw new ScriptException( $"!!运行出错!!{Describe(code)}(函数 {image.FunctionName(errorFunc)},指令 {errorPc})", sourceLine); } + catch (OperationCanceledException) + { + throw; + } + catch (ScriptException) + { + throw; + } + catch (Exception ex) + { + // 宿主委托(视觉匹配/BEEP/原生 FFI 等)抛出的异常不带 VM 错误码, + // 在此统一包装成 ScriptException,兑现 IScriptSession 的错误契约 + throw new ScriptException($"!!运行出错!!宿主能力异常: {ex.Message}"); + } finally { + audio?.Dispose(); DesktopFileSystem.Instance.CloseAllFiles(); } } @@ -94,7 +122,7 @@ static EcxHost BuildHost(CapabilitySet capabilities, CancellationToken token, st : throw new Exception("图像标签匹配器未初始化"), Print = (s, newline) => console?.Print(s, newline), Alert = s => console?.Alert(s), - ReadLine = () => Console.ReadLine() ?? "", // v1 FREAD stdin 走控制台(ImplFRead) + ReadLine = () => console != null ? console.ReadLine() : Console.ReadLine() ?? "", Key = (k, d) => input?.ClickButtons((GamePadKey)k, d, token), KeyState = (k, d) => { @@ -110,7 +138,10 @@ static EcxHost BuildHost(CapabilitySet capabilities, CancellationToken token, st { if (f is < 37 or > 32767) throw new Exception("BEEP参数freq范围不正确(37~32767)"); - Console.Beep(f, d); + if (console != null) + console.Beep(f, d); + else + Console.Beep(f, d); }, }; } @@ -129,10 +160,14 @@ static Value[] ToValues(TaggedValue[] argv, EcxNativeContext ctx) static string Describe(int code) => code switch { + EcxInterpreter.ERR_OPCODE => "非法操作码", + EcxInterpreter.ERR_SLOT => "槽位/索引越界", EcxInterpreter.ERR_DIVZERO => "整数除零", EcxInterpreter.ERR_INDEX => "下标越界", EcxInterpreter.ERR_TYPE => "类型错误", + EcxInterpreter.ERR_DEPTH => "调用深度超限", EcxInterpreter.ERR_NOSUCHNATIVE => "原生函数未实现", + EcxInterpreter.ERR_OOM => "解释器内存不足", _ => $"虚拟机错误码 {code}", }; } \ No newline at end of file diff --git a/src/EasyCon.Core/Runner/NativeLoader.cs b/src/EasyCon.Core/Runner/NativeLoader.cs index 1a86fb13..b37ecc70 100644 --- a/src/EasyCon.Core/Runner/NativeLoader.cs +++ b/src/EasyCon.Core/Runner/NativeLoader.cs @@ -47,7 +47,8 @@ internal ICallable ResolveFunction(FunctionSymbol symbol) try { return NativeLibrary.Load(name); } catch (Exception ex) { - throw new ScriptException($"无法加载库 \"{name}\": {ex.Message}", 0); + string detail = NativeLibraryLoadDetail(name, ex); + throw new ScriptException($"无法加载原生库 \"{name}\"。{detail} {ex.Message}", ex); } }); @@ -56,13 +57,28 @@ internal ICallable ResolveFunction(FunctionSymbol symbol) try { return NativeLibrary.GetExport(handle, symbol.ExternalName); } catch (Exception ex) { - throw new ScriptException($"在 \"{libName}\" 中未找到导出函数 \"{symbol.ExternalName}\": {ex.Message}", 0); + throw new ScriptException( + $"在原生库 \"{libName}\" 中未找到导出函数 \"{symbol.ExternalName}\": {ex.Message}", ex); } }); return CreateCallable(symbol, funcPtr); } + private static string NativeLibraryLoadDetail(string libraryName, Exception exception) + { + bool pathLike = Path.IsPathFullyQualified(libraryName) + || libraryName.Contains('/') + || libraryName.Contains('\\'); + if (pathLike && !File.Exists(libraryName)) + return "声明路径对应的文件不存在。"; + if (exception is BadImageFormatException) + return "文件格式或进程位数不兼容,也可能是其原生依赖项不兼容。"; + if (exception is DllNotFoundException && pathLike) + return "目标文件存在,但系统加载器未能加载该文件或其原生依赖项。"; + return "系统原生加载器返回失败。"; + } + [RequiresDynamicCode("Calls System.Runtime.InteropServices.Marshal.GetDelegateForFunctionPointer(nint, Type)")] private ICallable CreateCallable(FunctionSymbol symbol, IntPtr funcPtr) { diff --git a/src/EasyCon.Core/Script/EasyScriptEngine.cs b/src/EasyCon.Core/Script/EasyScriptEngine.cs index 2880c514..e4332952 100644 --- a/src/EasyCon.Core/Script/EasyScriptEngine.cs +++ b/src/EasyCon.Core/Script/EasyScriptEngine.cs @@ -16,6 +16,12 @@ public IScriptSession FromSource(string code, ScriptHostOptions options) return new Session(Compilation.CompileSource(code, options.Compile), options); } + public IScriptSession FromSource(string code, string fileName, ScriptHostOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return new Session(Compilation.CompileSource(code, fileName, options.Compile), options); + } + public IScriptSession LoadFile(string path, ScriptHostOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/EasyCon.Core/Script/EcxExportService.cs b/src/EasyCon.Core/Script/EcxExportService.cs new file mode 100644 index 00000000..a1c3d968 --- /dev/null +++ b/src/EasyCon.Core/Script/EcxExportService.cs @@ -0,0 +1,59 @@ +using EasyCon.Core.Hosting; +using EasyCon.Script; +using EasyCon.Script.Bytecode; +using EasyCon.Script.Text; + +namespace EasyCon.Core.Script; + +/// 统一的 ECX 分发导出结果;CLI 和 GUI 使用同一编译/容器化路径。 +public sealed class EcxExportResult +{ + internal EcxExportResult(CompileResult compilation, byte[]? bytes, IReadOnlyList diagnostics) + { + Compilation = compilation; + Bytes = bytes; + Diagnostics = diagnostics; + } + + public CompileResult Compilation { get; } + public byte[]? Bytes { get; } + public IReadOnlyList Diagnostics { get; } + public bool Success => Bytes is not null && !Diagnostics.HasErrors(); +} + +/// +/// 以 Distributable 编译档生成 ECX 容器。该类不负责烧录或固件封装。 +/// +public static class EcxExportService +{ + public static EcxExportResult Export( + string source, + string? sourcePath, + IEnumerable? externalLabels = null, + bool stripDebug = true) + { + ArgumentNullException.ThrowIfNull(source); + + CompileOptions options = ScriptCompileProfiles.Distributable(externalLabels); + CompileResult compilation = string.IsNullOrWhiteSpace(sourcePath) + ? Compilation.CompileSource(source, options) + : Compilation.CompileSource(source, Path.GetFullPath(sourcePath), options); + List diagnostics = compilation.Diagnostics.DistinctStable().ToList(); + if (compilation.Image is null || diagnostics.HasErrors()) + return new EcxExportResult(compilation, null, diagnostics); + + try + { + byte[] bytes = EcsContainer.WriteImage(compilation.Image, stripDebug: stripDebug); + return new EcxExportResult(compilation, bytes, diagnostics); + } + catch (Exception ex) + { + TextLocation location = new( + SourceText.From("", sourcePath ?? ""), + new SourceSpan(0, 0)); + diagnostics.Add(Diagnostic.Error(location, ex.Message, DiagnosticCodes.ContainerImage)); + return new EcxExportResult(compilation, null, diagnostics.DistinctStable().ToList()); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Core/Script/IScriptEngine.cs b/src/EasyCon.Core/Script/IScriptEngine.cs index 8228c7b1..516a58b9 100644 --- a/src/EasyCon.Core/Script/IScriptEngine.cs +++ b/src/EasyCon.Core/Script/IScriptEngine.cs @@ -25,6 +25,9 @@ public interface IScriptEngine /// 编译源码并返回会话(诊断经 查看)。 IScriptSession FromSource(string code, ScriptHostOptions options); + /// 编译内存源码并按源文件路径解析 IMPORT 与根级 lib/。 + IScriptSession FromSource(string code, string fileName, ScriptHostOptions options); + /// 编译脚本文件并返回会话。 IScriptSession LoadFile(string path, ScriptHostOptions options); } diff --git a/src/EasyCon.Core/Services/IAlertService.cs b/src/EasyCon.Core/Services/IAlertService.cs new file mode 100644 index 00000000..ad53ce1a --- /dev/null +++ b/src/EasyCon.Core/Services/IAlertService.cs @@ -0,0 +1,9 @@ +#nullable enable + +namespace EasyCon.Core.Services; + +/// 桌面通知能力。调用方不应等待网络发送完成。 +public interface IAlertService +{ + void Dispatch(string content, string title = "伊机控消息", byte[]? image = null); +} \ No newline at end of file diff --git a/src/EasyCon.Core/Services/IFirmwareService.cs b/src/EasyCon.Core/Services/IFirmwareService.cs deleted file mode 100644 index 6faafc10..00000000 --- a/src/EasyCon.Core/Services/IFirmwareService.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace EasyCon.Core.Services; - -public interface IFirmwareService -{ - BoardInfo[] GetSupportedBoards(); - Task GenerateFirmware(int boardIndex, string scriptText, string? fileName); - Task Flash(int boardIndex, bool autoRun, string scriptText, string? fileName); - Task FlashClear(); -} - -public record BoardInfo(string DisplayName, string CoreName, int DataSize); \ No newline at end of file diff --git a/src/EasyCon.Core/deps/btkeylib.dll b/src/EasyCon.Core/deps/btkeylib.dll deleted file mode 100644 index c00277bf..00000000 Binary files a/src/EasyCon.Core/deps/btkeylib.dll and /dev/null differ diff --git a/src/EasyCon.Core/deps/libamiibo.dll b/src/EasyCon.Core/deps/libamiibo.dll deleted file mode 100644 index 6d7ff706..00000000 Binary files a/src/EasyCon.Core/deps/libamiibo.dll and /dev/null differ diff --git a/src/EasyCon.Core/deps/libwdi.dll b/src/EasyCon.Core/deps/libwdi.dll deleted file mode 100644 index b177bda0..00000000 Binary files a/src/EasyCon.Core/deps/libwdi.dll and /dev/null differ diff --git a/src/EasyCon.Device/Connection/ConnectionEndpoint.cs b/src/EasyCon.Device/Connection/ConnectionEndpoint.cs new file mode 100644 index 00000000..4f8288d3 --- /dev/null +++ b/src/EasyCon.Device/Connection/ConnectionEndpoint.cs @@ -0,0 +1,56 @@ +using System.Net; + +namespace EasyDevice.Connection; + +public enum ConnectionTransport { Serial, TcpBridge } + +/// 传输地址和资源标识。固件协议不参与地址解析或来源占用锁。 +public sealed record ConnectionEndpoint +{ + private ConnectionEndpoint(ConnectionTransport transport, string address, string? host = null, int? port = null) + { + Transport = transport; + Address = address; + Host = host; + Port = port; + } + + public ConnectionTransport Transport { get; } + public string Address { get; } + public string? Host { get; } + public int? Port { get; } + public string OwnershipKey => Transport == ConnectionTransport.Serial ? "serial:" + Address : Address; + + public static ConnectionEndpoint Parse(string address) + { + ArgumentException.ThrowIfNullOrWhiteSpace(address); + string endpoint = address.Trim(); + if (endpoint.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) || uri.Scheme != "tcp" + || uri.Port is < 1 or > 65535 || string.IsNullOrEmpty(uri.Host) + || uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0 + || uri.AbsolutePath is not ("" or "/")) + throw new ArgumentException("桥接地址应为 tcp://主机:端口,不能包含用户信息、路径或参数。", nameof(address)); + string host = uri.DnsSafeHost; + if (IPAddress.TryParse(host, out IPAddress? ip)) + host = ip.ToString(); + else + host = uri.IdnHost.ToLowerInvariant(); + string canonical = new UriBuilder("tcp", host, uri.Port).Uri.AbsoluteUri.TrimEnd('/'); + return new ConnectionEndpoint(ConnectionTransport.TcpBridge, canonical, host, uri.Port); + } + + if (OperatingSystem.IsWindows()) + { + endpoint = endpoint.StartsWith(@"\\.\", StringComparison.Ordinal) ? endpoint[4..] : endpoint; + if (endpoint.StartsWith("COM", StringComparison.OrdinalIgnoreCase) && endpoint.Length > 3 && endpoint[3..].All(char.IsAsciiDigit)) + endpoint = endpoint.ToUpperInvariant(); + } + else if (Path.IsPathRooted(endpoint)) + { + endpoint = new FileInfo(endpoint).ResolveLinkTarget(true)?.FullName ?? Path.GetFullPath(endpoint); + } + return new ConnectionEndpoint(ConnectionTransport.Serial, endpoint); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/IConnection.cs b/src/EasyCon.Device/Connection/IConnection.cs index 6e0d32ca..67d32d38 100644 --- a/src/EasyCon.Device/Connection/IConnection.cs +++ b/src/EasyCon.Device/Connection/IConnection.cs @@ -3,7 +3,7 @@ namespace EasyDevice.Connection; public delegate void BytesTransferedHandler(string comPort, byte[] bytes); public delegate void StatusChangedHandler(Status status); -abstract class IConnection +public abstract class IConnection { public virtual event BytesTransferedHandler BytesSent; public virtual event BytesTransferedHandler BytesReceived; @@ -11,10 +11,20 @@ abstract class IConnection public abstract Status CurrentStatus { get; protected set; } + public virtual int ConnectTimeoutMilliseconds => 1000; + public virtual bool SupportsLegacyCommands => true; + public virtual string FirmwareName => "EasyCon"; + public virtual string? FirmwareVersion => null; + public virtual bool? ConsoleConnected => null; + public virtual string? LastError => null; + public abstract void Connect(); public abstract void Disconnect(); public abstract void Write(params byte[] val); + /// 由连接选择报文编码;旧固件保持原有的 7 位打包格式。 + public virtual void WriteReport(SwitchReport report) => Write(report.GetBytes()); + /// /// 清空待发送队列,脚本终止时调用以丢弃尚未发出的 HID 报文。 /// diff --git a/src/EasyCon.Device/Connection/IConnectionProvider.cs b/src/EasyCon.Device/Connection/IConnectionProvider.cs new file mode 100644 index 00000000..72316977 --- /dev/null +++ b/src/EasyCon.Device/Connection/IConnectionProvider.cs @@ -0,0 +1,37 @@ +namespace EasyDevice.Connection; + +/// 按传输地址提供候选协议;工厂仅在轮到该候选时调用,避免提前占用硬件。 +public interface IConnectionProvider +{ + IEnumerable> GetCandidates(ConnectionEndpoint endpoint); +} + +public sealed record SerialProtocol(int BaudRate, Func CreateConnection); + +/// 串口的协议/波特率探测顺序由注册项决定,宿主无需识别固件名称。 +public sealed class SerialConnectionProvider(IEnumerable protocols) : IConnectionProvider +{ + private readonly SerialProtocol[] _protocols = [.. protocols]; + + public IEnumerable> GetCandidates(ConnectionEndpoint endpoint) + { + if (endpoint.Transport != ConnectionTransport.Serial) + yield break; + foreach (SerialProtocol protocol in _protocols) + yield return () => protocol.CreateConnection(endpoint.Address, protocol.BaudRate); + } +} + +/// TCP 桥接的协议适配器集合。蓝牙扫描、配对和连接由桥接服务实现。 +public sealed class BridgeConnectionProvider(IEnumerable> protocols) : IConnectionProvider +{ + private readonly Func[] _protocols = [.. protocols]; + + public IEnumerable> GetCandidates(ConnectionEndpoint endpoint) + { + if (endpoint.Transport != ConnectionTransport.TcpBridge) + yield break; + foreach (Func protocol in _protocols) + yield return () => protocol(endpoint); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/Pokeldn/IPokeldnTransport.cs b/src/EasyCon.Device/Connection/Pokeldn/IPokeldnTransport.cs new file mode 100644 index 00000000..9bed710f --- /dev/null +++ b/src/EasyCon.Device/Connection/Pokeldn/IPokeldnTransport.cs @@ -0,0 +1,12 @@ +namespace EasyDevice.Connection.Pokeldn; + +/// 每个连接只由一个工作线程访问;取消和超时必须使 I/O 有界退出。 +internal interface IPokeldnTransport : IDisposable +{ + int ConnectTimeoutMilliseconds { get; } + PokeldnStatus Connect(CancellationToken token); + PokeldnStatus GetStatus(CancellationToken token); + void SendReport(byte[] report, CancellationToken token); + void Stop(CancellationToken token); + void Disconnect(CancellationToken token); +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/Pokeldn/PokeldnConnection.cs b/src/EasyCon.Device/Connection/Pokeldn/PokeldnConnection.cs new file mode 100644 index 00000000..4e40aaa4 --- /dev/null +++ b/src/EasyCon.Device/Connection/Pokeldn/PokeldnConnection.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; + +namespace EasyDevice.Connection.Pokeldn; + +/// 串行请求/ACK、状态轮询和停止释放共用一条工作循环,不在 UI 线程进行 I/O。 +internal sealed class PokeldnConnection(string endpoint, IPokeldnTransport transport) : IConnection +{ + private readonly object _gate = new(); + private sealed record PendingReport(byte[] Payload, byte[] Packed); + private readonly Queue _queue = new(); + private readonly AutoResetEvent _wake = new(false); + private readonly CancellationTokenSource _cancel = new(); + private Task? _worker; + private PokeldnStatus? _padStatus; + private string? _lastError; + private int _disconnected; + private volatile Status _status = Status.Connecting; + + public override event BytesTransferedHandler BytesSent = delegate { }; + public override event StatusChangedHandler StatusChanged = delegate { }; + + public override int ConnectTimeoutMilliseconds => transport.ConnectTimeoutMilliseconds; + public override bool SupportsLegacyCommands => false; + public override string FirmwareName => transport is PokeldnSerialTransport ? "pokeldn-pad (串口)" : "pokeldn-pad (本地服务)"; + public override string? FirmwareVersion => Volatile.Read(ref _padStatus)?.Version; + public override bool? ConsoleConnected => Volatile.Read(ref _padStatus)?.Mounted; + public override string? LastError => _lastError; + + public override Status CurrentStatus + { + get => _status; + protected set + { + if (_status == value) + return; + _status = value; + StatusChanged(value); + } + } + + public override void Connect() + { + lock (_gate) + _worker ??= Task.Run(Run); + } + + public override void Write(params byte[] val) + => throw new NotSupportedException("pokeldn-pad 不支持伊机控原生固件命令。"); + + public override void WriteReport(SwitchReport report) + { + lock (_gate) + { + if (_disconnected != 0 || _cancel.IsCancellationRequested || CurrentStatus != Status.Connected) + return; + // 保留按下/释放边沿;慢 BLE 链路积压时明确报错,不能静默丢掉释放包。 + if (_queue.Count >= 256) + { + _lastError = "pokeldn 发送队列已满,请降低脚本按键频率。"; + _cancel.Cancel(); + return; + } + _queue.Enqueue(Snapshot(report)); + _wake.Set(); + } + } + + public override void ClearQueue() + { + lock (_gate) + { + _queue.Clear(); + if (_disconnected == 0 && !_cancel.IsCancellationRequested && CurrentStatus == Status.Connected) + { + // STOP 同时中止固件宏并释放所有按键;仅发中立报告会被正在播放的宏忽略。 + _queue.Enqueue(null); + _wake.Set(); + } + } + } + + public override void Disconnect() + { + if (Interlocked.Exchange(ref _disconnected, 1) != 0) + return; + // 已连接时先收完正在进行的 ACK。中途取消 ReadLine 会丢掉半行,后续释放请求 + // 可能误用迟到的 ACK;连接/扫描尚未完成时则立即取消。 + if (CurrentStatus != Status.Connected) + _cancel.Cancel(); + _wake.Set(); + lock (_gate) + _queue.Clear(); + Task? worker = _worker; + if (worker == null) + { + transport.Dispose(); + return; + } + if (!worker.Wait(3500)) + { + _cancel.Cancel(); + if (!worker.Wait(2500)) + transport.Dispose(); + } + } + + private void Run() + { + bool opened = false; + try + { + CancellationToken token = _cancel.Token; + _padStatus = transport.Connect(token); + opened = true; + transport.Stop(token); + Send(Snapshot(new SwitchReport()), token); + token.ThrowIfCancellationRequested(); + CurrentStatus = Status.Connected; + Stopwatch poll = Stopwatch.StartNew(); + while (Volatile.Read(ref _disconnected) == 0 && !token.IsCancellationRequested) + { + // 队列锁不覆盖 I/O:慢 BLE 请求不能阻塞 UI/Reset,也不能在锁内回调宿主。 + // ClearQueue 丢弃排队项;已出队的一条结束后,下一条必为 STOP。 + bool hasWork; + PendingReport? report; + lock (_gate) + hasWork = _queue.TryDequeue(out report); + if (hasWork) + { + token.ThrowIfCancellationRequested(); + if (report == null) + transport.Stop(token); + else + Send(report, token); + } + if (poll.ElapsedMilliseconds >= 1000) + { + Volatile.Write(ref _padStatus, transport.GetStatus(token)); + poll.Restart(); + } + bool idle; + lock (_gate) + idle = _queue.Count == 0; + if (idle) + WaitHandle.WaitAny([_wake, token.WaitHandle], 100); + } + } + catch (OperationCanceledException) when (_cancel.IsCancellationRequested) { } + catch (Exception ex) + { + _lastError = ex.Message; + } + finally + { + if (opened) + { + // 正常退出/停止时,UART 固件不会感知电脑关闭了端口,必须主动释放。 + using CancellationTokenSource release = new(TimeSpan.FromSeconds(2)); + try { transport.Stop(release.Token); } + catch (Exception ex) { Debug.WriteLine(ex); } + try { transport.SendReport(PokeldnProtocol.EncodeReport(new SwitchReport()), release.Token); } + catch (Exception ex) { Debug.WriteLine(ex); } + try { transport.Disconnect(release.Token); } + catch (Exception ex) { Debug.WriteLine(ex); } + } + transport.Dispose(); + CurrentStatus = _lastError == null ? Status.Connecting : Status.Error; + } + } + + private static PendingReport Snapshot(SwitchReport report) => new(PokeldnProtocol.EncodeReport(report), report.GetBytes()); + + private void Send(PendingReport report, CancellationToken token) + { + transport.SendReport(report.Payload, token); + // 与 TTLSerialClient 保持同一事件契约;上层用打包后的中立报告确认释放。 + // pokeldn 额外等待固件/服务 ACK,但这仍不代表 NS 或游戏已确认输入。 + BytesSent(endpoint, report.Packed); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/Pokeldn/PokeldnProtocol.cs b/src/EasyCon.Device/Connection/Pokeldn/PokeldnProtocol.cs new file mode 100644 index 00000000..b39faede --- /dev/null +++ b/src/EasyCon.Device/Connection/Pokeldn/PokeldnProtocol.cs @@ -0,0 +1,101 @@ +using System.Buffers.Binary; +using System.Text; + +namespace EasyDevice.Connection.Pokeldn; + +/// pokeldn-pad 1.3.0 的 UART 帧和共享 HID 报文(不是 pokeldn-radio 协议)。 +internal static class PokeldnProtocol +{ + internal const int BaudRate = 921600; + internal const int MaxBodyLength = 600; + internal const byte Report = 0x01; + internal const byte StatusRequest = 0x02; + internal const byte Command = 0x03; + internal const byte Result = 0x81; + internal const byte StatusReply = 0x82; + internal const byte Stop = 0x13; + + internal static byte[] EncodeReport(SwitchReport report) => + [ + (byte)report.Button, (byte)(report.Button >> 8), report.HAT, + report.LX, report.LY, report.RX, report.RY, 0 + ]; + + internal static byte[] Frame(byte kind, ReadOnlySpan payload) + { + if (payload.Length >= MaxBodyLength) + throw new ArgumentOutOfRangeException(nameof(payload)); + byte[] frame = new byte[payload.Length + 6]; + frame[0] = 0xA5; + frame[1] = 0x5A; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(2), (ushort)(payload.Length + 1)); + frame[4] = kind; + payload.CopyTo(frame.AsSpan(5)); + int checksum = kind; + foreach (byte value in payload) + checksum += value; + frame[^1] = (byte)checksum; + return frame; + } + + internal static PokeldnStatus ParseStatus(ReadOnlySpan payload) + { + if (payload.Length < 5 || payload[0] > 1 || (payload.Length > 5 && payload.Length < 14)) + throw new IOException("pokeldn-pad 返回了无效的状态报文。"); + bool playing = payload.Length >= 14 && payload[5] != 0; + string version = ""; + if (payload.Length > 14) + { + ReadOnlySpan text = payload[14..]; + int end = text.IndexOf((byte)0); + version = Encoding.UTF8.GetString(end < 0 ? text : text[..end]); + } + return new PokeldnStatus(payload[0] != 0, playing, version); + } +} + +internal sealed record PokeldnStatus(bool Mounted, bool Playing, string Version); +internal sealed record PokeldnFrame(byte Kind, byte[] Payload); + +/// 允许串口分片、粘包和开机日志;校验失败后重新查找帧头。 +internal sealed class PokeldnFrameReader +{ + private readonly List _buffer = []; + + internal void Reset() => _buffer.Clear(); + + internal IEnumerable Feed(byte[] bytes, int count) + { + _buffer.AddRange(bytes.AsSpan(0, count).ToArray()); + List frames = []; + while (_buffer.Count >= 2) + { + if (_buffer[0] != 0xA5 || _buffer[1] != 0x5A) + { + _buffer.RemoveAt(0); + continue; + } + if (_buffer.Count < 4) + break; + int length = _buffer[2] | (_buffer[3] << 8); + if (length is < 1 or > PokeldnProtocol.MaxBodyLength) + { + _buffer.RemoveAt(0); + continue; + } + if (_buffer.Count < length + 5) + break; + int checksum = 0; + for (int i = 4; i < length + 4; i++) + checksum += _buffer[i]; + if ((byte)checksum != _buffer[length + 4]) + { + _buffer.RemoveAt(0); + continue; + } + frames.Add(new PokeldnFrame(_buffer[4], _buffer.GetRange(5, length - 1).ToArray())); + _buffer.RemoveRange(0, length + 5); + } + return frames; + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/Pokeldn/PokeldnSerialTransport.cs b/src/EasyCon.Device/Connection/Pokeldn/PokeldnSerialTransport.cs new file mode 100644 index 00000000..a6671980 --- /dev/null +++ b/src/EasyCon.Device/Connection/Pokeldn/PokeldnSerialTransport.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.IO.Ports; + +namespace EasyDevice.Connection.Pokeldn; + +internal interface IPokeldnSerialPort : IDisposable +{ + void Open(); + void DiscardInput(); + void Write(byte[] bytes); + int Read(byte[] buffer); +} + +internal sealed class PokeldnSerialPort(string portName, int baudrate = PokeldnProtocol.BaudRate) : IPokeldnSerialPort +{ + private readonly SerialPort _port = new(portName, baudrate) + { + DtrEnable = false, + RtsEnable = false, + Handshake = Handshake.None, + ReadTimeout = 50, + WriteTimeout = 1000 + }; + + public void Open() => _port.Open(); + public void DiscardInput() => _port.DiscardInBuffer(); + public void Write(byte[] bytes) => _port.Write(bytes, 0, bytes.Length); + public int Read(byte[] buffer) => _port.Read(buffer, 0, buffer.Length); + public void Dispose() => _port.Dispose(); +} + +internal sealed class PokeldnSerialTransport(IPokeldnSerialPort port) : IPokeldnTransport +{ + private readonly PokeldnFrameReader _reader = new(); + + public int ConnectTimeoutMilliseconds => 7000; + + public PokeldnStatus Connect(CancellationToken token) + { + port.Open(); + // CP210x 开口可能复位 ESP32;启动期间请求会丢失,按上游实现重试 3 秒。 + Stopwatch boot = Stopwatch.StartNew(); + while (true) + { + try + { + return GetStatus(token); + } + catch (TimeoutException) when (boot.ElapsedMilliseconds < 3000) + { + token.ThrowIfCancellationRequested(); + } + } + } + + public PokeldnStatus GetStatus(CancellationToken token) + => PokeldnProtocol.ParseStatus(Ask(PokeldnProtocol.StatusRequest, [], PokeldnProtocol.StatusReply, token)); + + public void SendReport(byte[] report, CancellationToken token) => Call(PokeldnProtocol.Report, report, token); + public void Stop(CancellationToken token) => Call(PokeldnProtocol.Command, [PokeldnProtocol.Stop], token); + public void Disconnect(CancellationToken token) { } + public void Dispose() => port.Dispose(); + + private void Call(byte kind, byte[] payload, CancellationToken token) + { + byte[] result = Ask(kind, payload, PokeldnProtocol.Result, token); + if (result.Length != 1) + throw new IOException("pokeldn-pad 返回了无效的 ACK。"); + if (result[0] != 0) + throw new IOException($"pokeldn-pad 拒绝指令,错误码 {(sbyte)result[0]}。"); + } + + private byte[] Ask(byte kind, byte[] payload, byte expected, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + port.DiscardInput(); + _reader.Reset(); + port.Write(PokeldnProtocol.Frame(kind, payload)); + Stopwatch wait = Stopwatch.StartNew(); + byte[] buffer = new byte[256]; + while (wait.ElapsedMilliseconds < 1000) + { + token.ThrowIfCancellationRequested(); + int count; + try + { + count = port.Read(buffer); + } + catch (TimeoutException) + { + continue; + } + foreach (PokeldnFrame frame in _reader.Feed(buffer, count)) + if (frame.Kind == expected) + return frame.Payload; + } + throw new TimeoutException("pokeldn-pad 未响应串口请求;请检查手柄固件、USB 连接和串口占用。"); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs b/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs new file mode 100644 index 00000000..ad176125 --- /dev/null +++ b/src/EasyCon.Device/Connection/Pokeldn/PokeldnServiceTransport.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace EasyDevice.Connection.Pokeldn; + +/// 官方 pokeldn.pad.service 的本机 JSON 行协议,由该服务持有 ESP32-S3 的 BLE 连接。 +internal sealed class PokeldnServiceTransport(string host, int port) : IPokeldnTransport +{ + private readonly TcpClient _client = new() { NoDelay = true }; + private StreamReader? _reader; + private NetworkStream? _stream; + + public int ConnectTimeoutMilliseconds => 60000; + + public PokeldnStatus Connect(CancellationToken token) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + try + { + _client.ConnectAsync(host, port, timeout.Token).AsTask().GetAwaiter().GetResult(); + } + catch (Exception ex) when (ex is SocketException or OperationCanceledException) + { + token.ThrowIfCancellationRequested(); + throw new IOException($"无法连接桥接服务 {host}:{port};请检查桥接地址及服务是否运行。", ex); + } + _stream = _client.GetStream(); + _reader = new StreamReader(_stream, Encoding.UTF8, false, 1024, leaveOpen: true); + // 上游扫描、连接最长 40 秒;整个连接仍在后台执行,且可取消。 + using JsonDocument reply = Call("connect", null, token, 45000); + return GetStatus(token); + } + + public PokeldnStatus GetStatus(CancellationToken token) + { + using JsonDocument reply = Call("status", null, token, 6000); + JsonElement root = reply.RootElement; + if (!root.TryGetProperty("connected", out JsonElement connected) || !connected.GetBoolean() + || !root.TryGetProperty("status", out JsonElement status)) + throw new IOException("pokeldn 服务尚未连接手柄。请给 ESP32-S3 供电并开启电脑蓝牙。"); + return new PokeldnStatus( + status.GetProperty("mounted").GetBoolean(), + status.TryGetProperty("playing", out JsonElement playing) && playing.GetBoolean(), + status.TryGetProperty("version", out JsonElement version) ? version.GetString() ?? "" : ""); + } + + public void SendReport(byte[] report, CancellationToken token) + { + using JsonDocument reply = Call("send", Convert.ToHexString(report), token); + } + + public void Stop(CancellationToken token) + { + using JsonDocument reply = Call("stop", null, token); + } + + public void Disconnect(CancellationToken token) + { + using JsonDocument reply = Call("disconnect", null, token); + } + + private JsonDocument Call(string operation, string? report, CancellationToken token, int timeoutMilliseconds = 5000) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(timeoutMilliseconds); + using MemoryStream json = new(); + using (Utf8JsonWriter writer = new(json)) + { + writer.WriteStartObject(); + writer.WriteString("op", operation); + if (report != null) + writer.WriteString("report", report); + writer.WriteEndObject(); + } + json.WriteByte((byte)'\n'); + try + { + _stream!.WriteAsync(json.ToArray(), timeout.Token).AsTask().GetAwaiter().GetResult(); + string? line = _reader!.ReadLineAsync(timeout.Token).AsTask().GetAwaiter().GetResult(); + if (line == null) + throw new IOException("pokeldn 手柄服务已断开。"); + JsonDocument reply = JsonDocument.Parse(line); + if (!reply.RootElement.TryGetProperty("ok", out JsonElement ok) || ok.ValueKind != JsonValueKind.True) + { + string message = reply.RootElement.TryGetProperty("error", out JsonElement error) + ? error.GetString() ?? "未知错误" : "无效响应"; + reply.Dispose(); + throw new IOException($"pokeldn 服务: {message}"); + } + return reply; + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + // 超时后的迟到响应不能被下一次请求当作 ACK;工作循环将关闭连接。 + throw new TimeoutException($"pokeldn 服务未及时响应 {operation}。"); + } + } + + public void Dispose() + { + _client.Dispose(); + _reader?.Dispose(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/PokeldnSerialClient.cs b/src/EasyCon.Device/Connection/PokeldnSerialClient.cs new file mode 100644 index 00000000..68776802 --- /dev/null +++ b/src/EasyCon.Device/Connection/PokeldnSerialClient.cs @@ -0,0 +1,77 @@ +using EasyDevice.Connection.Pokeldn; + +namespace EasyDevice.Connection; + +/// pokeldn 串口入口;与官方服务连接共用 ACK、状态轮询及停止释放逻辑。 +public class PokeldnSerialClient : IConnection +{ + public const int DefaultBaudRate = PokeldnProtocol.BaudRate; + private readonly PokeldnConnection _connection; + private volatile Status _status = Status.Connecting; + + public PokeldnSerialClient(string connStr, int baudrate = DefaultBaudRate) + : this(connStr, new PokeldnSerialTransport(new PokeldnSerialPort(connStr, baudrate))) { } + + internal PokeldnSerialClient(string connStr, IPokeldnTransport transport) + { + _connection = new PokeldnConnection(connStr, transport); + _connection.StatusChanged += status => + { + CurrentStatus = status; + StatusChanged(status); + }; + } + + public override Status CurrentStatus { get => _status; protected set => _status = value; } + public override int ConnectTimeoutMilliseconds => _connection.ConnectTimeoutMilliseconds; + public override bool SupportsLegacyCommands => false; + public override string FirmwareName => _connection.FirmwareName; + public override string? FirmwareVersion => _connection.FirmwareVersion; + public override bool? ConsoleConnected => _connection.ConsoleConnected; + public override string? LastError => _connection.LastError; + public override event BytesTransferedHandler BytesSent { add => _connection.BytesSent += value; remove => _connection.BytesSent -= value; } + public override event BytesTransferedHandler BytesReceived { add => _connection.BytesReceived += value; remove => _connection.BytesReceived -= value; } + public override event StatusChangedHandler StatusChanged = delegate { }; + public override void Connect() => _connection.Connect(); + public override void Disconnect() => _connection.Disconnect(); + public override void ClearQueue() => _connection.ClearQueue(); + public override void WriteReport(SwitchReport report) => _connection.WriteReport(report); + + /// 兼容已有调用者的 7-bit HID 包;EasyCon 私有命令不会被写到 pokeldn。 + public override void Write(params byte[] val) + { + SwitchReport? report = DecodeReport(val); + if (report != null) + WriteReport(report); + } + + /// 保留公开的报文翻译接缝;线上编码由共享协议实现提供。 + public static byte[]? TranslateReport(byte[]? packet) + { + SwitchReport? report = DecodeReport(packet); + return report == null ? null : BuildFrame(PokeldnProtocol.Report, PokeldnProtocol.EncodeReport(report)); + } + + public static byte[] BuildFrame(byte type, params byte[] payload) => PokeldnProtocol.Frame(type, payload); + + private static SwitchReport? DecodeReport(byte[]? packet) + { + if (packet == null || packet.Length != 8 || (packet[^1] & 0x80) == 0) + return null; + for (int i = 0; i < packet.Length - 1; i++) + if ((packet[i] & 0x80) != 0) + return null; + ulong bits = 0; + foreach (byte value in packet) + bits = (bits << 7) | (byte)(value & 0x7F); + return new SwitchReport + { + Button = (ushort)(bits >> 40), + HAT = (byte)(bits >> 32), + LX = (byte)(bits >> 24), + LY = (byte)(bits >> 16), + RX = (byte)(bits >> 8), + RY = (byte)bits + }; + } +} \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/SerialPortClient.cs b/src/EasyCon.Device/Connection/SerialPortClient.cs deleted file mode 100644 index 0d4672bc..00000000 --- a/src/EasyCon.Device/Connection/SerialPortClient.cs +++ /dev/null @@ -1,663 +0,0 @@ -using System.Data; -using System.Diagnostics; -using System.IO.Ports; -using System.Text; -using System.Timers; - -namespace EasyDevice.Connection; - -class SerialPortClient : IDisposable -{ - private SerialPort _serialPort; - private readonly object _lockObject = new(); - private AutoResetEvent _dataReceivedEvent = new(false); - private List _receivedBuffer = new(); - private readonly int _receiveTimeout = 300; - - // 心跳检测定时器 - private System.Timers.Timer _heartbeatTimer; - private bool _isHeartbeatEnabled = false; - private byte[] _heartbeatCommand; - private int _heartbeatInterval = 5000; // 默认5秒 - private int _heartbeatFailCount = 0; - private int _maxHeartbeatFailCount = 3; - - // 连接状态 - private volatile ConnectionState _connectionState = ConnectionState.Disconnected; - - public string ConnectPort => _serialPort.PortName; - - /// - /// 连接状态枚举 - /// - public enum ConnectionState - { - Disconnected, - Connecting, - Connected, - Disconnecting, - Error - } - - /// - /// 连接状态变化事件 - /// - public event Action ConnectionStateChanged; - - /// - /// 连接成功事件 - /// - public event EventHandler ConnectionOpened; - - /// - /// 连接失败事件 - /// - public event EventHandler ConnectionFailed; - - /// - /// 设备断开事件(被动断开) - /// - public event EventHandler DeviceDisconnected; - - /// - /// 数据接收事件 - /// - public event EventHandler DataReceived; - - /// - /// 连接成功事件 - /// - public event Func HeartbeatCheckHandler; - - /// - /// 当前连接状态 - /// - public ConnectionState CurrentState => _connectionState; - - /// - /// 是否已连接 - /// - public bool IsConnected => _connectionState == ConnectionState.Connected; - - /// - /// 初始化串口 - /// - public SerialPortClient(string portName, int baudRate = 115200) - { - _serialPort = new SerialPort - { - PortName = portName, - BaudRate = baudRate, - ReadTimeout = 500, - WriteTimeout = 500 - }; - - _serialPort.DataReceived += SerialPort_DataReceived; - _serialPort.ErrorReceived += SerialPort_ErrorReceived; - - // 初始化心跳定时器 - _heartbeatTimer = new System.Timers.Timer(); - _heartbeatTimer.Elapsed += HeartbeatTimer_Elapsed; - _heartbeatTimer.AutoReset = true; - } - - /// - /// 异步打开串口 - /// - public async Task OpenAsync() - { - if (_connectionState == ConnectionState.Connected || - _connectionState == ConnectionState.Connecting) - { - return true; - } - - try - { - UpdateConnectionState(ConnectionState.Connecting, "正在连接串口..."); - - _serialPort.Open(); - _serialPort.DiscardInBuffer(); - _serialPort.DiscardOutBuffer(); - - UpdateConnectionState(ConnectionState.Connected, "串口连接成功"); - ConnectionOpened?.Invoke(this, $"串口 {_serialPort.PortName} 已成功连接"); - - // 启用心跳检测 - StartHeartbeat(); - - return true; - } - catch (Exception ex) - { - UpdateConnectionState(ConnectionState.Error, $"连接失败: {ex.Message}"); - OnConnectionError(new($"串口 {_serialPort.PortName} 连接失败", ex)); - - return false; - } - } - - /// - /// 同步打开串口 - /// - public bool Open() - { - try - { - UpdateConnectionState(ConnectionState.Connecting, "正在连接串口..."); - - if (!_serialPort.IsOpen) - { - _serialPort.Open(); - _serialPort.DiscardInBuffer(); - _serialPort.DiscardOutBuffer(); - - UpdateConnectionState(ConnectionState.Connected, "串口连接成功"); - ConnectionOpened?.Invoke(this, $"串口 {_serialPort.PortName} 已成功连接"); - - // 启用心跳检测 - StartHeartbeat(); - - return true; - } - return false; - } - catch (Exception ex) - { - UpdateConnectionState(ConnectionState.Error, $"连接失败: {ex.Message}"); - OnConnectionError(new($"串口 {_serialPort.PortName} 连接失败", ex)); - - return false; - } - } - /// - /// 触发连接错误事件 - /// - protected virtual void OnConnectionError(ConnectionErrorEventArgs e) - { - ConnectionFailed?.Invoke(this, e); - } - - /// - /// 关闭串口 - /// - public void Close() - { - try - { - if (_serialPort.IsOpen) - { - UpdateConnectionState(ConnectionState.Disconnecting, "正在断开连接..."); - - // 停止心跳检测 - StopHeartbeat(); - - _serialPort.Close(); - UpdateConnectionState(ConnectionState.Disconnected, "连接已断开"); - } - } - catch (Exception ex) - { - UpdateConnectionState(ConnectionState.Error, $"断开连接时发生错误: {ex.Message}"); - } - } - - /// - /// 异步发送命令 - /// - public async Task SendDataAsync(byte[] data) - { - if (!IsConnected) - { - throw new InvalidOperationException("串口未连接"); - } - - if (data == null || data.Length == 0) - { - return; - } - - try - { - lock (_lockObject) - { - if (_serialPort == null || !_serialPort.IsOpen) - { - throw new InvalidOperationException("串口已断开"); - } - - _serialPort.Write(data, 0, data.Length); - - // 重置心跳失败计数 - _heartbeatFailCount = 0; - - // 触发事件 - } - } - catch (Exception ex) - { - // 检查是否为连接断开错误 - if (IsConnectionLostException(ex)) - { - HandleDeviceDisconnection($"发送数据时连接断开: {ex.Message}"); - } - throw; - } - } - - /// - /// 发送命令并接收返回数据 - /// - public byte[] SendCommand(byte[] command, int? expectedResponseLength = null, int? timeout = null) - { - if (!IsConnected) - { - throw new InvalidOperationException("串口未连接"); - } - _serialPort.DiscardOutBuffer(); - _receivedBuffer.Clear(); - - try - { - // 发送命令 - _serialPort.Write(command, 0, command.Length); - - // 重置心跳失败计数(有通信活动) - _heartbeatFailCount = 0; - - // 等待接收数据 - var waitTime = timeout ?? _receiveTimeout; - if (_dataReceivedEvent.WaitOne(waitTime)) - { - if (expectedResponseLength.HasValue && _receivedBuffer.Count < expectedResponseLength.Value) - { - int remainingTime = waitTime; - while (remainingTime > 0 && _receivedBuffer.Count < expectedResponseLength.Value) - { - Thread.Sleep(10); - remainingTime -= 10; - } - } - - return _receivedBuffer.ToArray(); - } - - throw new TimeoutException("接收数据超时"); - } - catch (Exception ex) - { - // 检查是否为连接断开错误 - if (IsConnectionLostException(ex)) - { - HandleDeviceDisconnection($"发送命令时连接断开: {ex.Message}"); - } - throw; - } - - } - - /// - /// 设置心跳检测 - /// - /// 心跳命令 - /// 检测间隔(毫秒) - /// 最大失败次数 - public void SetHeartbeat(byte[] heartbeatCommand, Func check, int interval = 5000, int maxFailCount = 3) - { - _heartbeatCommand = heartbeatCommand; - _heartbeatInterval = interval; - _maxHeartbeatFailCount = maxFailCount; - HeartbeatCheckHandler = check; - _isHeartbeatEnabled = true; - - if (IsConnected) - { - StartHeartbeat(); - } - } - - /// - /// 启用心跳检测 - /// - private void StartHeartbeat() - { - if (_isHeartbeatEnabled && _heartbeatCommand != null && _heartbeatCommand.Length > 0) - { - _heartbeatFailCount = 0; - _heartbeatTimer.Interval = _heartbeatInterval; - _heartbeatTimer.Start(); - } - } - - /// - /// 停止心跳检测 - /// - private void StopHeartbeat() - { - if (_heartbeatTimer != null) - { - _heartbeatTimer.Stop(); - } - } - - /// - /// 心跳检测定时器事件 - /// - private void HeartbeatTimer_Elapsed(object sender, ElapsedEventArgs e) - { - if (!IsConnected || _heartbeatCommand == null) - { - _heartbeatTimer.Stop(); - return; - } - - try - { - // 发送心跳命令 - _serialPort.Write(_heartbeatCommand, 0, _heartbeatCommand.Length); - - // 简单的心跳检测:检查是否有响应(可以更复杂,比如等待特定响应) - if (_serialPort.BytesToRead > 0) - { - // 读取心跳响应数据 - byte[] buffer = new byte[_serialPort.BytesToRead]; - _serialPort.Read(buffer, 0, buffer.Length); - //判断心跳行为 - if (!HeartbeatCheckHandler?.Invoke(buffer) ?? false) - { - _heartbeatFailCount++; - Debug.WriteLine($"心跳检测失败 {_heartbeatFailCount}/{_maxHeartbeatFailCount}"); - - if (_heartbeatFailCount >= _maxHeartbeatFailCount) - { - HandleDeviceDisconnection("心跳检测失败,设备可能已断开"); - return; - } - - HandleDeviceDisconnection("心跳检测失败,心跳字节不正确"); - } - else - { - // 有响应,重置失败计数 - _heartbeatFailCount = 0; - } - } - } - catch (Exception ex) - { - if (IsConnectionLostException(ex)) - { - HandleDeviceDisconnection($"心跳检测时连接断开: {ex.Message}"); - } - else - { - _heartbeatFailCount++; - if (_heartbeatFailCount >= _maxHeartbeatFailCount) - { - HandleDeviceDisconnection($"心跳检测异常: {ex.Message}"); - } - } - } - } - - /// - /// 处理设备断开连接 - /// - private void HandleDeviceDisconnection(string reason) - { - if (_connectionState == ConnectionState.Connected) - { - UpdateConnectionState(ConnectionState.Error, reason); - DeviceDisconnected?.Invoke(this, $"设备已断开: {reason}"); - - // 尝试重新连接 - //ThreadPool.QueueUserWorkItem(state => - //{ - // Thread.Sleep(2000); // 等待2秒后尝试重连 - // TryReconnect(); - //}); - } - } - - /// - /// 尝试重新连接 - /// - private void TryReconnect() - { - if (_connectionState == ConnectionState.Error) - { - Debug.WriteLine("尝试重新连接..."); - UpdateConnectionState(ConnectionState.Connecting, "尝试重新连接..."); - - try - { - // 关闭现有连接 - if (_serialPort.IsOpen) - { - _serialPort.Close(); - } - - Thread.Sleep(1000); - - // 重新打开 - _serialPort.Open(); - UpdateConnectionState(ConnectionState.Connected, "重新连接成功"); - ConnectionOpened?.Invoke(this, $"串口 {_serialPort.PortName} 重新连接成功"); - - // 重新启用心跳 - StartHeartbeat(); - } - catch (Exception ex) - { - UpdateConnectionState(ConnectionState.Error, $"重新连接失败: {ex.Message}"); - // 继续尝试重连 - Thread.Sleep(5000); - TryReconnect(); - } - } - } - - /// - /// 检查是否为连接断开异常 - /// - private bool IsConnectionLostException(Exception ex) - { - return ex is InvalidOperationException || - ex is UnauthorizedAccessException || - ex is IOException || - (ex.Message.Contains("端口") && ex.Message.Contains("关闭")) || - ex.Message.Contains("disconnected") || - ex.Message.Contains("not open"); - } - - /// - /// 更新连接状态 - /// - private void UpdateConnectionState(ConnectionState newState, string message) - { - if (_connectionState != newState) - { - _connectionState = newState; - Debug.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{_serialPort.PortName}]连接状态: {newState} - {message}"); - ConnectionStateChanged?.Invoke(newState, message); - } - } - - /// - /// 串口数据接收事件 - /// - private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) - { - try - { - int bytesToRead = _serialPort.BytesToRead; - if (bytesToRead > 0) - { - byte[] buffer = new byte[bytesToRead]; - int bytesRead = _serialPort.Read(buffer, 0, bytesToRead); - - - _receivedBuffer.AddRange(buffer); - _dataReceivedEvent.Set(); - - - // 有数据接收,重置心跳失败计数 - _heartbeatFailCount = 0; - - DataReceived?.Invoke(this, new DataReceivedEventArgs(buffer)); - } - } - catch (Exception ex) - { - if (IsConnectionLostException(ex)) - { - HandleDeviceDisconnection($"数据接收时连接断开: {ex.Message}"); - } - UpdateConnectionState(ConnectionState.Error, $"数据接收异常: {ex.Message}"); - } - } - - /// - /// 串口错误接收事件 - /// - private void SerialPort_ErrorReceived(object sender, SerialErrorReceivedEventArgs e) - { - string errorMsg = $"串口错误: {e.EventType}"; - UpdateConnectionState(ConnectionState.Error, errorMsg); - - // 根据错误类型处理 - switch (e.EventType) - { - case SerialError.RXOver: - case SerialError.Overrun: - case SerialError.RXParity: - case SerialError.Frame: - // 数据错误,可能不需要断开连接 - Debug.WriteLine($"数据通信错误: {e.EventType}"); - break; - case SerialError.TXFull: - // 发送缓冲区满 - Debug.WriteLine("发送缓冲区满"); - break; - } - } - - /// - /// 分包发送数据 - /// - public bool SendDataInPackets(byte[] data, int packetSize = 50, - int delayBetweenPackets = 10, - Action sendProgress = null) - { - if (!IsConnected) - { - throw new InvalidOperationException("串口未连接"); - } - - if (data == null || data.Length == 0) - { - return true; - } - - int totalPackets = (int)Math.Ceiling(data.Length / (double)packetSize); - - try - { - for (int i = 0; i < totalPackets; i++) - { - if (!IsConnected) - { - throw new InvalidOperationException("发送过程中连接断开"); - } - - int offset = i * packetSize; - int remaining = data.Length - offset; - int currentPacketSize = Math.Min(packetSize, remaining); - - _serialPort.Write(data, offset, currentPacketSize); - sendProgress?.Invoke(i + 1, totalPackets); - - if (i < totalPackets - 1 && delayBetweenPackets > 0) - { - Thread.Sleep(delayBetweenPackets); - } - } - - return true; - } - catch (Exception ex) - { - if (IsConnectionLostException(ex)) - { - HandleDeviceDisconnection($"分包发送时连接断开: {ex.Message}"); - } - throw; - } - } - - /// - /// 释放资源 - /// - public void Dispose() - { - StopHeartbeat(); - - if (_heartbeatTimer != null) - { - _heartbeatTimer.Elapsed -= HeartbeatTimer_Elapsed; - _heartbeatTimer.Dispose(); - _heartbeatTimer = null; - } - - if (_serialPort != null) - { - _serialPort.DataReceived -= SerialPort_DataReceived; - _serialPort.ErrorReceived -= SerialPort_ErrorReceived; - - if (_serialPort.IsOpen) - { - _serialPort.Close(); - } - - _serialPort.Dispose(); - _serialPort = null; - } - - _dataReceivedEvent?.Dispose(); - _dataReceivedEvent = null; - } -} -#region 事件参数类 - -/// -/// 连接错误事件参数 -/// -public class ConnectionErrorEventArgs(string errorMessage, Exception exception) : EventArgs -{ - public string ErrorMessage { get; } = errorMessage; - public Exception Exception { get; } = exception; - public DateTime ErrorTime { get; } = DateTime.Now; - - public override string ToString() - { - return $"{ErrorTime:yyyy-MM-dd HH:mm:ss} - {ErrorMessage}" + - (Exception != null ? $"\n异常信息: {Exception.Message}" : ""); - } -} - -/// -/// 数据接收事件参数 -/// -public class DataReceivedEventArgs(byte[] data) : EventArgs -{ - public byte[] Data { get; } = data; - public DateTime ReceiveTime { get; } = DateTime.Now; - - public string GetString(Encoding encoding = null) - { - encoding ??= Encoding.UTF8; - return encoding.GetString(Data); - } -} - -#endregion \ No newline at end of file diff --git a/src/EasyCon.Device/Connection/TTLSerialClient.cs b/src/EasyCon.Device/Connection/TTLSerialClient.cs index 153ef061..97b80d82 100644 --- a/src/EasyCon.Device/Connection/TTLSerialClient.cs +++ b/src/EasyCon.Device/Connection/TTLSerialClient.cs @@ -2,90 +2,9 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.IO.Ports; -using static EasyDevice.Connection.SerialPortClient; namespace EasyDevice.Connection; -internal class TTLv2SerialClient(string name, int port) : IConnection -{ - private SerialPortClient _serialPortClient = new(name, port); - - public override event BytesTransferedHandler BytesSent; - public override event BytesTransferedHandler BytesReceived; - public override event StatusChangedHandler StatusChanged; - - public override Status CurrentStatus - { - get => _serialPortClient.CurrentState switch - { - ConnectionState.Connected => Status.Connected, - ConnectionState.Connecting => Status.Connecting, - _ => Status.Error - }; protected set => throw new NotImplementedException(); - } - - public override void Connect() - { - _serialPortClient.SetHeartbeat([EzDvCommand.Ready, EzDvCommand.Ready, EzDvCommand.Hello], (bs) => bs.Length == 1 && bs[0] == Reply.Hello); - - void checkopend(object sender, string message) - { - bool check(byte[] bs) => bs.Length == 1 && bs[0] == Reply.Hello; - var recv = _serialPortClient.SendCommand([EzDvCommand.Ready, EzDvCommand.Ready, EzDvCommand.Hello]); - - Console.WriteLine($"[{_serialPortClient.ConnectPort}] --recv-- " + string.Join(" ", recv.Select(b => b.ToString("X2")))); - - if (!check(recv)) - { - _serialPortClient.Close(); - StatusChanged?.Invoke(Status.Error); - } - } - ; - _serialPortClient.ConnectionOpened += checkopend; - _serialPortClient.DataReceived += (sender, args) => - { - BytesReceived?.Invoke(_serialPortClient.ConnectPort, args.Data); - }; - Task.Run(() => - { - try - { - if (_serialPortClient.Open()) - { - StatusChanged?.Invoke(CurrentStatus); - _serialPortClient.ConnectionStateChanged += (sender, args) => - { - StatusChanged?.Invoke(CurrentStatus); - }; - } - throw new Exception("连接失败"); - } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - StatusChanged?.Invoke(Status.Error); - } - }); - } - - public override void Disconnect() - { - _serialPortClient.Close(); - } - - public override void Write(params byte[] val) - { - BytesSent?.Invoke(_serialPortClient.ConnectPort, val); - _serialPortClient.SendCommand(val); - } - - public override void ClearQueue() - { - // SerialPortClient 无发送队列,空实现 - } -} - class TTLSerialClient : IConnection { readonly string _connStr; @@ -121,7 +40,8 @@ protected set if (_status == value) return; _status = value; - Task.Run(() => StatusChanged?.Invoke(_status)); + var status = value; // 快照:闭包读字段可能已被后续翻转覆盖 + Task.Run(() => StatusChanged?.Invoke(status)); } } @@ -137,6 +57,14 @@ public override void Connect() return; _sport = new SerialPort(_connStr, _port); + // 非 Windows 平台打开串口时系统会先拉高 DTR/RTS,若随后先撤销 DTR,会短暂出现 + // “RTS 高、DTR 低”,经 ESP32 等板子的自动下载电路拉低 EN 导致单片机复位。 + // 因此以拉高状态打开,再在 Open 后按“先 RTS、后 DTR”的顺序撤销(见 Loop)。 + if (!OperatingSystem.IsWindows()) + { + _sport.DtrEnable = true; + _sport.RtsEnable = true; + } source = new CancellationTokenSource(); var token = source.Token; @@ -151,6 +79,15 @@ public override void Disconnect() { source?.Cancel(); ClearQueue(); + // 有界等待写循环退出:其 finally 会 Close 串口。不同步等待时, + // 重连路径上新 client 的 _sport.Open() 会与旧 Close 竞争同一 COM 口 + // (Windows 独占打开),造成间歇性 UnauthorizedAccessException。 + var t = _t; + if (t != null) + { + try { t.Wait(1500); } + catch { /* 任务已故障结束也视为退出(循环 finally 已关闭串口) */ } + } } void Loop() @@ -159,6 +96,11 @@ void Loop() { byte[] inBuffer = new byte[2550]; _sport.Open(); + if (!OperatingSystem.IsWindows()) + { + _sport.RtsEnable = false; + _sport.DtrEnable = false; + } _sport.DiscardInBuffer(); _sport.DiscardOutBuffer(); var stream = _sport.BaseStream; @@ -187,9 +129,8 @@ void Loop() if (inBuffer[0] == Reply.Hello) { - // hello received + // hello received(状态翻转经 setter 统一触发,此处不再重复发事件) CurrentStatus = Status.Connected; - StatusChanged?.Invoke(CurrentStatus); } BytesReceived?.Invoke(_connStr, _inBuffer.ToArray()); #if DEBUG @@ -213,10 +154,10 @@ void Loop() Thread.Sleep(1); } } - catch (Exception) + catch (Exception ex) { + Debug.WriteLine($"[{_connStr}] TTLSerialClient 异常退出: {ex}"); CurrentStatus = Status.Error; - StatusChanged?.Invoke(CurrentStatus); } finally { diff --git a/src/EasyCon.Device/ECKey.cs b/src/EasyCon.Device/ECKey.cs index 9124cf96..36f842d6 100644 --- a/src/EasyCon.Device/ECKey.cs +++ b/src/EasyCon.Device/ECKey.cs @@ -9,13 +9,13 @@ public sealed class ECKey(string name, int keyCode, Action down, A } -public class KeyStroke(ECKey key, bool up = false, int duration = 0, DateTime time = default) +internal class KeyStroke(ECKey key, bool up = false, int duration = 0, DateTime time = default) { public readonly ECKey Key = key; public int KeyCode => Key.KeyCode; public readonly bool Up = up; public readonly int Duration = duration; - public readonly DateTime Time = DateTime.Now; + public readonly DateTime Time = time == default ? DateTime.Now : time; } [Flags] diff --git a/src/EasyCon.Device/EasyCon.Device.csproj b/src/EasyCon.Device/EasyCon.Device.csproj index 3267cee0..d8cd7e2b 100644 --- a/src/EasyCon.Device/EasyCon.Device.csproj +++ b/src/EasyCon.Device/EasyCon.Device.csproj @@ -8,6 +8,7 @@ + diff --git a/src/EasyCon.Device/JoyStickDevice.cs b/src/EasyCon.Device/JoyStickDevice.cs index f9a7f112..43589bf8 100644 --- a/src/EasyCon.Device/JoyStickDevice.cs +++ b/src/EasyCon.Device/JoyStickDevice.cs @@ -23,6 +23,17 @@ public enum ConnectResult bool _reset = false; CancellationTokenSource source = new(); + private Task _writeLoop; + + /// 重连前取消上一条设备写循环,避免新旧两个 Loop 并行向设备写报告。 + private void CancelStaleLoop() + { + try { source.Cancel(); } + catch (ObjectDisposedException) { } + if (_writeLoop != null && !_writeLoop.Wait(TimeSpan.FromSeconds(5))) + throw new InvalidOperationException("busy: 上一设备写循环仍在停止中"); + source.Dispose(); + } DirectionKey _leftStick = 0; DirectionKey _rightStick = 0; @@ -34,36 +45,52 @@ public enum ConnectResult public event StatusChangedHandler StatusChanged; private readonly OperationRecords operationRecords = new(); - public RecordState recordState = RecordState.RECORD_STOP; + internal RecordState recordState = RecordState.RECORD_STOP; public ConnectResult TryConnect(string constr) { - var result = _TryConnect(constr); - - if (result == ConnectResult.Success) + ConnectionEndpoint endpoint; + try { - source = new(); - Task.Run(() => - { - Loop(source.Token); - }, - source.Token); + endpoint = ConnectionEndpoint.Parse(constr); } - else + catch (ArgumentException ex) { - Thread.Sleep(100); - result = _TryConnect(constr, 9600); + LastConnectionError = ex.Message; + return ConnectResult.InvalidArgument; + } + ConnectResult result = ConnectResult.None; + foreach (Func candidate in GetConnectionProviders().SelectMany(provider => provider.GetCandidates(endpoint))) + { + result = _TryConnect(candidate); if (result == ConnectResult.Success) { source = new(); - Task.Run(() => + CancellationToken token = source.Token; + lock (this) { - Loop(source.Token); - }, - source.Token); + _keystrokes.Clear(); + _report.Reset(); + _leftStick = _rightStick = _hat = DirectionKey.None; + _reset = false; + _ewh.Reset(); + } + // 保留上游专用线程:Loop 会长期等待设备事件。 + _writeLoop = Task.Factory.StartNew(() => Loop(token), CancellationToken.None, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + Log?.Invoke($"已连接 {FirmwareName} {FirmwareVersion}"); + if (ConsoleConnected == false) + Log?.Invoke("手柄固件已连接,NS 尚未接管手柄;请检查 USB 连接或在 NS 上更改握法/顺序完成配对。"); + break; } + if (result == ConnectResult.InvalidArgument) + break; + } + if (result == ConnectResult.None) + { + LastConnectionError = $"没有适用于 {endpoint.Transport} 的连接协议。"; + return ConnectResult.InvalidArgument; } - return result; } @@ -74,6 +101,7 @@ public void Reset() DebugKey("Reset"); Signal(); _keystrokes.Clear(); + _leftStick = _rightStick = _hat = 0; _reset = true; clientCon?.ClearQueue(); } @@ -156,7 +184,7 @@ private void DebugKey(string str, ECKey key = null) SwitchReport IReporter.GetReport() { - return _report.Clone() as SwitchReport; + lock (this) return _report.Clone() as SwitchReport; } public void StartRecord() diff --git a/src/EasyCon.Device/NintendoSwitchCmd.cs b/src/EasyCon.Device/NintendoSwitchCmd.cs index c1d1ce95..3f9191e9 100644 --- a/src/EasyCon.Device/NintendoSwitchCmd.cs +++ b/src/EasyCon.Device/NintendoSwitchCmd.cs @@ -11,77 +11,124 @@ public enum Status public partial class NintendoSwitch { - private IConnection clientCon { get; set; } + private IConnection? clientCon { get; set; } - private ConnectResult _TryConnect(string connStr, int baudrate = 115200) + public bool SupportsLegacyCommands => clientCon?.SupportsLegacyCommands == true; + public string? FirmwareName => clientCon?.FirmwareName; + public string? FirmwareVersion => clientCon?.FirmwareVersion; + public bool? ConsoleConnected => clientCon?.ConsoleConnected; + public string? LastConnectionError { get; private set; } + + private ConnectResult _TryConnect(Func createConnection) { - if (connStr == "") + Disconnect(); + LastConnectionError = null; + IConnection connection; + try + { + connection = createConnection(); + } + catch (ArgumentException ex) + { + LastConnectionError = ex.Message; return ConnectResult.InvalidArgument; - - var ewh = new EventWaitHandle(false, EventResetMode.AutoReset); - var result = ConnectResult.None; + } + clientCon = connection; + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); void statuschanged(Status status) { - lock (ewh) + if (status == Status.Connected) + completion.TrySetResult(ConnectResult.Success); + if (status == Status.Error) + completion.TrySetResult(ConnectResult.Error); + } + connection.BytesSent += (port, bytes) => { if (ReferenceEquals(clientCon, connection)) BytesSent?.Invoke(port, bytes); }; + connection.BytesReceived += (port, bytes) => { if (ReferenceEquals(clientCon, connection)) BytesReceived?.Invoke(port, bytes); }; + connection.StatusChanged += status => + { + if (ReferenceEquals(clientCon, connection)) { - if (result != ConnectResult.None) - return; - if (status == Status.Connected) - { - result = ConnectResult.Success; - ewh.Set(); - } if (status == Status.Error) { - result = ConnectResult.Error; - ewh.Set(); + LastConnectionError = connection.LastError; + Log?.Invoke(LastConnectionError ?? "设备连接已断开。"); } + StatusChanged?.Invoke(status); } - } - - Disconnect(); - clientCon = new TTLSerialClient(connStr, baudrate); - clientCon.BytesSent += BytesSent; - clientCon.BytesReceived += BytesReceived; - clientCon.StatusChanged += StatusChanged; - - clientCon.StatusChanged += statuschanged; - clientCon.Connect(); - if (!ewh.WaitOne(1000)) + }; + connection.StatusChanged += statuschanged; + try { - clientCon.Disconnect(); - clientCon = null; - return ConnectResult.Timeout; + connection.Connect(); + ConnectResult result = completion.Task.Wait(connection.ConnectTimeoutMilliseconds) + ? completion.Task.Result : ConnectResult.Timeout; + if (result != ConnectResult.Success) + { + clientCon = null; + connection.Disconnect(); + LastConnectionError = connection.LastError ?? LastConnectionError; + } + return result; } - if (result != ConnectResult.Success) + finally { - clientCon?.Disconnect(); - clientCon = null; - return result; + connection.StatusChanged -= statuschanged; } - clientCon.StatusChanged -= statuschanged; - - return ConnectResult.Success; } public void Disconnect() { - clientCon?.Disconnect(); + CancelStaleLoop(); + IConnection? connection = clientCon; clientCon = null; - source?.Cancel(); + connection?.Disconnect(); + } + + /// 复位并等待串口实际写出中性包;不表示主机/游戏已确认。 + public async Task ResetAndWaitForWriteAsync(TimeSpan timeout, CancellationToken token = default) + { + IConnection? connection = clientCon; + if (connection == null || !IsConnected()) { Reset(); return false; } + byte[] neutral = new SwitchReport().GetBytes().ToArray(); + TaskCompletionSource written = new(TaskCreationOptions.RunContinuationsAsynchronously); + void OnWritten(string port, byte[] bytes) + { + // 一次串口写可合并多个包,最后一个必须是中性包。 + if (bytes.AsSpan().EndsWith(neutral)) written.TrySetResult(true); + } + void OnStatus(Status status) + { + if (status == Status.Error) written.TrySetResult(false); + } + connection.BytesSent += OnWritten; + connection.StatusChanged += OnStatus; + try + { + Reset(); + return await written.Task.WaitAsync(timeout, token).ConfigureAwait(false); + } + catch (TimeoutException) { return false; } + finally + { + connection.BytesSent -= OnWritten; + connection.StatusChanged -= OnStatus; + } } public bool IsConnected() => clientCon?.CurrentStatus == Status.Connected; - void WriteReport(Span b) + void WriteReport(SwitchReport report) { Log?.Invoke($"[Send {DateTime.Now:ss.fff}] {_report}"); - clientCon.Write(b.ToArray()); + var con = clientCon; // 局部快照:Disconnect 可并发置 null + con?.WriteReport(report); } bool SendSync(Func predicate, int timeout = 100, params byte[] bytes) { + if (!RequireLegacyCommands()) + return false; var ewh = new EventWaitHandle(false, EventResetMode.AutoReset); void h(string port, byte[] bytes_) { @@ -94,20 +141,26 @@ void h(string port, byte[] bytes_) } try { - clientCon.BytesReceived += h; - clientCon.Write(bytes); + var con = clientCon; + if (con == null) + return false; + con.BytesReceived += h; + con.Write(bytes); if (!ewh.WaitOne(timeout)) return false; return true; } finally { - clientCon.BytesReceived -= h; + if (clientCon != null) + clientCon.BytesReceived -= h; } } bool ResetControl() { + if (!RequireLegacyCommands()) + return false; var ewh = new EventWaitHandle(false, EventResetMode.AutoReset); void h(string port, byte[] bytes_) { @@ -116,19 +169,25 @@ void h(string port, byte[] bytes_) } try { - clientCon.BytesReceived += h; + var con = clientCon; + if (con == null) + return false; + con.BytesReceived += h; for (int i = 0; i < 3; i++) - clientCon.Write(EzDvCommand.Ready, EzDvCommand.Hello); + con.Write(EzDvCommand.Ready, EzDvCommand.Hello); return ewh.WaitOne(50); } finally { - clientCon.BytesReceived -= h; + if (clientCon != null) + clientCon.BytesReceived -= h; } } public bool Flash(byte[] asmBytes) { + if (!RequireLegacyCommands()) + return false; const int PacketSize = 20; List list = new(asmBytes); for (int i = 0; i < list.Count; i += PacketSize) @@ -231,6 +290,8 @@ public bool ChangeControllerColor(byte[] color) public bool SaveAmiibo(byte index, byte[] amiibo) { + if (!RequireLegacyCommands()) + return false; const int PacketSize = 20; List list = [.. amiibo]; for (int i = 0; i < list.Count; i += PacketSize) @@ -270,4 +331,13 @@ public bool ChangeAmiiboIndex(byte index) { return SendSync(b => b == Reply.Ack, 200, EzDvCommand.Ready, index, EzDvCommand.ChangeAmiiboIndex); } + + private bool RequireLegacyCommands() + { + if (SupportsLegacyCommands) + return true; + if (IsConnected()) + Log?.Invoke($"{FirmwareName} 仅支持在线手柄控制;不支持伊机控离线脚本烧录、远程运行、Amiibo、颜色及解除配对命令。"); + return false; + } } \ No newline at end of file diff --git a/src/EasyCon.Device/NintendoSwitchConnections.cs b/src/EasyCon.Device/NintendoSwitchConnections.cs new file mode 100644 index 00000000..e043736e --- /dev/null +++ b/src/EasyCon.Device/NintendoSwitchConnections.cs @@ -0,0 +1,38 @@ +using EasyDevice.Connection; +using EasyDevice.Connection.Pokeldn; + +namespace EasyDevice; + +public partial class NintendoSwitch +{ + private readonly IConnectionProvider[]? _connectionProviders; + + public NintendoSwitch() { } + + public NintendoSwitch(IEnumerable connectionProviders) + { + ArgumentNullException.ThrowIfNull(connectionProviders); + _connectionProviders = [.. connectionProviders]; + } + + /// 默认协议组合;自定义宿主可通过构造函数提供其他串口或桥接协议。 + protected virtual IEnumerable GetConnectionProviders() => _connectionProviders ?? + [ + new SerialConnectionProvider( + [ + new SerialProtocol(115200, CreateConnection), + new SerialProtocol(9600, CreateConnection), + new SerialProtocol(PokeldnSerialClient.DefaultBaudRate, (port, _) => CreatePokeldnConnection(port)) + ]), + new BridgeConnectionProvider( + [ + endpoint => new PokeldnConnection(endpoint.Address, new PokeldnServiceTransport(endpoint.Host!, endpoint.Port!.Value)) + ]) + ]; + + /// 保留原生串口连接的测试接缝。 + protected virtual IConnection CreateConnection(string connStr, int baudrate) => new TTLSerialClient(connStr, baudrate); + + /// 保留已公开的 pokeldn 串口测试接缝;它不解析桥接或固件专用地址。 + protected virtual IConnection CreatePokeldnConnection(string connStr) => new PokeldnSerialClient(connStr); +} \ No newline at end of file diff --git a/src/EasyCon.Device/NintendoSwitchPriv.cs b/src/EasyCon.Device/NintendoSwitchPriv.cs index 9a00c8ed..243e449a 100644 --- a/src/EasyCon.Device/NintendoSwitchPriv.cs +++ b/src/EasyCon.Device/NintendoSwitchPriv.cs @@ -14,6 +14,7 @@ public void ApplyReport(SwitchReport report) lock (this) { _keystrokes.Clear(); + _reset = false; _report.Button = report.Button; _report.HAT = report.HAT; _report.LX = report.LX; @@ -31,19 +32,42 @@ void Signal() } void Loop(CancellationToken token) + { + // 写循环没有任何上层兜底:一次未观察异常会让循环静默死亡, + // 表现为"按键永远发不出去"且用户无感知。捕获、上报并退出。 + try + { + RunLoop(token); + } + catch (Exception ex) + { + Log?.Invoke($"设备发送循环异常退出: {ex.Message}"); + StatusChanged?.Invoke(Status.Error); + } + } + + private void RunLoop(CancellationToken token) { int sleep = 0; while (!token.IsCancellationRequested) { if (_keystrokes.Count == 0) - _ewh.WaitOne(); + { + // 可取消等待:重连/断开后旧循环不再阻塞在无限 WaitOne 上, + // 且 token 先于任何共享状态写入被检查(避免新旧 Loop 并发写报告) + if (WaitHandle.WaitAny(new WaitHandle[] { _ewh, token.WaitHandle }) == WaitHandle.WaitTimeout) + continue; + } else _ewh.WaitOne(sleep); + if (token.IsCancellationRequested) + return; if (DateTime.Now < _nextSendTime) Thread.Sleep((int)(_nextSendTime - DateTime.Now).TotalMilliseconds); sleep = int.MaxValue; lock (this) { + if (token.IsCancellationRequested) return; if (_reset) { _report.Reset(); @@ -78,7 +102,7 @@ void Loop(CancellationToken token) System.Diagnostics.Debug.WriteLine($"[Send {DateTime.Now:ss.fff}] {_report}"); - WriteReport(_report.GetBytes()); + WriteReport(_report); _nextSendTime = DateTime.Now.AddMilliseconds(MINIMAL_INTERVAL); _ewh.Reset(); } diff --git a/src/EasyCon.Device/OperationRecords.cs b/src/EasyCon.Device/OperationRecords.cs index dd1d49d8..f579a636 100644 --- a/src/EasyCon.Device/OperationRecords.cs +++ b/src/EasyCon.Device/OperationRecords.cs @@ -2,7 +2,7 @@ namespace EasyDevice; -public class OperationRecords +internal class OperationRecords { private List records = []; string script = ""; @@ -80,7 +80,7 @@ public string ToScript() } } -public enum RecordState +internal enum RecordState { RECORD_START = 0x00, RECORD_PAUSE = 0x01, diff --git a/src/EasyCon.Device/README.md b/src/EasyCon.Device/README.md index eb261b6a..164ade0f 100644 --- a/src/EasyCon.Device/README.md +++ b/src/EasyCon.Device/README.md @@ -2,14 +2,15 @@ ## 模块概述 -Device模块负责与伊机控下位机(单片机)的串口通信,将上层的手柄操作指令发送到硬件设备。模块实现了完整的Nintendo Switch控制器协议,支持按键、摇杆、Amiibo等所有操控功能。 +Device 模块负责通过串口或桥接服务连接手柄设备,将上层的手柄操作发送到硬件。各连接实现声明固件信息和能力;按键、摇杆通用,Amiibo 等扩展命令取决于设备协议。 ## 核心功能 ### 设备连接 -- 串口设备自动发现(`ECDevice`) -- 支持标准串口和TTL串口两种连接实现 -- 内建心跳检测和断线自动重连机制 +- 串口设备发现(`ECDevice` 枚举本机串口) +- 通用串口协议注册(`SerialConnectionProvider`):默认依次探测原生协议 115200、9600 和 pokeldn-pad 921600 +- 通用 TCP 桥接注册(`BridgeConnectionProvider`):显式指定 `tcp://主机:端口`,默认适配 pokeldn 官方 BLE 服务 +- 断线经 `StatusChanged` 上报,由上层(DeviceService)负责重连 ### Switch控制器协议 - **按键操作**: 按下/释放按键(A/B/X/Y/L/R/ZL/ZR/加减/Home/截屏等) @@ -19,28 +20,38 @@ Device模块负责与伊机控下位机(单片机)的串口通信,将上 - **设备控制**: LED触发、固件版本查询、手柄配对、手柄颜色修改、控制器模式切换 ### 指令发送 -- 基于时间调度的指令队列,确保按键时序精确 -- 最小间隔控制,避免指令过快导致设备丢包 -- 同步发送带ACK确认机制 +- 后台写循环 + 有界间隔(30ms MINIMAL_INTERVAL)节流,避免指令过快丢包 +- ConcurrentQueue 批量出队合并为单次串口写 +- 同步发送带 ACK 确认机制(SendSync) ### 操作录制 - 支持录制用户的手动操作,回放为简单脚本文本 ## 关键类 -- **NintendoSwitch** — 高层设备API(partial类),提供按键、摇杆、Amiibo等操作方法 +- **NintendoSwitch** — 高层设备 API(partial 类),提供按键、摇杆等操作;协议组合集中于 `NintendoSwitchConnections.cs` - **ECDevice** — 设备发现,枚举可用串口 +- **ConnectionEndpoint** — 规范化串口/TCP 地址及来源占用标识,不识别固件名称 +- **IConnectionProvider** — 按传输类型提供延迟创建的候选协议,支持宿主注入 - **ECKey / KeyStroke** — 按键模型,封装按键编码和按下/释放动作 -- **SwitchReport** — 6字节控制器状态报文(Button + HAT + LX/LY/RX/RY),支持7位打包序列化 -- **SerialPortClient** — 串口客户端,管理连接生命周期、心跳和报文收发 +- **SwitchReport** — 7 字节控制器状态(2 字节 Button + HAT + LX/LY/RX/RY),支持原生固件的 7 位打包序列化 +- **TTLSerialClient** — 原生固件连接:后台写循环、状态机(Connecting/Connected/Error)、一次性使用(断开后由上层重建) +- **PokeldnConnection** — pokeldn-pad 在线控制,串口 / 官方本地 BLE 服务共用报告队列、STOP 释放与状态轮询 ## 连接层设计 -连接抽象为 `IConnection` 基类,有两个实现: -- **TTLv2SerialClient** — 基于SerialPortClient的新版TTL连接 -- **TTLSerialClient** — 基于原始SerialPort流的旧版连接 - -上层代码通过 `NintendoSwitch` 使用,无需关心底层连接实现。 +连接抽象为 `IConnection` 基类(public)。`NintendoSwitch` 构造函数可注入 +`IConnectionProvider` 集合,分别注册串口和桥接协议,不改变报告队列和上层调用。 +原有 `protected virtual CreateConnection` 测试接缝保留,见 test/EasyCon.SDLInput.Tests。 +原生固件使用 **TTLSerialClient**:`Connect()` 启动后台写循环,`Disconnect()` 取消并 +有界等待循环退出(避免新旧串口句柄竞争同一 COM 口)。上层通过 `NintendoSwitch` 使用, +无需关心底层连接实现。 + +`IConnection.WriteReport` 在连接层选择 HID 编码;原生固件保持 7 位打包,pokeldn-pad 使用 +8 字节原始 HID。`SupportsLegacyCommands` 阻止向 pokeldn 发送烧录、Amiibo 等专有指令。 +`GetPortNames()` 只枚举物理串口;GUI 保留用户配置的桥接地址,不添加固件专用服务列表项。 +地址配置、协议扩展和占用规则见 [通用设备连接](../../docs/DeviceConnections.md), +具体协议和硬件限制见 [pokeldn 适配说明](../../docs/Pokeldn.md)。 ## 依赖项 diff --git a/src/EasyCon.Device/Utils/CommandCode.cs b/src/EasyCon.Device/Utils/CommandCode.cs index 23e0568f..8d02d3b2 100644 --- a/src/EasyCon.Device/Utils/CommandCode.cs +++ b/src/EasyCon.Device/Utils/CommandCode.cs @@ -1,6 +1,6 @@ namespace EasyDevice; -public static class EzDvCommand +internal static class EzDvCommand { public const byte Ready = 0xA5; public const byte Debug = 0x80; @@ -17,7 +17,7 @@ public static class EzDvCommand public const byte ChangeAmiiboIndex = 0x91; } -public static class Reply +internal static class Reply { public const byte Error = 0x0; public const byte Busy = 0xFE; diff --git a/src/EasyCon.Lsp/Analysis/CompletionProvider.cs b/src/EasyCon.Lsp/Analysis/CompletionProvider.cs index 1222eed5..9b9ef973 100644 --- a/src/EasyCon.Lsp/Analysis/CompletionProvider.cs +++ b/src/EasyCon.Lsp/Analysis/CompletionProvider.cs @@ -7,7 +7,7 @@ namespace EasyCon.Lsp.Analysis; internal static class CompletionProvider { - public static CompletionList GetCompletions(CompicationUnit? root) + public static CompletionList GetCompletions(CompilationUnit? root, string? prefix = null) { var items = new List(); @@ -111,6 +111,13 @@ public static CompletionList GetCompletions(CompicationUnit? root) } } + // 按光标前当前词前缀过滤(协议语义:服务端负责 textEdit 级精度或前缀过滤, + // 而不是任何位置都吐同一份全量列表) + if (!string.IsNullOrEmpty(prefix)) + items = items + .Where(i => i.Label.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return new() { IsIncomplete = false, Items = items }; } } \ No newline at end of file diff --git a/src/EasyCon.Lsp/Analysis/DefinitionFinder.cs b/src/EasyCon.Lsp/Analysis/DefinitionFinder.cs index c622ba1c..0db56b3e 100644 --- a/src/EasyCon.Lsp/Analysis/DefinitionFinder.cs +++ b/src/EasyCon.Lsp/Analysis/DefinitionFinder.cs @@ -5,7 +5,7 @@ namespace EasyCon.Lsp.Analysis; internal static class DefinitionFinder { - public static DocumentRange? FindDefinition(CompicationUnit? root, string? lineText, Position position) + public static DocumentRange? FindDefinition(CompilationUnit? root, string? lineText, Position position) { if (lineText == null || root == null) return null; @@ -24,7 +24,7 @@ internal static class DefinitionFinder return FindStruct(root, word); } - private static DocumentRange? FindVariable(CompicationUnit root, string name) + private static DocumentRange? FindVariable(CompilationUnit root, string name) { foreach (var stmt in root.Members) { @@ -79,7 +79,7 @@ internal static class DefinitionFinder return null; } - private static DocumentRange? FindConstant(CompicationUnit root, string name) + private static DocumentRange? FindConstant(CompilationUnit root, string name) { foreach (var stmt in root.Members) { @@ -89,7 +89,7 @@ internal static class DefinitionFinder return null; } - private static DocumentRange? FindFunction(CompicationUnit root, string name) + private static DocumentRange? FindFunction(CompilationUnit root, string name) { foreach (var stmt in root.Members) { @@ -104,7 +104,7 @@ internal static class DefinitionFinder return null; } - private static DocumentRange? FindStruct(CompicationUnit root, string name) + private static DocumentRange? FindStruct(CompilationUnit root, string name) { foreach (var stmt in root.Members) { diff --git a/src/EasyCon.Lsp/Analysis/HoverProvider.cs b/src/EasyCon.Lsp/Analysis/HoverProvider.cs index bf6b0590..217b0659 100644 --- a/src/EasyCon.Lsp/Analysis/HoverProvider.cs +++ b/src/EasyCon.Lsp/Analysis/HoverProvider.cs @@ -7,7 +7,7 @@ namespace EasyCon.Lsp.Analysis; internal static class HoverProvider { - public static HoverResponse? GetHover(CompicationUnit? root, string? lineText, Position position) + public static HoverResponse? GetHover(CompilationUnit? root, string? lineText, Position position) { if (lineText == null || root == null) return null; @@ -41,12 +41,18 @@ internal static class HoverProvider { if (string.Equals(word, sym.Name, sym.Kind is "constant" or "parameter" or "variable" or "field" ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)) { - // 对于结构体,显示更详细的字段信息 + // 对于结构体,显示更详细的字段信息(只取本 struct 声明内的字段, + // 而不是全文档字段并集) if (sym.Kind == "struct") { - var fields = symbols.Where(s => s.Kind == "field").ToList(); + var fields = root.Members + .OfType() + .FirstOrDefault(b => string.Equals(b.Header.Name, sym.Name, StringComparison.OrdinalIgnoreCase)) + ?.Fields + .Select(f => f.Name) + .ToList() ?? []; var fieldList = fields.Count > 0 - ? "\n\n**字段:**\n" + string.Join("\n", fields.Select(f => $"- `{f.Name}`")) + ? "\n\n**字段:**\n" + string.Join("\n", fields.Select(f => $"- `{f}`")) : ""; return new() { diff --git a/src/EasyCon.Lsp/Analysis/SymbolCollector.cs b/src/EasyCon.Lsp/Analysis/SymbolCollector.cs index 52264263..0f4ec7c5 100644 --- a/src/EasyCon.Lsp/Analysis/SymbolCollector.cs +++ b/src/EasyCon.Lsp/Analysis/SymbolCollector.cs @@ -7,7 +7,7 @@ internal sealed record SymbolInfo(string Name, string Kind, int Line, int Charac internal static class SymbolCollector { - public static List CollectSymbols(CompicationUnit root) + public static List CollectSymbols(CompilationUnit root) { var symbols = new List(); foreach (var stmt in root.Members) diff --git a/src/EasyCon.Lsp/Constants.cs b/src/EasyCon.Lsp/Constants.cs index 2ca881c1..d68dc032 100644 --- a/src/EasyCon.Lsp/Constants.cs +++ b/src/EasyCon.Lsp/Constants.cs @@ -1,48 +1,72 @@ +using EasyCon.Script.Syntax; +using Bf = EasyCon.Script.Binding.BuiltinFunctions; + namespace EasyCon.Lsp; +/// +/// 语言关键字与内建函数表——从 EasyCon.Script 的 Lexer 词表与 BuiltinFunctions.Manifest +/// 派生(单一事实源)。手抄副本曾发生双向漂移(提示不存在的 CALL、缺 UNTIL/STRUCT、 +/// OCR 签名失真),因此这里禁止再写死任何关键字/函数名;新增语言元素只改 Script 侧。 +/// 中文 Doc 是 LSP 特有展示信息,按名映射,缺失时回退通用提示。 +/// internal static class Constants { + static readonly Dictionary Docs = new(StringComparer.Ordinal) + { + ["WAIT"] = "等待指定毫秒数(默认50ms)", + ["PRINT"] = "打印消息到控制台", + ["ALERT"] = "弹窗显示消息", + ["RAND"] = "生成随机整数(0~max,默认100)", + ["AMIIBO"] = "模拟Amiibo扫描", + ["BEEP"] = "发出蜂鸣声", + ["OCR_CONF"] = "查询/设置 OCR 置信度阈值,返回当前值", + ["JQ"] = "使用JQ查询语法解析JSON字符串并返回结果", + ["APPEND"] = "向数组末尾添加元素", + ["LEN"] = "获取数组长度", + ["ENCODE"] = "将byte数组转换为字符串(可选type: unicode, utf8)", + ["INT"] = "将变量转换为数字类型", + ["STRING"] = "将变量转换为字符串类型", + ["ENV"] = "读取环境变量", + ["ARG"] = "读取脚本启动参数", + ["FOPEN"] = "打开文件,返回句柄", + ["FREAD"] = "从文件句柄读取内容", + ["FWRITE"] = "向文件句柄写入,返回写入长度", + ["FCLOSE"] = "关闭文件句柄", + ["FEOF"] = "文件是否已读到末尾", + ["READFILE"] = "读取整个文本文件", + ["WRITEFILE"] = "覆盖写入文本文件", + ["APPENDFILE"] = "追加写入文本文件", + ["FILE_EXISTS"] = "文件是否存在", + ["NET_LOAD"] = "加载 ONNX 模型,返回网络句柄", + ["NET_OUT"] = "读取输出张量元素", + ["AUDIO_LOAD"] = "从入口脚本旁 audiolabel 加载标签并开始后台识别;按键和 WAIT 期间持续运行;同名重复加载需先 AUDIO_UNLOAD", + ["AUDIO_SCORE"] = "非阻塞读取本次加载以来的累计最高相似度 0~100(INT,百分制向下取整);首个完整结果前为 0,读取不清零;未加载、断线或后台异常时报错", + ["AUDIO_UNLOAD"] = "停止指定标签的后台识别并释放本轮分数;重新 AUDIO_LOAD 开始新一轮;脚本结束、出错或停止也会自动清理", + }; + public static readonly string[] Keywords = [ - "IMPORT", "IF", "ELIF", "ELSE", "ENDIF", - "WHILE", "END", "FOR", "TO", "IN", "STEP", - "BREAK", "CONTINUE", "NEXT", - "FUNC", "ENDFUNC", "RETURN", - "EXTERN", "AS", "FROM", - "TRUE", "FALSE", "RESET", - "AND", "OR", "NOT", - "CALL", + .. Lexer.KeywordNames.Select(k => k.ToUpperInvariant()), + .. Lexer.LogicWordNames.Select(k => k.ToUpperInvariant()), ]; public static readonly (string Name, string Signature, string Doc)[] BuiltinFunctions = [ - ("WAIT", "WAIT duration?", "等待指定毫秒数(默认50ms)"), - ("PRINT", "PRINT message?", "打印消息到控制台"), - ("ALERT", "ALERT message", "弹窗显示消息"), - ("RAND", "RAND max?", "生成随机整数(0~max,默认100)"), - ("TIME", "TIME()", "获取当前时间戳"), - ("AMIIBO", "AMIIBO index", "模拟Amiibo扫描"), - ("BEEP", "BEEP freq, duration", "发出蜂鸣声"), - ("OCR", "OCR x, y, width, height", "对指定区域进行光学字符识别,返回识别出的文本"), - ("JQ", "JQ json, query", "使用JQ查询语法解析JSON字符串并返回结果"), - ("APPEND", "APPEND array, value", "向数组末尾添加元素"), - ("LEN", "LEN var", "获取数组长度"), - ("ENCODE", "STRING array [, type]", "将byte数组转换为字符串(可选type: unicode, utf8)"), - ("INT", "INT var", "将变量转换为数字类型"), - ("STRING", "STRING var", "将变量转换为字符串类型"), + .. Bf.Manifest + .Where(d => !d.Symbol.Name.StartsWith("__", StringComparison.Ordinal)) // 内建洞函数不对用户暴露 + .Select(d => + { + var name = d.Symbol.Name; + var ps = string.Join(", ", d.Symbol.Parameters.Select(p => p.HasDefaultValue ? $"{p.Name}?" : p.Name)); + var sig = ps.Length > 0 ? $"{name} {ps}" : $"{name}()"; + return (name, sig, Docs.GetValueOrDefault(name, "内建函数")); + }), ]; public static readonly string[] FfiTypes = ["INT", "BOOL", "STRING", "VOID", "PTR", "DOUBLE", "BYTE", "UINT", "UINT64"]; - public static readonly string[] GamepadKeys = - [ - "A", "B", "X", "Y", "L", "R", "ZL", "ZR", - "MINUS", "PLUS", "HOME", "CAPTURE", "LCLICK", "RCLICK", - "UP", "DOWN", "LEFT", "RIGHT", - "DOWNLEFT", "DOWNRIGHT", "UPLEFT", "UPRIGHT", - ]; - - public static readonly string[] StickKeys = ["LS", "RS"]; - public static readonly string[] Directions = ["UP", "DOWN", "LEFT", "RIGHT", "DOWNLEFT", "DOWNRIGHT", "UPLEFT", "UPRIGHT"]; + public static readonly string[] GamepadKeys = [.. Lexer.GamepadKeywordNames]; + public static readonly string[] StickKeys = [.. Lexer.StickKeywordNames]; + public static readonly string[] Directions = [.. Lexer.DirectionKeywordNames]; public static readonly string[] KeyMods = ["UP", "DOWN"]; } \ No newline at end of file diff --git a/src/EasyCon.Lsp/DocumentManager.cs b/src/EasyCon.Lsp/DocumentManager.cs index 84a6752b..f8b9b089 100644 --- a/src/EasyCon.Lsp/DocumentManager.cs +++ b/src/EasyCon.Lsp/DocumentManager.cs @@ -68,7 +68,7 @@ public void CloseDocument(DocumentUri uri) return sourceText.Lines[line].Text; } - public CompicationUnit? GetRoot(DocumentUri uri) + public CompilationUnit? GetRoot(DocumentUri uri) { return _documents.TryGetValue(uri.UnescapeUri, out var state) ? state.SyntaxTree?.Root : null; } diff --git a/src/EasyCon.Lsp/Handlers/EcsCompletionHandler.cs b/src/EasyCon.Lsp/Handlers/EcsCompletionHandler.cs index 802518e1..24cdfeb7 100644 --- a/src/EasyCon.Lsp/Handlers/EcsCompletionHandler.cs +++ b/src/EasyCon.Lsp/Handlers/EcsCompletionHandler.cs @@ -13,7 +13,20 @@ internal sealed class EcsCompletionHandler(DocumentManager docManager) : Complet { var uri = request.TextDocument.Uri; var root = docManager.GetRoot(uri); - var list = CompletionProvider.GetCompletions(root); + + // 提取光标前的当前词前缀,服务端按前缀过滤 + string? prefix = null; + var lineText = docManager.GetLineText(uri, request.Position.Line); + if (lineText != null && request.Position.Character >= 0 && request.Position.Character <= lineText.Length) + { + var before = lineText[..request.Position.Character]; + var start = before.Length; + while (start > 0 && (char.IsLetterOrDigit(before[start - 1]) || before[start - 1] == '_')) + start--; + prefix = before[start..]; + } + + var list = CompletionProvider.GetCompletions(root, string.IsNullOrEmpty(prefix) ? null : prefix); return Task.FromResult(new CompletionResponse(list)); } @@ -26,7 +39,8 @@ public override void RegisterCapability(ServerCapabilities serverCapabilities, C { serverCapabilities.CompletionProvider = new CompletionOptions { - TriggerCharacters = ["$", "_", "@", "("], + // 不声明 "("(无 signatureHelp 支撑);$/_/@ 仅为编辑器侧触发便利 + TriggerCharacters = ["$", "_", "@"], }; } } \ No newline at end of file diff --git a/src/EasyCon.Lsp/Handlers/EcsTextDocumentHandler.cs b/src/EasyCon.Lsp/Handlers/EcsTextDocumentHandler.cs index 25be54ec..2abf75df 100644 --- a/src/EasyCon.Lsp/Handlers/EcsTextDocumentHandler.cs +++ b/src/EasyCon.Lsp/Handlers/EcsTextDocumentHandler.cs @@ -60,17 +60,16 @@ private async Task PublishDiagnostics(DocumentUri uri) var diagnostics = new List(); foreach (var diag in tree.Diagnostics) { - if (!diag.IsError) continue; - var line = diag.Location.StartLine; - var lineLength = 0; - if (line >= 0 && line < tree.Text.Lines.Length) - lineLength = tree.Text.Lines[line].Length; - + if (diag.Severity == EasyCon.Script.DiagnosticSeverity.Info) continue; diagnostics.Add(new() { - Range = new(new(line, 0), new(line, lineLength)), - Severity = EmmyLua.LanguageServer.Framework.Protocol.Model.Diagnostic.DiagnosticSeverity.Error, + Range = new(new(diag.Location.StartLine, diag.Location.StartCharacter), + new(diag.Location.EndLine, diag.Location.EndCharacter)), + Severity = diag.IsError + ? EmmyLua.LanguageServer.Framework.Protocol.Model.Diagnostic.DiagnosticSeverity.Error + : EmmyLua.LanguageServer.Framework.Protocol.Model.Diagnostic.DiagnosticSeverity.Warning, Message = diag.Message, + Code = diag.Code, Source = "ecs-lsp", }); } diff --git a/src/EasyCon.SDLInput/KeyMappingStore.cs b/src/EasyCon.SDLInput/KeyMappingStore.cs new file mode 100644 index 00000000..9377755b --- /dev/null +++ b/src/EasyCon.SDLInput/KeyMappingStore.cs @@ -0,0 +1,33 @@ +using EasyCon.Core.Config; + +namespace EasyCon.SDLInput; + +/// 按键映射的内存读缓存:首次访问加载一次,保存时刷新缓存。 +public sealed class KeyMappingStore +{ + private readonly Func _loader; + private readonly Action _saver; + private KeyMappingConfig? _current; + + public KeyMappingStore(Func loader, Action saver) + { + _loader = loader; + _saver = saver; + } + + /// 进程内单例:真实磁盘 IO + 保存时广播变更。 + public static KeyMappingStore Instance { get; } = new(SdlKeyMappingDefaults.LoadFromDiskOrDefaults, ConfigManager.SaveKeyMapping); + + /// 当前生效的映射(内存缓存,首次访问加载一次)。 + public KeyMappingConfig Current => _current ??= _loader(); + + /// 保存并刷新缓存:先更新缓存,再写盘并触发 KeyMappingChanged,避免订阅方读到旧值。 + public void Save(KeyMappingConfig mapping) + { + _current = mapping; + _saver(mapping); + } + + /// 清空缓存,下次访问时重新加载。 + public void Reset() => _current = null; +} \ No newline at end of file diff --git a/src/EasyCon.SDLInput/SdlEventLoop.cs b/src/EasyCon.SDLInput/SdlEventLoop.cs index b53d82df..f53bcf2b 100644 --- a/src/EasyCon.SDLInput/SdlEventLoop.cs +++ b/src/EasyCon.SDLInput/SdlEventLoop.cs @@ -13,6 +13,12 @@ public sealed class SdlEventLoop : IDisposable public event Action? GamepadAxisEvent; public event Action? GamepadDeviceEvent; + /// + /// 事件处理器抛出的异常经此上报(宿主接到日志);后台线程上的未处理异常 + /// 会直接终止整个进程,因此 Dispatch 内必须兜住。未设置时静默忽略。 + /// + public static Action? HandlerException { get; set; } + public void Start() { lock (_lock) @@ -26,21 +32,40 @@ public void Start() public void Stop() { + bool wasRunning; lock (_lock) { - if (!_running) return; + wasRunning = _running; _running = false; } - _thread?.Join(); + if (!wasRunning) return; + + // 有界等待:正常 1ms 轮询循环瞬间退出;若 SDL_PollEvent 卡死, + // 不能让窗口关闭路径(UI 线程)无限冻结。线程为 IsBackground,残留无害。 + _thread?.Join(TimeSpan.FromSeconds(2)); _thread = null; } private void Run() { - SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD); - + // SDL_Init 在 try 内并检查返回值:原生库缺失/初始化失败时上报而非 + // 让 DllNotFoundException 变成进程级未处理异常,或失败后静默空转 try { + if (!SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD)) + { + HandlerException?.Invoke(new InvalidOperationException( + $"SDL_Init 失败: {SDL3.SDL_GetError()}")); + return; + } + + // 全局键盘捕获(SDL 3.4.4+):Windows 下 SDL 据此以 RAWINPUT+INPUTSINK 注册, + // 不创建窗口、不抢焦点,任意前台应用下都能收到 SDL 键盘事件,且只观察不吞键 + // (不带 NOLEGACY/NOHOTKEYS,前台应用的正常按键流程不受影响)。 + // 其它平台/旧版 SDL 会忽略这两个 hint,回落到窗口焦点路径(VPadOverlay 聚焦仍可用)。 + SDL3.SDL_SetHint(SDL3.SDL_HINT_WINDOWS_RAW_KEYBOARD, "1"); + SDL3.SDL_SetHint(SDL3.SDL_HINT_WINDOWS_RAW_KEYBOARD_INPUTSINK, "1"); + while (_running) { PollEvents(); @@ -68,19 +93,31 @@ private void Dispatch(SDL_Event ev) if (t == (uint)SDL_EventType.SDL_EVENT_KEY_DOWN || t == (uint)SDL_EventType.SDL_EVENT_KEY_UP) { if (!ev.key.repeat) - KeyEvent?.Invoke(ev.key); + SafeInvoke(KeyEvent, ev.key); } else if (t == (uint)SDL_EventType.SDL_EVENT_GAMEPAD_BUTTON_DOWN || t == (uint)SDL_EventType.SDL_EVENT_GAMEPAD_BUTTON_UP) { - GamepadButtonEvent?.Invoke(ev.gbutton); + SafeInvoke(GamepadButtonEvent, ev.gbutton); } else if (t == (uint)SDL_EventType.SDL_EVENT_GAMEPAD_AXIS_MOTION) { - GamepadAxisEvent?.Invoke(ev.gaxis); + SafeInvoke(GamepadAxisEvent, ev.gaxis); } else if (t == (uint)SDL_EventType.SDL_EVENT_GAMEPAD_ADDED || t == (uint)SDL_EventType.SDL_EVENT_GAMEPAD_REMOVED) { - GamepadDeviceEvent?.Invoke(ev.gdevice); + SafeInvoke(GamepadDeviceEvent, ev.gdevice); + } + } + + private static void SafeInvoke(Action? handlers, T arg) + { + try + { + handlers?.Invoke(arg); + } + catch (Exception ex) + { + HandlerException?.Invoke(ex); } } diff --git a/src/EasyCon.SDLInput/SdlGamepadDetector.cs b/src/EasyCon.SDLInput/SdlGamepadDetector.cs index de9a0aa8..b58cd334 100644 --- a/src/EasyCon.SDLInput/SdlGamepadDetector.cs +++ b/src/EasyCon.SDLInput/SdlGamepadDetector.cs @@ -4,10 +4,22 @@ namespace EasyCon.SDLInput; public sealed unsafe class SdlGamepadDetector : IDisposable { + private readonly object _lock = new(); private readonly Dictionary _gamepads = []; private readonly Dictionary _names = []; - public IReadOnlyDictionary ConnectedGamepads => _names; + // 字典由 SDL 事件循环线程写、UI 线程读,必须各自持锁;读侧返回快照, + // 避免 UI 枚举期间字典被插拔事件修改(InvalidOperationException/脏读)。 + public IReadOnlyDictionary ConnectedGamepads + { + get + { + lock (_lock) + { + return new Dictionary(_names); + } + } + } public event Action? GamepadConnected; public event Action? GamepadDisconnected; @@ -37,43 +49,69 @@ public void HandleDeviceEvent(SDL_GamepadDeviceEvent ev) private void TryOpenGamepad(SDL_JoystickID instanceId) { var id = (int)instanceId; - if (_gamepads.ContainsKey(id)) return; + lock (_lock) + { + if (_gamepads.ContainsKey(id)) return; + } var gp = SDL3.SDL_OpenGamepad(instanceId); if (gp is null) return; var name = SDL3.SDL_GetGamepadName(gp) ?? "Unknown Gamepad"; - _gamepads[id] = (IntPtr)gp; - _names[id] = name; + lock (_lock) + { + // 双重检查:并发打开(热插拔事件 + OpenExisting)时只保留先到者 + if (_gamepads.ContainsKey(id)) + { + SDL3.SDL_CloseGamepad(gp); + return; + } + _gamepads[id] = (IntPtr)gp; + _names[id] = name; + } GamepadConnected?.Invoke(id, name); } private void CloseGamepad(SDL_JoystickID instanceId) { var id = (int)instanceId; - if (_gamepads.TryGetValue(id, out var gpPtr)) + IntPtr gpPtr; + lock (_lock) { - SDL3.SDL_CloseGamepad((SDL_Gamepad*)gpPtr!); + if (!_gamepads.TryGetValue(id, out gpPtr)) return; _gamepads.Remove(id); _names.Remove(id); - GamepadDisconnected?.Invoke(id); } + SDL3.SDL_CloseGamepad((SDL_Gamepad*)gpPtr); + GamepadDisconnected?.Invoke(id); } - public bool HasGamepad(int instanceId) => _gamepads.ContainsKey(instanceId); + public bool HasGamepad(int instanceId) + { + lock (_lock) + { + return _gamepads.ContainsKey(instanceId); + } + } public SDL_Gamepad* GetGamepad(int instanceId) { - if (_gamepads.TryGetValue(instanceId, out var ptr)) - return (SDL_Gamepad*)ptr; - return null; + lock (_lock) + { + if (_gamepads.TryGetValue(instanceId, out var ptr)) + return (SDL_Gamepad*)ptr; + return null; + } } public void Dispose() { - foreach (var ptr in _gamepads.Values) - SDL3.SDL_CloseGamepad((SDL_Gamepad*)ptr); - _gamepads.Clear(); - _names.Clear(); + lock (_lock) + { + foreach (var ptr in _gamepads.Values) + SDL3.SDL_CloseGamepad((SDL_Gamepad*)ptr); + _gamepads.Clear(); + _names.Clear(); + } } } \ No newline at end of file diff --git a/src/EasyCon.SDLInput/SdlGamepadInputBinder.cs b/src/EasyCon.SDLInput/SdlGamepadInputBinder.cs index a6e451d2..0923b9ea 100644 --- a/src/EasyCon.SDLInput/SdlGamepadInputBinder.cs +++ b/src/EasyCon.SDLInput/SdlGamepadInputBinder.cs @@ -1,5 +1,6 @@ using EasyCon.Core.Config; using EasyCon.Core.Input; +using EasyCon.Core.Maa; using EasyDevice; using SDL; @@ -9,12 +10,12 @@ public sealed unsafe class SdlGamepadInputBinder : IInputBinder, IDisposable { private readonly SdlEventLoop _eventLoop; private readonly SdlGamepadDetector _detector; - private readonly NintendoSwitch _switch; + private readonly IControllerSession _switch; private readonly int _gamepadInstanceId; private bool _enabled; private bool _started; - public SdlGamepadInputBinder(SdlEventLoop eventLoop, SdlGamepadDetector detector, NintendoSwitch @switch, int gamepadInstanceId) + public SdlGamepadInputBinder(SdlEventLoop eventLoop, SdlGamepadDetector detector, IControllerSession @switch, int gamepadInstanceId) { _eventLoop = eventLoop; _detector = detector; @@ -33,6 +34,9 @@ public void Stop() { _started = false; _enabled = false; + // 复位设备:断开/切换控制源时清掉最后一份报告(如按住的摇杆方向), + // 否则该状态会一直残留在 Switch 上,直到下一个 binder 写入 + if (_switch.CanAcceptManualInput) _switch.Reset(); } public void SetEnabled(bool enabled) @@ -46,13 +50,14 @@ public void RegisterEscapeKey(Func keydown, Func keyup) { } public void Poll() { - if (!_started || !_enabled) return; + if (!_started || !_enabled || !_switch.CanAcceptManualInput) return; var gp = _detector.GetGamepad(_gamepadInstanceId); if (gp == null) return; var report = SdlGamepadMapper.Map(gp); - _switch.ApplyReport(report); + try { _switch.ApplyReport(report); } + catch (ControllerOperationException ex) when (ex.ErrorCode is "Busy" or "InputDisconnected") { } } public void Dispose() diff --git a/src/EasyCon.SDLInput/SdlKeyMappingDefaults.cs b/src/EasyCon.SDLInput/SdlKeyMappingDefaults.cs index 7ed98fa2..216c09f4 100644 --- a/src/EasyCon.SDLInput/SdlKeyMappingDefaults.cs +++ b/src/EasyCon.SDLInput/SdlKeyMappingDefaults.cs @@ -9,6 +9,7 @@ public static KeyMappingConfig Create() { return new KeyMappingConfig { + SchemaVersion = KeyMappingConfig.CurrentSchemaVersion, A = (int)SDL_Scancode.SDL_SCANCODE_L, B = (int)SDL_Scancode.SDL_SCANCODE_K, X = (int)SDL_Scancode.SDL_SCANCODE_I, @@ -41,4 +42,17 @@ public static KeyMappingConfig Create() RSRight = (int)SDL_Scancode.SDL_SCANCODE_RIGHT, }; } + + /// 把盘上读到的配置解析为有效配置:缺档 / 旧 VK 档(SchemaVersion 落后)→ 默认值。 + public static KeyMappingConfig ResolveEffective(KeyMappingConfig? fromDisk) + { + if (fromDisk != null && fromDisk.SchemaVersion >= KeyMappingConfig.CurrentSchemaVersion) + return fromDisk; + + return Create(); + } + + /// 真实磁盘读取 + 解析(仅由 KeyMappingStore 的默认 loader 调用)。 + public static KeyMappingConfig LoadFromDiskOrDefaults() + => ResolveEffective(ConfigManager.LoadKeyMapping()); } \ No newline at end of file diff --git a/src/EasyCon.SDLInput/SdlKeyboardInputBinder.cs b/src/EasyCon.SDLInput/SdlKeyboardInputBinder.cs index 6708fc50..320377f6 100644 --- a/src/EasyCon.SDLInput/SdlKeyboardInputBinder.cs +++ b/src/EasyCon.SDLInput/SdlKeyboardInputBinder.cs @@ -1,5 +1,6 @@ using EasyCon.Core.Config; using EasyCon.Core.Input; +using EasyCon.Core.Maa; using EasyDevice; using SDL; @@ -8,15 +9,15 @@ namespace EasyCon.SDLInput; public sealed class SdlKeyboardInputBinder : IInputBinder, IDisposable { private readonly SdlEventLoop _eventLoop; - private readonly NintendoSwitch _switch; + private readonly IControllerSession _switch; private bool _enabled; private bool _started; private Func? _escapeKeydown; private Func? _escapeKeyup; - private readonly Dictionary> _keyMap = []; + private Dictionary> _keyMap = []; - public SdlKeyboardInputBinder(SdlEventLoop eventLoop, NintendoSwitch @switch) + public SdlKeyboardInputBinder(SdlEventLoop eventLoop, IControllerSession @switch) { _eventLoop = eventLoop; _switch = @switch; @@ -44,13 +45,13 @@ public void SetEnabled(bool enabled) public void UpdateKeyMapping(KeyMappingConfig mapping) { - _keyMap.Clear(); + Dictionary> map = new(); void AddButton(int scancode, SwitchButton button) { if (scancode == 0) return; var key = ECKeyUtil.Button(button); - _keyMap[scancode] = (sw, down) => + map[scancode] = (sw, down) => { if (down) sw.Down(key); else sw.Up(key); }; @@ -60,7 +61,7 @@ void AddHat(int scancode, SwitchHAT hat) { if (scancode == 0) return; var key = ECKeyUtil.HAT(hat); - _keyMap[scancode] = (sw, down) => + map[scancode] = (sw, down) => { if (down) sw.Down(key); else sw.Up(key); }; @@ -69,7 +70,7 @@ void AddHat(int scancode, SwitchHAT hat) void AddDirection(int scancode, DirectionKey dkey, bool isLeft) { if (scancode == 0) return; - _keyMap[scancode] = (sw, down) => + map[scancode] = (sw, down) => { if (isLeft) { @@ -85,7 +86,7 @@ void AddDirection(int scancode, DirectionKey dkey, bool isLeft) void AddHatDirection(int scancode, DirectionKey dkey) { if (scancode == 0) return; - _keyMap[scancode] = (sw, down) => + map[scancode] = (sw, down) => { sw.HatDirection(dkey, down); }; @@ -124,6 +125,9 @@ void AddHatDirection(int scancode, DirectionKey dkey) AddDirection(mapping.RSDown, DirectionKey.Down, false); AddDirection(mapping.RSLeft, DirectionKey.Left, false); AddDirection(mapping.RSRight, DirectionKey.Right, false); + + // 原子替换整张表:SDL 事件线程可能正在 HandleKeyEvent 中读取,避免原地 Clear/写入造成竞态。 + _keyMap = map; } public void RegisterEscapeKey(Func keydown, Func keyup) @@ -147,7 +151,9 @@ public void HandleKeyEvent(int scancode, bool down) if (_keyMap.TryGetValue(scancode, out var action)) { - action(_switch, down); + if (!_switch.CanAcceptManualInput) return; + try { action(_switch, down); } + catch (ControllerOperationException ex) when (ex.ErrorCode is "Busy" or "InputDisconnected") { } } } diff --git a/src/EasyCon.Script/Assembly/Assembler.cs b/src/EasyCon.Script/Assembly/Assembler.cs deleted file mode 100644 index b7342a9e..00000000 --- a/src/EasyCon.Script/Assembly/Assembler.cs +++ /dev/null @@ -1,201 +0,0 @@ -using EasyCon.Script.Binding; -using static EasyCon.Script.Binding.BoundNodeKind; - -namespace EasyCon.Script.Asm; - -class Assembler -{ - public const uint IReg = 7; - - readonly List _instructions = []; - public readonly Dictionary KeyMapping = []; - public readonly Dictionary StickMapping = []; - public readonly Dictionary FunctionMapping = []; - public readonly Dictionary CallMapping = []; - - - static Instruction Assert(Instruction ins) - { - if (ins is Instruction.Failed fins) - throw new AssembleException(fins.Message); - return ins; - } - - public void Add(Instruction ins) - { - _instructions.Add(Assert(ins)); - } - - public Instruction Last() - { - return _instructions.Last(); - } - - private static void Assemble(TextWriter f, BoundBlockStatement body) - { - var index = 0; - while (index < body.Statements.Length) - { - var s = body.Statements[index]; - switch (s.Kind) - { - case NopStatement: - f.WriteLine("NOP"); - break; - case ExpressionStatement: - f.WriteLine("EXP"); - break; - case KeyAction: - f.WriteLine("KEY"); - break; - case GotoStatement: - f.WriteLine("GOTO"); - break; - case ConditionGotoStatement: - f.WriteLine("JGOTO"); - break; - case Label: - f.WriteLine("LBL:"); - break; - case Return: - f.WriteLine("RET"); - break; - default: - f.WriteLine("UNK"); - break; - } - } - } - - public byte[] Assemble(BoundProgram prog, bool autoRun) - { - // initialize - _instructions.Clear(); - //ForMapping.Clear(); - //IfMapping.Clear(); - //ElseMapping.Clear(); - KeyMapping.Clear(); - StickMapping.Clear(); - FunctionMapping.Clear(); - CallMapping.Clear(); - - // compile into instructions - using var f = new StringWriter(); - foreach (var body in prog.Functions.Values) - { - Assemble(f, body); - } - File.WriteAllText("code.tmp", f.ToString()); - - throw new AssembleException("此版本暂不支持编译"); -#pragma warning disable CS0162 - // optimize - var discarded = new HashSet(); - List list = new(); - foreach (var item in _instructions) - { - list.Add(item); - - // 1 Instruction - var ins1 = list[^1]; // list.Count - 1 - - // 2 Instructions - if (list.Count < 2) - continue; - var ins2 = ins1; - ins1 = list[^2]; // list.Count - 2 - // keypress-wait => compressed keypress - if (ins1 is Instructions.AsmKey_Standard press && ins2 is Instructions.AsmWait wait) - { - var ins = Instructions.AsmKey_Compressed.Create(press.KeyCode, press.RealDuration, wait.RealDuration); - if (ins.Success) - { - list.Add(ins); - discarded.Add(ins1); - discarded.Add(ins2); - continue; - } - } - - // 3 Instructions - if (list.Count < 3) - continue; - var ins3 = ins2; - ins2 = ins1; - ins1 = list[^3]; - // if-loopcontrol-endif => loopcontrol_cf - if (ins1 is Instructions.AsmBranchFalse falseins && ins2 is Instructions.AsmLoopControl ctlins && ins3 is Instructions.AsmEmpty && falseins.Target == ins3) - { - ctlins.CheckFlag = 1; - discarded.Add(ins1); - continue; - } - - // 4 Instructions - if (list.Count < 4) - continue; - var ins4 = ins3; - ins3 = ins2; - ins2 = ins1; - ins1 = list[list.Count - 4]; - // if-loopcontrol-else-endif => loopcontrol_cf-ifnot-endif - if (ins1 is Instructions.AsmBranchFalse falsein && ins2 is Instructions.AsmLoopControl ctlin && ins3 is Instructions.AsmBranch branch3 && ins4 is Instructions.AsmEmpty && falsein.Target == ins4) - { - ctlin.CheckFlag = 1; - list.Add(Instructions.AsmBranchTrue.Create(branch3.Target)); - discarded.Add(ins1); - discarded.Add(ins3); - continue; - } - } - _instructions.Clear(); - - // update address and index - int addr = 2; - int index = 0; - for (int i = 0; i < list.Count; i++) - { - var ins = list[i]; - ins.Address = addr; - ins.Index = index; - if (!discarded.Contains(ins)) - { - addr += ins.ByteCount; - index += ins.InsCount; - _instructions.Add(ins); - } - } - - // generate bytecode - using var stream = new MemoryStream(); - // preserved for length - stream.WriteByte(0); - stream.WriteByte(0); - // write instructions - foreach (var ins in _instructions) - if (!discarded.Contains(ins)) - ins.WriteBytes(stream); - // get array - var bytes = stream.ToArray(); - // write length - bytes[0] = (byte)(bytes.Length & 0xFF); - bytes[1] = (byte)(bytes.Length >> 8); - - if (!autoRun) - { - bytes[1] |= 0x80; - } - return bytes; - } -} - -public class AssembleException : Exception -{ - public int Index; - - public AssembleException(string message, int index = -1) - : base(message) - { - Index = index; - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instruction.cs b/src/EasyCon.Script/Assembly/Instruction.cs deleted file mode 100644 index fddb7130..00000000 --- a/src/EasyCon.Script/Assembly/Instruction.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace EasyCon.Script.Asm; - -abstract class Instruction -{ - public const uint REG_COUNT = 8; - - public int Index { get; set; } - public int Address { get; set; } - public virtual int InsCount => 1; - public virtual int ByteCount => 2; - public bool Success => this is not Failed; - - public abstract void WriteBytes(Stream stream); - - public byte[] GetBytes() - { - using (var stream = new MemoryStream()) - { - WriteBytes(stream); - return stream.ToArray(); - } - } - - protected static bool Scale(ref uint val, uint unit) - { - // Ceiling(val / unit) - var b = val % unit == 0; - val = (val + unit - 1) / unit; - return b; - } - - protected static bool Scale(ref int val, int unit) - { - // Ceiling(val / unit) - var b = val % unit == 0; - val = (val + unit - 1) / unit; - return b; - } - - ulong _bitbuffer = 0; - int _bitcount = 0; - - protected void WriteBits(Stream stream, ulong bits, int bitcount) - { - _bitbuffer = _bitbuffer << bitcount | bits; - _bitcount += bitcount; - while (_bitcount >= 8) - { - stream.WriteByte((byte)(_bitbuffer >> _bitcount - 8)); - //_bitbuffer = _bitbuffer & ((1ul << (_bitcount - 8)) - 1); - _bitcount -= 8; - } - } - - protected void WriteBits(Stream stream, long bits, int bitcount) - { - WriteBits(stream, (ulong)bits, bitcount); - } - - // simulated exception to avoid try-catch hell - public class Failed : Instruction - { - public string Message; - - Failed(string message) - { - Message = message; - } - - public override void WriteBytes(Stream stream) - { - throw new InvalidOperationException(); - } - - public static readonly Failed OutOfRange = new("数值超出范围"); - public static readonly Failed InvalidArgument = new("错误的参数格式"); - public static readonly Failed NotSupported = new("这个语句暂时只能联机模式使用"); - public static readonly Failed NotImplemented = new("类型未定义,这可能是一个bug,请汇报给作者"); - } - - public static Instruction CreateInstance([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type, params object[] args) - { - return type.GetMethod("Create", System.Reflection.BindingFlags.FlattenHierarchy | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public).Invoke(null, args) as Instruction; - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmBinaryOp.cs b/src/EasyCon.Script/Assembly/Instructions/AsmBinaryOp.cs deleted file mode 100644 index 24b91d55..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmBinaryOp.cs +++ /dev/null @@ -1,172 +0,0 @@ -using EasyCon.Script.Syntax; - -namespace EasyCon.Script.Asm.Instructions; - -public enum BinaryOperator -{ - Mov = 0b000, - Add = 0b001, - Mul = 0b010, - Div = 0b011, - Mod = 0b100, - And = 0b101, - Or = 0b110, - Xor = 0b111, -} - -abstract class AsmBinaryOp : Instruction - where T : AsmBinaryOp, new() -{ - - public readonly uint Op; - public uint RegDst; - public uint RegSrc; - - public AsmBinaryOp() - { - Op = (uint)((Attribute.GetCustomAttribute(typeof(T), typeof(AsmBinaryOperatorAttribute)) as AsmBinaryOperatorAttribute)!).Operator; - } - - public static Instruction Create(uint regdst, BaseExpr value) - { - if (value is VariableExpr valR) - { - var ins = new T - { - RegDst = regdst, - RegSrc = valR.Reg - }; - return ins; - } - else if (value is LiteralExpr valIns) - { - var val = (int)valIns.Value; - if (val < -(1 << 15) || val >= 1 << 15) - return Failed.OutOfRange; - var ins = new AsmBinaryOpInstant - { - RegDst = regdst, - Value = val - }; - return ins; - } - else - return Failed.NotImplemented; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0101, 4); - WriteBits(stream, 0b10, 2); - WriteBits(stream, Op, 3); - WriteBits(stream, RegDst, 3); - WriteBits(stream, RegSrc, 3); - } -} - -class AsmBinaryOpInstant : Instruction -{ - public override int ByteCount => 4; - - public readonly uint Op; - public uint RegDst; - public int Value; - - public AsmBinaryOpInstant() - { - Op = (uint)((Attribute.GetCustomAttribute(typeof(T), typeof(AsmBinaryOperatorAttribute)) as AsmBinaryOperatorAttribute)!).Operator; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0101, 4); - WriteBits(stream, 0, 1); - WriteBits(stream, 0b000, 3); - WriteBits(stream, 0, 1); - WriteBits(stream, Op, 3); - WriteBits(stream, RegDst, 3); - WriteBits(stream, Value, 16); - } -} - -[AttributeUsage(AttributeTargets.Class)] -class AsmBinaryOperatorAttribute : Attribute -{ - public readonly BinaryOperator Operator; - - public AsmBinaryOperatorAttribute(BinaryOperator @operator) - { - Operator = @operator; - } -} - -[AsmBinaryOperator(BinaryOperator.Mov)] -class AsmMov : AsmBinaryOp -{ - public static new Instruction Create(uint regdst, BaseExpr value) - { - var ins = AsmMovCompressed.Create(regdst, value); - if (ins.Success) - return ins; - return AsmBinaryOp.Create(regdst, value); - } -} - -[AsmBinaryOperator(BinaryOperator.Add)] -class AsmAdd : AsmBinaryOp -{ } - -[AsmBinaryOperator(BinaryOperator.Mul)] -class AsmMul : AsmBinaryOp -{ } - -[AsmBinaryOperator(BinaryOperator.Div)] -class AsmDiv : AsmBinaryOp -{ } - -[AsmBinaryOperator(BinaryOperator.Mod)] -class AsmMod : AsmBinaryOp -{ } - -[AsmBinaryOperator(BinaryOperator.And)] -class AsmAnd : AsmBinaryOp -{ } - -[AsmBinaryOperator(BinaryOperator.Or)] -class AsmOr : AsmBinaryOp -{ } - -[AsmBinaryOperator(BinaryOperator.Xor)] -class AsmXor : AsmBinaryOp -{ } - -class AsmMovCompressed : Instruction -{ - public uint RegDst; - public int Value; - - public static Instruction Create(uint regdst, BaseExpr value) - { - if (value is not LiteralExpr vi) - return Failed.InvalidArgument; - var val = (int)vi.Value; - if (val < -(1 << 6) || val >= 1 << 6) - return Failed.OutOfRange; - return new AsmMovCompressed - { - RegDst = regdst, - Value = val - }; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0101, 4); - WriteBits(stream, 0, 1); - WriteBits(stream, RegDst, 3); - WriteBits(stream, Value, 7); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmBitwiseShift.cs b/src/EasyCon.Script/Assembly/Instructions/AsmBitwiseShift.cs deleted file mode 100644 index 28b35a76..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmBitwiseShift.cs +++ /dev/null @@ -1,72 +0,0 @@ -using EasyCon.Script.Syntax; - -namespace EasyCon.Script.Asm.Instructions; - -public enum BitwiseShiftOperator -{ - ShiftLeft = 0, - ShiftRight = 1, -} - -abstract class AsmBitwiseShift : Instruction - where T : AsmBitwiseShift, new() -{ - - public readonly uint Op; - public uint RegDst; - public int Value; - - public AsmBitwiseShift() - { - Op = (uint)((Attribute.GetCustomAttribute(typeof(T), typeof(AsmBinaryOperatorAttribute)) as AsmBinaryOperatorAttribute)!).Operator; - } - - public static Instruction Create(uint regdst, BaseExpr value) - { - if ((value is VariableExpr)) - { - return Failed.InvalidArgument; - } - else if (value is LiteralExpr vli) - { - var val = (int)vli.Value; - if (val < 0 || val >= 1 << 4) - return Failed.OutOfRange; - var ins = new AsmBinaryOpInstant(); - ins.RegDst = regdst; - ins.Value = val; - return ins; - } - else - return Failed.NotImplemented; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0101, 4); - WriteBits(stream, 0b110, 3); - WriteBits(stream, Op, 1); - WriteBits(stream, RegDst, 3); - WriteBits(stream, Value, 4); - } -} - -[AttributeUsage(AttributeTargets.Class)] -class AsmBitwiseShiftOperatorAttribute : Attribute -{ - public readonly BitwiseShiftOperator Operator; - - public AsmBitwiseShiftOperatorAttribute(BitwiseShiftOperator @operator) - { - Operator = @operator; - } -} - -[AsmBitwiseShiftOperator(BitwiseShiftOperator.ShiftLeft)] -class AsmShiftLeft : AsmBitwiseShift -{ } - -[AsmBitwiseShiftOperator(BitwiseShiftOperator.ShiftRight)] -class AsmShiftRight : AsmBitwiseShift -{ } \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmBranchBase.cs b/src/EasyCon.Script/Assembly/Instructions/AsmBranchBase.cs deleted file mode 100644 index b70c8e34..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmBranchBase.cs +++ /dev/null @@ -1,99 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -abstract class AsmBranchBase : Instruction -{ - protected enum BranchType - { - Force = 0b00, - JmpTrue = 0b01, - JmpFalse = 0b10, - Call = 0b11, - } - - public uint Code; - public Instruction? Target; - - protected static Instruction Create(BranchType code, Instruction? target = null) - where T : AsmBranchBase, new() - { - var ins = new T - { - Code = (uint)code, - Target = target - }; - return ins; - } - - public override void WriteBytes(Stream stream) - { - var offset = (Target.Address - Address - 2) >> 1; - if (offset < -(1 << 8) || offset >= 1 << 8) - throw new AssembleException("跳转超出范围"); - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0110, 4); - WriteBits(stream, Code, 2); - WriteBits(stream, offset, 9); - } -} - -class AsmBranch : AsmBranchBase -{ - public static Instruction Create(Instruction? target = null) - { - return Create(BranchType.Force, target); - } -} - -class AsmBranchTrue : AsmBranchBase -{ - public static Instruction Create(Instruction? target = null) - { - return Create(BranchType.JmpTrue, target); - } -} - -class AsmBranchFalse : AsmBranchBase -{ - public static Instruction Create(Instruction? target = null) - { - return Create(BranchType.JmpFalse, target); - } -} - -class AsmCall : AsmBranchBase -{ - public readonly string lbl; - public static Instruction Create(Instruction? target = null) - { - return Create(BranchType.Call, target); - } - public static Instruction Create(string label) - { - return Create(BranchType.Call, null); - } -} - -class AsmReturn : Instruction -{ - public uint CheckFlag; - - public static Instruction Create(uint checkflag) - { - var ins = new AsmReturn - { - CheckFlag = checkflag - }; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0100, 4); - WriteBits(stream, 0, 1); - WriteBits(stream, 0b00, 3); - WriteBits(stream, 0b111, 3); - WriteBits(stream, CheckFlag, 1); - WriteBits(stream, 0b0000, 4); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmCompare.cs b/src/EasyCon.Script/Assembly/Instructions/AsmCompare.cs deleted file mode 100644 index e85e14cf..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmCompare.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -abstract class AsmCompare : Instruction -{ - public enum AssignType - { - Assign = 0b00, - And = 0b01, - Or = 0b10, - Xor = 0b11, - } - - public uint Code; - public AssignType Type; - public uint Reg0; - public uint Reg1; - - protected static Instruction Create(uint code, AssignType type, uint reg0, uint reg1) - where T : AsmCompare, new() - { - var ins = new T(); - ins.Code = code; - ins.Type = type; - ins.Reg0 = reg0; - ins.Reg1 = reg1; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0100, 4); - WriteBits(stream, 1, 1); - WriteBits(stream, Code, 2); - WriteBits(stream, (uint)Type, 2); - WriteBits(stream, Reg0, 3); - WriteBits(stream, Reg1, 3); - } -} - -class AsmEqual : AsmCompare -{ - public static Instruction Create(AssignType type, uint reg0, uint reg1) - { - return Create(0b00, type, reg0, reg1); - } -} - -class AsmNotEqual : AsmCompare -{ - public static Instruction Create(AssignType type, uint reg0, uint reg1) - { - return Create(0b01, type, reg0, reg1); - } -} - -class AsmLessThan : AsmCompare -{ - public static Instruction Create(AssignType type, uint reg0, uint reg1) - { - return Create(0b10, type, reg0, reg1); - } -} - -class AsmLessOrEqual : AsmCompare -{ - public static Instruction Create(AssignType type, uint reg0, uint reg1) - { - return Create(0b11, type, reg0, reg1); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmEmpty.cs b/src/EasyCon.Script/Assembly/Instructions/AsmEmpty.cs deleted file mode 100644 index d216d6a2..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmEmpty.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -class AsmEmpty : Instruction -{ - public override int ByteCount => 0; - - public override int InsCount => 0; - - public static Instruction Create() - { - return new AsmEmpty(); - } - - public override void WriteBytes(Stream stream) - { } -} - -class AsmLabel : Instruction -{ - public override int ByteCount => 0; - - public override int InsCount => 0; - - public string Label { get; set; } = string.Empty; - - public static Instruction Create(string lbl) - { - return new AsmLabel() { Label = lbl }; - } - - public override void WriteBytes(Stream stream) - { } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmFor.cs b/src/EasyCon.Script/Assembly/Instructions/AsmFor.cs deleted file mode 100644 index 5692cb45..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmFor.cs +++ /dev/null @@ -1,123 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -class AsmFor : Instruction -{ - public AsmNext? Next; - - public static Instruction Create(AsmNext? next = null) - { - var ins = new AsmFor(); - ins.Next = next; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0010, 4); - WriteBits(stream, Next.Address, 11); - } -} - -abstract class AsmNext : Instruction -{ - public int LoopNumber; - - public static Instruction Create(int loopnumber) - { - var ins = AsmNext_Small.Create(loopnumber); - if (ins.Success) - return ins; - return AsmNext_Large.Create(loopnumber); - } -} - -class AsmNext_Small : AsmNext -{ - public static new Instruction Create(int loopnumber) - { - if (loopnumber >= 1 << 10 || loopnumber < 0) - return Failed.OutOfRange; - var ins = new AsmNext_Small - { - LoopNumber = loopnumber - }; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0011, 4); - WriteBits(stream, 0, 1); - WriteBits(stream, LoopNumber, 10); - } -} - -class AsmNext_Large : AsmNext -{ - public override int ByteCount => 4; - - public static new Instruction Create(int loopnumber) - { - if (loopnumber >= 1 << 26 || loopnumber < 0) - return Failed.OutOfRange; - var ins = new AsmNext_Large(); - ins.LoopNumber = loopnumber; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0011, 4); - WriteBits(stream, 1, 1); - WriteBits(stream, LoopNumber, 26); - } -} - -abstract class AsmLoopControl : Instruction -{ - public uint Code; - public uint CheckFlag; - public int Level; - - protected static Instruction Create(uint code, uint checkflag, int level) - where T : AsmLoopControl, new() - { - if (level < 0 || level >= 1 << 4) - return Failed.OutOfRange; - var ins = new T(); - ins.Code = code; - ins.CheckFlag = checkflag; - ins.Level = level; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0100, 4); - WriteBits(stream, 0, 1); - WriteBits(stream, 0b00, 2); - WriteBits(stream, Code, 3); - WriteBits(stream, CheckFlag, 1); - WriteBits(stream, Level, 4); - } -} - -class AsmBreak : AsmLoopControl -{ - public static Instruction Create(uint checkflag, int level) - { - return Create(0b000, checkflag, level); - } -} - -class AsmContinue : AsmLoopControl -{ - public static Instruction Create(uint checkflag, int level) - { - return Create(0b001, checkflag, level); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmKey.cs b/src/EasyCon.Script/Assembly/Instructions/AsmKey.cs deleted file mode 100644 index bf79119e..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmKey.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -abstract class AsmKey : Instruction -{ - public int KeyCode; -} - -class AsmKey_Standard : AsmKey -{ - public int RealDuration => Duration * Unit; - public const int Unit = 10; - public int Duration; - - public static Instruction Create(int keycode, int duration) - { - Scale(ref duration, Unit); - if (duration < 0) - return AsmEmpty.Create(); - if (duration >= 1 << 8) - return Failed.OutOfRange; - var ins = new AsmKey_Standard - { - KeyCode = keycode, - Duration = duration - }; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0b10, 2); - WriteBits(stream, KeyCode, 5); - WriteBits(stream, 0, 1); - WriteBits(stream, Duration, 8); - } -} - -class AsmKey_Compressed : AsmKey -{ - public int WaitTime; - - public override int InsCount => 2; - - public static Instruction Create(int keycode, int duration, int waittime) - { - if (duration != 50) - return Failed.InvalidArgument; - if (waittime < 0) - waittime = 0; - if (waittime == 0 || !Scale(ref waittime, 50)) - return Failed.InvalidArgument; - if (waittime >= 1 << 7) - return Failed.OutOfRange; - var ins = new AsmKey_Compressed(); - ins.KeyCode = keycode; - ins.WaitTime = waittime; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0b10, 2); - WriteBits(stream, KeyCode, 5); - WriteBits(stream, 0b10, 2); - WriteBits(stream, WaitTime, 7); - } -} - -class AsmKey_Hold : AsmKey -{ - public Instruction? HoldUntil; - - public static Instruction Create(int keycode) - { - var ins = new AsmKey_Hold(); - ins.KeyCode = keycode; - return ins; - } - - public override void WriteBytes(Stream stream) - { - var offset = HoldUntil?.Index - Index ?? 0; - if (offset >= 1 << 7) - offset = 0; - WriteBits(stream, 0b10, 2); - WriteBits(stream, KeyCode, 5); - WriteBits(stream, 0b11, 2); - WriteBits(stream, offset, 7); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmSerialPrint.cs b/src/EasyCon.Script/Assembly/Instructions/AsmSerialPrint.cs deleted file mode 100644 index e7df9603..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmSerialPrint.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -class AsmSerialPrint : Instruction -{ - public uint Mem; - public uint Value; - - public static Instruction Create(uint mem, uint value) - { - if (value >= 1 << 9) - return Failed.OutOfRange; - var ins = new AsmSerialPrint(); - ins.Mem = mem; - ins.Value = value; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0000, 4); - WriteBits(stream, 1, 1); - WriteBits(stream, Mem, 1); - WriteBits(stream, Value, 9); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmStick.cs b/src/EasyCon.Script/Assembly/Instructions/AsmStick.cs deleted file mode 100644 index 53d52cc9..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmStick.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -abstract class AsmStick : Instruction -{ - public int KeyCode; - public int LR => KeyCode & 1; - public int DIndex; -} - -class AsmStick_Standard : AsmStick -{ - public int Duration; - - public static Instruction Create(int keycode, int dindex, int duration) - { - Scale(ref duration, 50); - if (duration < 0) - return AsmEmpty.Create(); - if (duration >= 1 << 7) - return Failed.OutOfRange; - var ins = new AsmStick_Standard - { - KeyCode = keycode, - DIndex = dindex, - Duration = duration - }; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0b11, 2); - WriteBits(stream, LR, 1); - WriteBits(stream, DIndex, 5); - WriteBits(stream, 0, 1); - WriteBits(stream, Duration, 7); - } -} - -class AsmStick_Hold : AsmStick -{ - public Instruction? HoldUntil; - - public static Instruction Create(int keycode, int dindex) - { - var ins = new AsmStick_Hold(); - ins.KeyCode = keycode; - ins.DIndex = dindex; - return ins; - } - - public override void WriteBytes(Stream stream) - { - var offset = HoldUntil?.Index - Index ?? 0; - if (offset >= 1 << 7) - offset = 0; - WriteBits(stream, 0b11, 2); - WriteBits(stream, LR, 1); - WriteBits(stream, DIndex, 5); - WriteBits(stream, 1, 1); - WriteBits(stream, offset, 7); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmUnaryOp.cs b/src/EasyCon.Script/Assembly/Instructions/AsmUnaryOp.cs deleted file mode 100644 index a15ab4a6..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmUnaryOp.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -abstract class AsmUnaryOp : Instruction -{ - protected enum Operator - { - Negative = 0b0010, - Not = 0b0011, - Push = 0b0100, - Pop = 0b0101, - LoadFor = 0b0110, - StoreOp = 0b0111, - Bool = 0b1000, - Rand = 0b1001, - } - - public uint Op; - public uint Reg; - - protected static Instruction Create(Operator op, uint reg) - where T : AsmUnaryOp, new() - { - var ins = new T - { - Op = (uint)op, - Reg = reg - }; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0101, 4); - WriteBits(stream, 0b111, 3); - WriteBits(stream, 1, 1); - WriteBits(stream, Op, 4); - WriteBits(stream, Reg, 3); - } -} - -class AsmNegative : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.Negative, reg); - } -} - -class AsmNot : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.Not, reg); - } -} - -class AsmPush : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.Push, reg); - } -} - -class AsmPop : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.Pop, reg); - } -} - -class AsmStoreOp : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.StoreOp, reg); - } -} - -class AsmBool : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.Bool, reg); - } -} - -class AsmRand : AsmUnaryOp -{ - public static Instruction Create(uint reg) - { - return Create(Operator.Rand, reg); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Assembly/Instructions/AsmWait.cs b/src/EasyCon.Script/Assembly/Instructions/AsmWait.cs deleted file mode 100644 index 78a9d1a3..00000000 --- a/src/EasyCon.Script/Assembly/Instructions/AsmWait.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace EasyCon.Script.Asm.Instructions; - -abstract class AsmWait : Instruction -{ - public abstract int RealDuration { get; } - - public static Instruction Create(int duration) - { - if (duration < 0) - return AsmEmpty.Create(); - var ins = AsmWait_HighPrecision.Create(duration); - if (ins.Success) - return ins; - ins = AsmWait_Standard.Create(duration); - if (ins.Success) - return ins; - return AsmWait_Extended.Create(duration); - } -} - -class AsmWait_Standard : AsmWait -{ - public override int RealDuration => Duration * Unit; - public const int Unit = 10; - public int Duration; - - public new static Instruction Create(int duration) - { - Scale(ref duration, Unit); - if (duration >= 1 << 10) - return Failed.OutOfRange; - var ins = new AsmWait_Standard(); - ins.Duration = duration; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0001, 4); - WriteBits(stream, 0, 1); - WriteBits(stream, Duration, 10); - } -} - -class AsmWait_Extended : AsmWait -{ - public override int RealDuration => Duration * Unit; - public const int Unit = 10; - public int Duration; - - public override int ByteCount => 4; - - public new static Instruction Create(int duration) - { - Scale(ref duration, Unit); - if (duration >= 1 << 25) - return Failed.OutOfRange; - var ins = new AsmWait_Extended(); - ins.Duration = duration; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0001, 4); - WriteBits(stream, 1, 1); - WriteBits(stream, 0, 1); - WriteBits(stream, Duration, 25); - } -} - -class AsmWait_HighPrecision : AsmWait -{ - public override int RealDuration => Duration; - public int Duration; - - public new static Instruction Create(int duration) - { - if (duration >= 1 << 9) - return Failed.OutOfRange; - var ins = new AsmWait_HighPrecision(); - ins.Duration = duration; - return ins; - } - - public override void WriteBytes(Stream stream) - { - WriteBits(stream, 0, 1); - WriteBits(stream, 0b0001, 4); - WriteBits(stream, 1, 1); - WriteBits(stream, 1, 1); - WriteBits(stream, Duration, 9); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Binding/Binder.Calls.cs b/src/EasyCon.Script/Binding/Binder.Calls.cs index 2a283bb8..6c901f61 100644 --- a/src/EasyCon.Script/Binding/Binder.Calls.cs +++ b/src/EasyCon.Script/Binding/Binder.Calls.cs @@ -25,9 +25,6 @@ internal sealed partial class Binder return (paramTypes, arguments[0].Type); } - if (function.Name == "JQ") - return (paramTypes, ScriptType.String); - var returnType = function.ReturnType.Equals(ScriptType.Any) ? arguments[0].Type : function.ReturnType; @@ -242,28 +239,6 @@ private BoundStmt BindCallStatement(CallStmt syntax) return BindErrorStatement(syntax); } - if (_legacySyntax && syntax.Args.Length == 1 && syntax.Args[0] is VariableExpr legacyVar) - { - var builtinFunc = candidates.FirstOrDefault(c => BuiltinFunctions.GetAll().Contains(c)); - // TIME 支持库函数匹配(builtin 或 stdlib 均可) - var timeFunc = builtinFunc == null - ? candidates.FirstOrDefault(c => c.Name == "TIME" && c.Parameters.Length == 0) - : null; - if (BuiltinFunctions.Rand == builtinFunc) - { - var randCallExpr = BindCallExpressionInternal(syntax, builtinFunc, [legacyVar]); - var variable = BindVariableDeclaration(legacyVar, false, ScriptType.Int); - return new BoundVariableDeclaration(syntax, variable, randCallExpr); - } - var timeTarget = builtinFunc?.Name == "TIME" ? builtinFunc : timeFunc; - if (timeTarget != null) - { - var timeCallExpr = BindCallExpressionInternal(syntax, timeTarget, []); - var variable = BindVariableDeclaration(legacyVar, false, ScriptType.Int); - return new BoundVariableDeclaration(syntax, variable, timeCallExpr); - } - } - var boundArgs = syntax.Args.Select(BindExpression).ToImmutableArray(); var function = ResolveOverload(syntax, name, candidates, boundArgs); if (function == null) diff --git a/src/EasyCon.Script/Binding/Binder.Declarations.cs b/src/EasyCon.Script/Binding/Binder.Declarations.cs index 440e796a..2b414a59 100644 --- a/src/EasyCon.Script/Binding/Binder.Declarations.cs +++ b/src/EasyCon.Script/Binding/Binder.Declarations.cs @@ -1,3 +1,4 @@ +using EasyCon.Script.Resolution; using EasyCon.Script.Runtime; using EasyCon.Script.Symbols; using EasyCon.Script.Syntax; @@ -30,6 +31,10 @@ private FunctionSymbol BindFuncDeclaration(FuncDeclBlock syntax) var returnType = BindTypeClause(syntax.Declare, syntax.Declare.Type) ?? ScriptType.Void; + // 参数数受指令编码/函数帧预算约束,不设独立的固定参数窗容量。 + if (syntax.Declare.Paramters.Length > Bytecode.EcsRegisters.ArgCount) + _diagnostics.ReportParamWindowOverflow(syntax.Declare.Location, syntax.Declare.Name, syntax.Declare.Paramters.Length); + // 尝试复用 DeclarationCollector 已创建的 FunctionSymbol 实例, // 避免 Binder 和 DeclarationCollector 各持一份实例导致 Layout 等运行时属性不一致。 var existing = TryFindMatchingFunction(syntax.Declare.Name, parameters); @@ -76,6 +81,10 @@ private FunctionSymbol BindExternDeclaration(ExternFuncStmt syntax) var returnType = BindTypeClause(syntax, syntax.ReturnType) ?? ScriptType.Void; + // EXTERN 与用户函数使用同一参数数编码上限。 + if (syntax.Parameters.Length > Bytecode.EcsRegisters.ArgCount) + _diagnostics.ReportParamWindowOverflow(syntax.Location, syntax.Name, syntax.Parameters.Length); + // 尝试复用 DeclarationCollector 已创建的 FunctionSymbol 实例 var existing = TryFindMatchingFunction(syntax.Name, parameters); if (existing != null) @@ -86,7 +95,9 @@ private FunctionSymbol BindExternDeclaration(ExternFuncStmt syntax) } // 兜底:DeclarationCollector 未创建过 - var function = new FunctionSymbol(syntax.Name, parameters.ToImmutable(), returnType, libraryName: syntax.Library, externalName: syntax.ExportName != syntax.Name ? syntax.ExportName : null); + var function = new FunctionSymbol(syntax.Name, parameters.ToImmutable(), returnType, + libraryName: ExternLibraryPath.Resolve(syntax.Library, syntax.Location.FileName), + externalName: syntax.ExportName != syntax.Name ? syntax.ExportName : null); function.LocalSlotCount = parameters.Count; if (BuiltinFunctions.GetAll().Any(b => b.Name == syntax.Name.ToUpper())) diff --git a/src/EasyCon.Script/Binding/Binder.Statements.cs b/src/EasyCon.Script/Binding/Binder.Statements.cs index 1b29e5c0..35f4f4b7 100644 --- a/src/EasyCon.Script/Binding/Binder.Statements.cs +++ b/src/EasyCon.Script/Binding/Binder.Statements.cs @@ -301,6 +301,16 @@ private BoundStmt BindConstantDeclaration(ConstantDeclStmt syntax) if (operand == null) return null; return unary.Op.Operate(Value.From(operand)); + case BoundConversionExpression conv: + var convOperand = TryEvaluateConstant(conv.Expression); + if (convOperand == null) return null; + var convValue = Value.From(convOperand); + if (conv.Type.Equals(ScriptType.Double)) + return Value.FromDouble(convValue.Type.Equals(ScriptType.Double) ? convValue.AsDouble() : convValue.ToInt()); + if (conv.Type.Equals(ScriptType.Int)) + return Value.FromInt(convValue.ToInt()); + return convOperand; + case BoundBinaryExpression bin: var left = TryEvaluateConstant(bin.Left); var right = TryEvaluateConstant(bin.Right); @@ -350,7 +360,8 @@ private BoundStmt BindConstantDeclaration(ConstantDeclStmt syntax) private static Value FoldConvertConstant(Value v, ScriptType targetType) { if (v.Type.Equals(targetType)) return v; - if (targetType.Equals(ScriptType.Double)) return Value.FromDouble(v.AsInt()); + if (targetType.Equals(ScriptType.Double)) + return Value.FromDouble(v.Type.Equals(ScriptType.Double) ? v.AsDouble() : v.AsInt()); if (targetType.Equals(ScriptType.UInt)) return Value.FromUInt(unchecked((uint)v.AsInt())); if (targetType.Equals(ScriptType.UInt64)) return Value.FromUInt64((ulong)v.AsInt()); if (targetType.Equals(ScriptType.Byte)) return Value.FromByte((byte)v.AsInt()); diff --git a/src/EasyCon.Script/Binding/Binder.cs b/src/EasyCon.Script/Binding/Binder.cs index e573ad49..f328b40c 100644 --- a/src/EasyCon.Script/Binding/Binder.cs +++ b/src/EasyCon.Script/Binding/Binder.cs @@ -22,7 +22,6 @@ internal sealed partial class Binder private readonly DiagnosticBag? _programDiagnostics; private readonly Resolution.ResolutionResult? _resolution; private readonly HashSet? _bindingFunctions; - private readonly bool _legacySyntax; public DiagnosticBag Diagnostics => _diagnostics; @@ -30,8 +29,7 @@ private Binder(BoundScope? parent, FunctionSymbol? function, ImmutableDictionary.Builder? lazyFunctionBodies = null, DiagnosticBag? programDiagnostics = null, HashSet? bindingFunctions = null, - Resolution.ResolutionResult? resolution = null, - bool legacySyntax = true) + Resolution.ResolutionResult? resolution = null) { _scope = new BoundScope(parent); _function = function; @@ -39,7 +37,6 @@ private Binder(BoundScope? parent, FunctionSymbol? function, _programDiagnostics = programDiagnostics; _resolution = resolution; _bindingFunctions = bindingFunctions; - _legacySyntax = legacySyntax; if (function != null) { @@ -56,7 +53,7 @@ private Binder(BoundScope? parent, FunctionSymbol? function, /// 随源码级合并管线一并退役。 /// public static BoundProgram BindProgram(Resolution.ResolutionResult resolution, ImmutableHashSet? externalVariables = default, - bool eagerBindMainFunctions = false, bool legacySyntax = true) + bool eagerBindMainFunctions = false) { var parentScope = resolution.GlobalScope ?? CreateRootScope(); parentScope.SetValidExternalVariables(externalVariables ?? []); @@ -75,7 +72,7 @@ public static BoundProgram BindProgram(Resolution.ResolutionResult resolution, I // --- 模块声明绑定(函数/extern)--- var bindingFunctions = new HashSet(); var moduleBinder = new Binder(new BoundScope(parentScope), function: null, - functionBodies, diagnostics, bindingFunctions, resolution: resolution, legacySyntax: legacySyntax); + functionBodies, diagnostics, bindingFunctions, resolution: resolution); var moduleFunctions = new List(); foreach (var member in resolution.Trees.SelectMany(t => t.Root.Members)) @@ -107,7 +104,7 @@ public static BoundProgram BindProgram(Resolution.ResolutionResult resolution, I foreach (var function in moduleFunctions) { if (functionBodies.ContainsKey(function)) continue; - var (body, binderFn) = BindFunctionBody(function, moduleBinder._scope, legacySyntax: legacySyntax); + var (body, binderFn) = BindFunctionBody(function, moduleBinder._scope); if (function.ReturnType != ScriptType.Void && !ControlFlowGraph.AllPathsReturn(body)) binderFn.Diagnostics.ReportAllPathsMustReturn(function.Declaration!.Declare.Location); functionBodies.Add(function, body); @@ -135,10 +132,9 @@ private static (BoundBlockStatement Body, Binder Binder) BindFunctionBody( FunctionSymbol function, BoundScope scope, ImmutableDictionary.Builder? lazyFunctionBodies = null, DiagnosticBag? programDiagnostics = null, - HashSet? bindingFunctions = null, - bool legacySyntax = true) + HashSet? bindingFunctions = null) { - var binderFn = new Binder(scope, function, lazyFunctionBodies, programDiagnostics, bindingFunctions, legacySyntax: legacySyntax); + var binderFn = new Binder(scope, function, lazyFunctionBodies, programDiagnostics, bindingFunctions); var stmts = ImmutableArray.CreateBuilder(); foreach (var stmt in function.Declaration!.Statements) stmts.Add(binderFn.BindStatement(stmt)); diff --git a/src/EasyCon.Script/Binding/BoundBinaryOperator.cs b/src/EasyCon.Script/Binding/BoundBinaryOperator.cs index b264017a..e1636bd5 100644 --- a/src/EasyCon.Script/Binding/BoundBinaryOperator.cs +++ b/src/EasyCon.Script/Binding/BoundBinaryOperator.cs @@ -110,7 +110,8 @@ private static (ScriptType Left, ScriptType Right) ApplyImplicitConversion(Token return (left, right); } - // 比较运算: ptr 与 int 互转 + // 比较运算: ptr 与 int 互转(int 侧提升为 ptr;ptr 只与 0 比较做有效性判断, + // PTR 比较语义 = 64 位整数序,仅 PC 侧出现) if (IsComparison(kind)) { if (left.Equals(ScriptType.Int) && right.Equals(ScriptType.Ptr)) diff --git a/src/EasyCon.Script/Binding/BoundExpr.cs b/src/EasyCon.Script/Binding/BoundExpr.cs index 30f4c9d3..7b5cad5d 100644 --- a/src/EasyCon.Script/Binding/BoundExpr.cs +++ b/src/EasyCon.Script/Binding/BoundExpr.cs @@ -12,11 +12,12 @@ internal abstract class BoundExpr(AstNode expr) : BoundNode public object? ConstantValue = null; } +/// 绑定失败的占位表达式:类型查询收敛为 Int(阻断 NIE 级联误报;M1 诊断结构化)。 internal sealed class BoundErrorExpression(AstNode expr) : BoundExpr(expr) { - public override ScriptType Type => throw new NotImplementedException(); + public override ScriptType Type => ScriptType.Int; - public override BoundNodeKind Kind => throw new NotImplementedException(); + public override BoundNodeKind Kind => BoundNodeKind.ErrorExpression; } diff --git a/src/EasyCon.Script/Binding/BoundNodeKind.cs b/src/EasyCon.Script/Binding/BoundNodeKind.cs index bcfdff3b..84e680da 100644 --- a/src/EasyCon.Script/Binding/BoundNodeKind.cs +++ b/src/EasyCon.Script/Binding/BoundNodeKind.cs @@ -36,4 +36,8 @@ public enum BoundNodeKind ForStatement, UntilStatement, IfStatement, + + + /// 绑定失败占位表达式(BoundErrorExpression;诊断已报告,下游按错误收敛)。 + ErrorExpression, } \ No newline at end of file diff --git a/src/EasyCon.Script/Binding/BoundUnaryOperator.cs b/src/EasyCon.Script/Binding/BoundUnaryOperator.cs index 354cc95d..07b844c6 100644 --- a/src/EasyCon.Script/Binding/BoundUnaryOperator.cs +++ b/src/EasyCon.Script/Binding/BoundUnaryOperator.cs @@ -21,6 +21,7 @@ private BoundUnaryOperator(TokenType syntaxKind, BoundUnaryOperatorKind kind, Sc private static BoundUnaryOperator[] _operators = { new(TokenType.SUB, BoundUnaryOperatorKind.Subtraction, ScriptType.Int, a => -a.AsInt()), + new(TokenType.SUB, BoundUnaryOperatorKind.Subtraction, ScriptType.Double, a => Value.FromDouble(-a.AsDouble())), new(TokenType.SUB, BoundUnaryOperatorKind.Subtraction, ScriptType.Byte, a => Value.FromByte((byte)(-a.AsByte()))), new(TokenType.SUB, BoundUnaryOperatorKind.Subtraction, ScriptType.UInt, a => Value.FromUInt(unchecked((uint)(-(int)a.AsUInt())))), new(TokenType.BitNot, BoundUnaryOperatorKind.BitwiseNot, ScriptType.Int, a => ~a.AsInt()), diff --git a/src/EasyCon.Script/Binding/BuiltinFunctions.cs b/src/EasyCon.Script/Binding/BuiltinFunctions.cs index c9d26a61..1233fc0a 100644 --- a/src/EasyCon.Script/Binding/BuiltinFunctions.cs +++ b/src/EasyCon.Script/Binding/BuiltinFunctions.cs @@ -15,12 +15,21 @@ public static class BuiltinFunctions public static readonly FunctionSymbol Amiibo = new("AMIIBO", [new("index", ScriptType.Int)], ScriptType.Void); public static readonly FunctionSymbol Beep = new("BEEP", [new("freq", ScriptType.Int), new("duration", ScriptType.Int)], ScriptType.Void); public static readonly FunctionSymbol Env = new("ENV", [new("name", ScriptType.String)], ScriptType.String); + // --- 实用工具(std 实现,热路径打洞加速:std 定 API/语义,宿主只做加速器) --- public static readonly FunctionSymbol StrEncode = new("ENCODE", [new("array", ScriptType.ArrayOf(ScriptType.Byte)), new("type", ScriptType.String, hasDefault: true, defaultValue: "utf8")], ScriptType.String); public static readonly FunctionSymbol Jq = new("JQ", [new("json", ScriptType.String), new("query", ScriptType.String)], ScriptType.String); + /// ENCODE 打洞:UTF-8/UTF-16 多字节解码语义与性能由宿主保证(std 无 CHR 原语不可自实现)。 + public static readonly FunctionSymbol EncodeHole = new("__ENCODE__", + [new("array", ScriptType.ArrayOf(ScriptType.Byte)), new("type", ScriptType.String)], + ScriptType.String); + /// JQ 打洞:JSON 解析加速核;查询语法演化走 std JQ 层。 + public static readonly FunctionSymbol JqHole = new("__JQ__", + [new("json", ScriptType.String), new("query", ScriptType.String)], + ScriptType.String); // --- 文件 IO(低级句柄) --- public static readonly FunctionSymbol FOpen = new("FOPEN", [new("path", ScriptType.String), new("mode", ScriptType.String)], ScriptType.Ptr); @@ -29,7 +38,7 @@ public static class BuiltinFunctions public static readonly FunctionSymbol FClose = new("FCLOSE", [new("handle", ScriptType.Ptr)], ScriptType.Void); public static readonly FunctionSymbol FEof = new("FEOF", [new("handle", ScriptType.Ptr)], ScriptType.Bool); - // --- 文件 IO(高级便捷) --- + // --- 文件 IO(高级便捷:std 组合实现 READFILE/WRITEFILE/APPENDFILE,见 StdLib) --- public static readonly FunctionSymbol ReadFile = new("READFILE", [new("path", ScriptType.String)], ScriptType.String); public static readonly FunctionSymbol WriteFile = new("WRITEFILE", [new("path", ScriptType.String), new("data", ScriptType.String)], ScriptType.Void); public static readonly FunctionSymbol AppendFile = new("APPENDFILE", [new("path", ScriptType.String), new("data", ScriptType.String)], ScriptType.Void); @@ -56,27 +65,96 @@ public static class BuiltinFunctions // --- OCR 置信度查询(普通 callable,注册到 root scope)--- public static readonly FunctionSymbol OcrConf = new("OCR_CONF", [], ScriptType.Int); - // --- ONNX 推理实验函数(L3 名表,宿主能力 IInference;Vision 特征位,P5)。 - // 纯标量协议:原生边界(EcxNativeContext)不携带数组,输出经 NET_OUT 逐元素读取。 - public static readonly FunctionSymbol NetLoad = new("NET_LOAD", [new("path", ScriptType.String)], ScriptType.Int); - public static readonly FunctionSymbol NetRun = new("NET_RUN", - [new("net", ScriptType.Int), new("input", ScriptType.Any)], ScriptType.Int); + public static readonly FunctionSymbol AudioLoad = new("AUDIO_LOAD", [new("labelName", ScriptType.String)], ScriptType.Void); + public static readonly FunctionSymbol AudioScore = new("AUDIO_SCORE", [new("labelName", ScriptType.String)], ScriptType.Int); + public static readonly FunctionSymbol AudioUnload = new("AUDIO_UNLOAD", [new("labelName", ScriptType.String)], ScriptType.Void); + + // --- ONNX 推理函数(L3 名表,宿主能力 IInference;Vision 特征位)。 + // 纯标量协议:原生边界(EcxNativeContext)不携带数组,大数组驻留宿主,输出经 NET_OUT 逐元素读取。 + public static readonly FunctionSymbol NetLoad = new("NET_LOAD", [new("path", ScriptType.String)], ScriptType.Ptr); public static readonly FunctionSymbol NetOut = new("NET_OUT", [new("index", ScriptType.Int)], ScriptType.Double); + // --- NET 句柄族:大数组驻留宿主侧,脚本只见句柄(原生边界纯标量协议不变)。 + // __NET_IMAGE__ 为洞(入参 base64 图像,由 StdLib 包装出采集/显式两种形态); + // __NET_IMAGE_FILE__ 为洞(入参本地文件路径,由 StdLib 包装 NET_IMAGE_FILE)。 + public static readonly FunctionSymbol NetImageHole = new("__NET_IMAGE__", + [new("image", ScriptType.String), new("width", ScriptType.Int), new("height", ScriptType.Int), new("mode", ScriptType.String)], + ScriptType.Ptr); + public static readonly FunctionSymbol NetImageFileHole = new("__NET_IMAGE_FILE__", + [new("path", ScriptType.String), new("width", ScriptType.Int), new("height", ScriptType.Int), new("mode", ScriptType.String)], + ScriptType.Ptr); + public static readonly FunctionSymbol NetRunH = new("NET_RUNH", + [new("net", ScriptType.Ptr), new("tensor", ScriptType.Ptr)], ScriptType.Int); + public static readonly FunctionSymbol NetScale = new("NET_SCALE", + [new("tensor", ScriptType.Ptr), new("scale", ScriptType.Double), new("offset", ScriptType.Double)], ScriptType.Ptr); + public static readonly FunctionSymbol NetFree = new("NET_FREE", [new("handle", ScriptType.Ptr)], ScriptType.Int); + public static readonly FunctionSymbol NetUnload = new("NET_UNLOAD", [new("net", ScriptType.Ptr)], ScriptType.Int); + public static readonly FunctionSymbol NetArgMax = new("NET_ARGMAX", [new("row", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol NetRows = new("NET_ROWS", [], ScriptType.Int); + /// 逐通道归一化:(x - mean[c]) / std[c],通道号由宿主按张量形状推断。 + /// /255 量程折叠留给 NET_SCALE 链式组合——每步都是通用原语,预处理策略留在脚本层。 + public static readonly FunctionSymbol NetNorm = new("NET_NORM", + [new("tensor", ScriptType.Ptr), new("mean", ScriptType.ArrayOf(ScriptType.Double)), new("std", ScriptType.ArrayOf(ScriptType.Double))], + ScriptType.Ptr); + /// 标签表:分类结果下标 → 类别名(纯字符串处理,不挂 Vision 特征位)。 + public static readonly FunctionSymbol NetLabels = new("NET_LABELS", [new("path", ScriptType.String)], ScriptType.Int); + public static readonly FunctionSymbol NetLabel = new("NET_LABEL", [new("index", ScriptType.Int)], ScriptType.String); + + // --- YOLO26 端到端检测(输出协议 [1,300,6] = x1,y1,x2,y2,conf,cls,letterbox 空间; + // argmax 已由模型内部完成)。配方在 std YOLO_DETECT;访问器读最近解码缓存。 + /// YOLO 解码洞:按置信度过滤 + letterbox→屏幕逆映射 + 内容区裁剪 + /// (Ultralytics predict 同约定;几何参数由 std 配方传入,内容区尺寸取自最近 + /// "letter" 模式 __NET_IMAGE__ 线程槽),缓存命中列表。输出缺失或形状非 [·,·,6] 返回 -1。 + public static readonly FunctionSymbol YoloDecodeHole = new("__YOLO_DECODE__", + [new("thresh", ScriptType.Double), new("scale", ScriptType.Double), new("padX", ScriptType.Double), + new("padY", ScriptType.Double), new("roiX", ScriptType.Int), new("roiY", ScriptType.Int)], + ScriptType.Int); + public static readonly FunctionSymbol YoloCount = new("YOLO_COUNT", [], ScriptType.Int); + public static readonly FunctionSymbol YoloCls = new("YOLO_CLS", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol YoloScore = new("YOLO_SCORE", [new("index", ScriptType.Int)], ScriptType.Double); + public static readonly FunctionSymbol YoloX1 = new("YOLO_X1", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol YoloY1 = new("YOLO_Y1", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol YoloX2 = new("YOLO_X2", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol YoloY2 = new("YOLO_Y2", [new("index", ScriptType.Int)], ScriptType.Int); + + // --- PP-OCR det 文本找框(det 全图一次 → 概率图 → 连通域 → 框;配方 std PP_DET_FIND)。 + /// det 找框洞:阈值二值化 + 连通域外轮廓,逆映射 screen = det/scale + roi 后 + /// ±2px unclip,阅读序(y 优先 x)缓存。宿主不做形状假设——概率图几何 (mapW×mapH) + /// 由 std 配方传入;数据不足(输出与几何不符)返回 -1。 + public static readonly FunctionSymbol DetBoxesHole = new("__DET_BOXES__", + [new("thresh", ScriptType.Double), new("scale", ScriptType.Double), + new("roiX", ScriptType.Int), new("roiY", ScriptType.Int), + new("mapW", ScriptType.Int), new("mapH", ScriptType.Int)], + ScriptType.Int); + public static readonly FunctionSymbol DetCount = new("DET_COUNT", [], ScriptType.Int); + public static readonly FunctionSymbol DetX = new("DET_X", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol DetY = new("DET_Y", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol DetW = new("DET_W", [new("index", ScriptType.Int)], ScriptType.Int); + public static readonly FunctionSymbol DetH = new("DET_H", [new("index", ScriptType.Int)], ScriptType.Int); + + // --- 采集卡打洞函数(lib-only scope 可见,不放入 root scope)--- public static readonly FunctionSymbol CaptureHole = new("__CAPTURE__", [new("x", ScriptType.Int), new("y", ScriptType.Int), new("width", ScriptType.Int), new("height", ScriptType.Int)], ScriptType.String); - public static readonly FunctionSymbol OcrHole = new("__OCR__", + /// Tesseract OCR 洞(脚本 OCR 能力面对接的唯一引擎;ppocr 走 NET 族 + std 配方)。 + public static readonly FunctionSymbol TesseractHole = new("__TESSERACT__", [new("x", ScriptType.Int), new("y", ScriptType.Int), new("width", ScriptType.Int), new("height", ScriptType.Int), new("lang", ScriptType.String)], ScriptType.String); public static readonly FunctionSymbol RoiHole = new("__ROI__", [new("image", ScriptType.String), new("x", ScriptType.Int), new("y", ScriptType.Int), new("width", ScriptType.Int), new("height", ScriptType.Int)], ScriptType.String); - public static readonly FunctionSymbol OcrInitHole = new("__OCR_INIT__", + /// Tesseract 初始化洞(tessdata 目录语义随签名,Tesseract 专属)。 + public static readonly FunctionSymbol TesseractInitHole = new("__TESSERACT_INIT__", [new("lang", ScriptType.String), new("dataPath", ScriptType.String), new("engineMode", ScriptType.String), new("psmode", ScriptType.String)], ScriptType.Bool); + /// CTC 贪心折叠洞:读最近一次 NET_RUNH 输出 [1,T,C],逐帧 argmax 后按 + /// blank 重置 + 连续去重 + charset 窗口过滤折叠为字符串。通用原语(不感知 ppocr), + /// 配方语义(ppocr 字典下标/数字子集)由 std CTC_FOLD 传入。 + public static readonly FunctionSymbol CtcFoldHole = new("__CTC_FOLD__", + [new("blank", ScriptType.Int), new("charset", ScriptType.String), new("base", ScriptType.Int)], + ScriptType.String); // --- 路由分类(内置函数实现的单一事实源,docs/VM2.md §7) --- @@ -120,13 +198,14 @@ public sealed record BuiltinDescriptor( new(Length, BuiltinRoute.Intrinsic, McuAvailable: true), new(StrConvert, BuiltinRoute.Intrinsic, McuAvailable: true), new(IntConvert, BuiltinRoute.Intrinsic, McuAvailable: true), - new(CaptureHole, BuiltinRoute.Intrinsic), - new(OcrHole, BuiltinRoute.Intrinsic), - new(RoiHole, BuiltinRoute.Intrinsic), - new(OcrInitHole, BuiltinRoute.Intrinsic), + new(CaptureHole, BuiltinRoute.NativeName), + new(TesseractHole, BuiltinRoute.NativeName), + new(RoiHole, BuiltinRoute.NativeName), + new(TesseractInitHole, BuiltinRoute.NativeName), + new(CtcFoldHole, BuiltinRoute.NativeName, EcsImageFeatures.Vision), // L2 平台 syscall(McuAvailable = ecs_main.c h_syscall 实测语义: - // FWRITE 行断协议 / FREAD 空串 / ALERT-BEEP-AMIIBO no-op / ARG-ENV 可用 / - // APP-TIME 恒默认 / OCR_CONF 与文件族其余未实现 → ERR) + // FWRITE 行断协议 / FREAD 空串 / ALERT-BEEP-AMIIBO no-op / ARG-ENV 可用 / APP-TIME 恒默认; + // 未实现的 OCR_CONF/文件族其余 → S-21 缺省值表降级,strict_caps 恢复 ERR) new(Alert, BuiltinRoute.Syscall, McuAvailable: true), new(Arg, BuiltinRoute.Syscall, McuAvailable: true), new(Env, BuiltinRoute.Syscall, McuAvailable: true), @@ -138,18 +217,47 @@ public sealed record BuiltinDescriptor( new(FWrite, BuiltinRoute.Syscall, EcsImageFeatures.File, McuAvailable: true), new(FClose, BuiltinRoute.Syscall, EcsImageFeatures.File), new(FEof, BuiltinRoute.Syscall, EcsImageFeatures.File), - new(ReadFile, BuiltinRoute.Syscall, EcsImageFeatures.File), - new(WriteFile, BuiltinRoute.Syscall, EcsImageFeatures.File), - new(AppendFile, BuiltinRoute.Syscall, EcsImageFeatures.File), new(FileExists, BuiltinRoute.Syscall, EcsImageFeatures.File), - // L3 名表原生(MCU native=NULL → 不可用) - new(StrEncode, BuiltinRoute.NativeName), - new(Jq, BuiltinRoute.NativeName), + // L3 名表原生(准入:能力洞 + std 加速洞;MCU native=NULL → 不可用) + new(EncodeHole, BuiltinRoute.NativeName), + new(JqHole, BuiltinRoute.NativeName), + new(AudioLoad, BuiltinRoute.NativeName, EcsImageFeatures.Audio), + new(AudioScore, BuiltinRoute.NativeName, EcsImageFeatures.Audio), + new(AudioUnload, BuiltinRoute.NativeName, EcsImageFeatures.Audio), new(NetLoad, BuiltinRoute.NativeName, EcsImageFeatures.Vision), - new(NetRun, BuiltinRoute.NativeName, EcsImageFeatures.Vision), new(NetOut, BuiltinRoute.NativeName, EcsImageFeatures.Vision), - // stdlib 源码(PRINT→FWRITE) + new(NetImageHole, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetRunH, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetScale, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetFree, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetUnload, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetArgMax, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetRows, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetNorm, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetImageFileHole, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(NetLabels, BuiltinRoute.NativeName), + new(NetLabel, BuiltinRoute.NativeName), + new(YoloDecodeHole, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloCount, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloCls, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloScore, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloX1, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloY1, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloX2, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(YoloY2, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(DetBoxesHole, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(DetCount, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(DetX, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(DetY, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(DetW, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + new(DetH, BuiltinRoute.NativeName, EcsImageFeatures.Vision), + // stdlib 源码(PRINT→FWRITE;实用工具 std 实现——JQ/ENCODE 打洞、文件便捷件组合) new(Print, BuiltinRoute.StdlibSource, McuAvailable: true), + new(StrEncode, BuiltinRoute.StdlibSource), + new(Jq, BuiltinRoute.StdlibSource), + new(ReadFile, BuiltinRoute.StdlibSource, EcsImageFeatures.File), + new(WriteFile, BuiltinRoute.StdlibSource, EcsImageFeatures.File), + new(AppendFile, BuiltinRoute.StdlibSource, EcsImageFeatures.File), ]; /// 符号 → 路由(由清单派生,binder/encoder 消费)。 @@ -165,22 +273,27 @@ public static BuiltinRoute RouteOf(FunctionSymbol fn) => // --- 采集卡能力追踪 --- - private static readonly HashSet CaptureRequiringFunctions = [CaptureHole, OcrHole, RoiHole, OcrInitHole]; + private static readonly HashSet CaptureRequiringFunctions = [CaptureHole, TesseractHole, RoiHole, TesseractInitHole]; public const string CapturePlaceholder = "__capture__"; public static bool RequiresCapture(FunctionSymbol fn) => CaptureRequiringFunctions.Contains(fn); // --- 注册到 root scope 的函数列表 --- private static readonly FunctionSymbol[] All = - [Wait, Alert, Rand, Amiibo, Beep, Env, Append, Length, StrEncode, StrConvert, IntConvert, Jq, Arg, OcrConf, - FOpen, FRead, FWrite, FClose, FEof, ReadFile, WriteFile, AppendFile, FileExists, NetLoad, NetRun, NetOut]; + [Wait, Alert, Rand, Amiibo, Beep, Env, Append, Length, StrConvert, IntConvert, Arg, OcrConf, + FOpen, FRead, FWrite, FClose, FEof, FileExists, NetLoad, NetOut, + NetRunH, NetScale, NetFree, NetUnload, NetArgMax, NetRows, + NetNorm, NetLabels, NetLabel, + YoloCount, YoloCls, YoloScore, YoloX1, YoloY1, YoloX2, YoloY2, + DetCount, DetX, DetY, DetW, DetH, AudioLoad, AudioScore, AudioUnload]; internal static IReadOnlyList GetAll() => All; public static bool IsBuiltin(FunctionSymbol fn) => All.Contains(fn); - // --- 采集卡洞函数列表(注册到 lib-only scope + callable)--- + // --- lib-only 洞函数列表(注册到 lib-only scope + callable;对用户脚本不可见)--- - private static readonly FunctionSymbol[] CaptureHoles = [CaptureHole, OcrHole, RoiHole, OcrInitHole]; - internal static IReadOnlyList GetCaptureHoles() => CaptureHoles; + private static readonly FunctionSymbol[] LibOnlyHoles = + [CaptureHole, TesseractHole, RoiHole, TesseractInitHole, NetImageHole, NetImageFileHole, CtcFoldHole, YoloDecodeHole, DetBoxesHole, EncodeHole, JqHole]; + internal static IReadOnlyList GetLibOnlyHoles() => LibOnlyHoles; } \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/BytecodeEncoder.Instructions.cs b/src/EasyCon.Script/Bytecode/BytecodeEncoder.Instructions.cs index 48b8da54..e96d655a 100644 --- a/src/EasyCon.Script/Bytecode/BytecodeEncoder.Instructions.cs +++ b/src/EasyCon.Script/Bytecode/BytecodeEncoder.Instructions.cs @@ -61,6 +61,8 @@ void EmitInst(SsaValue v) case SsaOp.AddDouble: EmitIabc(EcsOpcode.AddD, Slot(v), Slot(v.Arg0!), Slot(v.Arg1!)); break; case SsaOp.SubDouble: EmitIabc(EcsOpcode.SubD, Slot(v), Slot(v.Arg0!), Slot(v.Arg1!)); break; + case SsaOp.NegInt: EmitIabc(EcsOpcode.NegI, Slot(v), Slot(v.Arg0!), 0); break; + case SsaOp.NegDouble: EmitIabc(EcsOpcode.NegD, Slot(v), Slot(v.Arg0!), 0); break; case SsaOp.MulDouble: EmitIabc(EcsOpcode.MulD, Slot(v), Slot(v.Arg0!), Slot(v.Arg1!)); break; case SsaOp.DivDouble: EmitIabc(EcsOpcode.DivD, Slot(v), Slot(v.Arg0!), Slot(v.Arg1!)); break; @@ -95,6 +97,10 @@ void EmitInst(SsaValue v) case SsaOp.GeqUInt64: EmitCmp(EcsOpcode.GeL, v); break; case SsaOp.EqString: EmitCmp(EcsOpcode.EqS, v); break; case SsaOp.EqPtr: EmitCmp(EcsOpcode.EqP, v); break; + case SsaOp.LtPtr: EmitCmp(EcsOpcode.LtL, v); break; + case SsaOp.LeqPtr: EmitCmp(EcsOpcode.LeL, v); break; + case SsaOp.GtPtr: EmitCmp(EcsOpcode.GtL, v); break; + case SsaOp.GeqPtr: EmitCmp(EcsOpcode.GeL, v); break; case SsaOp.EqBool: EmitCmp(EcsOpcode.EqI, v); break; case SsaOp.EqByte: EmitCmp(EcsOpcode.EqI, v); break; case SsaOp.LtByte: EmitCmp(EcsOpcode.LtI, v); break; @@ -173,8 +179,8 @@ void EmitInst(SsaValue v) { var key = KeyByte(v); var dur = v.Arg0!; - if (dur.IsConstant && dur.Const.GetInt() is >= 0 and <= 0xFFFF) - EmitAbx(EcsOpcode.KeyI, key, dur.Const.GetInt()); + if (dur.IsConstant && dur.Const.GetInt() is >= 0) + EmitExt(EcsOpcode.KeyI, key, 0, 0, unchecked((uint)dur.Const.GetInt())); else EmitIabc(EcsOpcode.KeyV, key, Slot(dur), 0); break; @@ -205,8 +211,8 @@ void EmitInst(SsaValue v) case SsaOp.Wait: { var dur = v.Arg0!; - if (dur.IsConstant && dur.Const.GetInt() is >= 0 and <= 0xFFFF) - EmitAbx(EcsOpcode.WaitI, 0, dur.Const.GetInt()); + if (dur.IsConstant && dur.Const.GetInt() is >= 0) + EmitExt(EcsOpcode.WaitI, 0, 0, 0, unchecked((uint)dur.Const.GetInt())); else EmitIabc(EcsOpcode.WaitV, Slot(dur), 0, 0); break; @@ -231,11 +237,6 @@ void EmitInst(SsaValue v) throw Fail($"不支持的运行时值 {name}"); break; } - case SsaOp.Capture: EmitHoleCall(v, "__CAPTURE__"); break; - case SsaOp.Ocr: EmitHoleCall(v, "__OCR__"); break; - case SsaOp.Roi: EmitHoleCall(v, "__ROI__"); break; - case SsaOp.OcrInit: EmitHoleCall(v, "__OCR_INIT__"); break; - // ---- 调用 ---- case SsaOp.Call: case SsaOp.StaticCall: @@ -260,7 +261,8 @@ void EmitNeq(EcsOpcode eqOp, SsaValue v) void EmitConv(EcsConvKind kind, SsaValue v) => EmitIabc(EcsOpcode.Conv, Slot(v), Slot(v.Arg0!), (int)kind); - /// 内联数组字面量(小字面量/含非常量元素):staging + NewArrV(docs/VM2.md §5.2)。 + /// 内联数组字面量(小字面量):STAGING + NewArrV(docs/VM2.md §5.2)。 + /// 元素全常量(语法阶段白名单)且 ≥8 走常量模板降级,内联路径元素数恒 ≤ STAGING 容量。 void EmitInlineArrayInit(SsaValue v) { var elemCode = TypeCode(((ArrayType)v.Type).ElementType); @@ -272,6 +274,8 @@ void EmitInlineArrayInit(SsaValue v) var args = new List { v.Arg0 }; if (v.ExtraArgs != null) args.AddRange(v.ExtraArgs); + if (args.Count > _stagingCount) + throw Fail($"数组字面量元素数 {args.Count} 超过 STAGING 容量 {_stagingCount}(内部错误)"); for (int i = 0; i < args.Count; i++) EmitIabc(EcsOpcode.Move, _stagingBase + i, Slot(args[i]), 0); EmitExt(EcsOpcode.NewArrV, Slot(v), args.Count, _stagingBase, elemCode); @@ -284,7 +288,7 @@ void EmitCallInst(SsaValue v) bool hasResult = !sym.ReturnType.Equals(ScriptType.Void) && v.Uses > 0; // 用户函数(含 stdlib):值相等语义与 SsaEvaluator 一致(A-07)。 - // 本模块函数 → 模块局部 fid;跨模块 → 导入表标记 0x80000000|importIdx(docs/EcmEcxFormat.md §4.1)。 + // 本模块函数 → 模块局部 fid;跨模块 → 导入表标记 0x80000000|importIdx。 // extern 声明恒走 CallN 按名分发(镜像里没有 extern 的函数体,导入标记将无法解析); // 其余(内建原生等)→ CallN 按名分发。 bool isLocal = _ctx.LocalFuncIds.TryGetValue(sym, out var localFid); @@ -294,17 +298,12 @@ void EmitCallInst(SsaValue v) || (_ctx.ExternalFunctions != null && _ctx.ExternalFunctions.Contains(sym))); if (isLocal || isExternal) { - for (int i = 0; i < args.Count; i++) - EmitIabc(EcsOpcode.Move, _stagingBase + i, Slot(args[i]), 0); - uint target = isLocal ? unchecked((uint)localFid) - : 0x80000000u | unchecked((uint)_ctx.ImportId(sym.Name, sym.Parameters.Length)); - EmitExt(EcsOpcode.Call, _stagingBase, args.Count, - hasResult ? _receiveSlot : 255, target); - - if (hasResult) - EmitIabc(EcsOpcode.Move, Slot(v), _receiveSlot, 0); + : 0x80000000u | unchecked((uint)_ctx.ImportId(sym.OriginModule, sym.Name, sym.Parameters.Length)); + // 接收槽直取结果寄存器:有结果 → Slot(v);无结果 → RET(R0 固定落点) + EmitStagedCall(EcsOpcode.Call, unchecked((int)target), args, + hasResult ? Slot(v) : EcsRegisters.Ret); } else { @@ -314,28 +313,23 @@ void EmitCallInst(SsaValue v) ? unchecked((int)scTarget) : _ctx.NativeId(NativeName(sym)); EmitStagedCall(EcsOpcode.CallN, targetId, args, - hasResult ? Slot(v) : -1); + hasResult ? Slot(v) : EcsRegisters.Ret); } } void EmitStagedCall(EcsOpcode op, int targetId, IReadOnlyList args, int resultSlot) { + if (args.Count > _stagingCount) + throw Fail($"调用实参数 {args.Count} 超过 STAGING 容量 {_stagingCount}(内部错误)"); for (int i = 0; i < args.Count; i++) EmitIabc(EcsOpcode.Move, _stagingBase + i, Slot(args[i]), 0); - EmitExt(op, _stagingBase, args.Count, - resultSlot >= 0 ? resultSlot : 255, unchecked((uint)targetId)); - } - - void EmitHoleCall(SsaValue v, string native) - { - var args = CallArgs(v); - EmitStagedCall(EcsOpcode.CallN, _ctx.NativeId(native), args, Slot(v)); + EmitExt(op, args.Count == 0 ? EcsRegisters.Ret : _stagingBase, args.Count, + resultSlot, unchecked((uint)targetId)); } static IReadOnlyList CallArgs(SsaValue v) { - // 洞调用(Capture/Ocr/Roi/OcrInit)的 SSA 形态是 Arg0+Arg1+ExtraArgs, - // 普通调用是 Arg0+ExtraArgs(Arg1 恒空)——两者都必须完整打包 + // Arg0+Arg1+ExtraArgs 全量打包(StaticCall 多参形态 Arg1 可非空) var args = new List(); if (v.Arg0 != null) args.Add(v.Arg0); if (v.Arg1 != null) args.Add(v.Arg1); diff --git a/src/EasyCon.Script/Bytecode/BytecodeEncoder.cs b/src/EasyCon.Script/Bytecode/BytecodeEncoder.cs index 9ddb174f..28fdcd93 100644 --- a/src/EasyCon.Script/Bytecode/BytecodeEncoder.cs +++ b/src/EasyCon.Script/Bytecode/BytecodeEncoder.cs @@ -29,7 +29,7 @@ public int Add(EcsConst c) /// /// 模块编码上下文:函数 ID、全局槽、原生表、导入表均为**模块局部** -/// (docs/EcmEcxFormat.md §4.1——链接期统一重写为镜像全局索引)。 +/// (链接期统一重写为镜像全局索引)。 /// public sealed class ModuleEncodeContext { @@ -38,7 +38,7 @@ public sealed class ModuleEncodeContext /// 本模块函数 → 模块局部 fid(值相等键,A-07)。 public required Dictionary LocalFuncIds; public required List Imports; - public required Dictionary<(string Name, int NParams), int> ImportIds; + public required Dictionary<(string? OriginModule, string Name, int NParams), int> ImportIds; /// 模块私有全局(首现序 = 模块局部槽)。 public required List Globals; public required Dictionary GlobalSlots; @@ -108,14 +108,18 @@ static ulong Fnv1a64(string s) return hash; } - public int ImportId(string name, int nparams) + public int ImportId(string name, int nparams) => ImportId(null, name, nparams); + + public int ImportId(string? originModule, string name, int nparams) { - var key = (name, nparams); + var key = (originModule, name, nparams); if (!ImportIds.TryGetValue(key, out var id)) { id = Imports.Count; ImportIds[key] = id; - Imports.Add(new EcsImport { Name = name, NParams = nparams }); + // 导入名模块限定("模块!函数"):层级命名下跨模块同名导入在导入表中可区分; + // 链接器按限定名解析到目标模块导出。无来源(历史路径)保持裸名。 + Imports.Add(new EcsImport { Name = originModule != null ? $"{originModule}!{name}" : name, NParams = nparams }); } return id; } @@ -162,7 +166,8 @@ sealed partial class Encoder readonly FunctionSymbol _symbol; readonly int _fid; readonly ModuleEncodeContext _ctx; - readonly List _code = new(); + /// 唯一指令流(解码形态;线格式只是序列化投影——SingleStreamFormat §2)。 + readonly List _code = new(); readonly Dictionary _slots = new(); /// 存活局部槽位重编号(旧帧槽 → 压缩槽;AssignSlots 步骤 0 构建,SymSlot 消费)。 readonly Dictionary _localSlotRemap = new(); @@ -173,7 +178,11 @@ sealed partial class Encoder readonly Dictionary _instReleases = new(); /// 结算计划:块终结符 → 待结算值(返回值/分支条件/出边 φ 臂,含死 φ 臂)。 readonly Dictionary _terminatorReleases = new(); - /// 行号表构建缓冲(稀疏:仅行变化处记录,发射点由 EmitInst 驱动)。 + /// 循环不变量常量外提:preheader 块 → 外提常量(HoistLoopInvariantConsts 构建)。 + readonly Dictionary> _hoistedConsts = new(); + /// 已外提常量全集(专属槽,不池化、不逐块重物化)。 + readonly HashSet _hoisted = new(); + /// 行号表构建缓冲(稀疏:仅行变化处记录,发射点由 EmitInst 驱动;单位 = 指令下标)。 readonly List _linePcs = new(); readonly List _lineLines = new(); int _currentEmitLine; @@ -181,12 +190,28 @@ sealed partial class Encoder readonly List _fixups = new(); readonly List _labels = new(); readonly List _poolFree = new(); + /// 空闲池槽的归还块(非常量池化值):归还块支配取用块才可复用—— + /// 归还块执行 ⟹ 其末次读取先于取用块的写入。发射序 ≠ CFG 序时(循环体内 + /// 块重排/回边),发射序上更晚的取用者可能先于末次读取执行,无守卫即污染 + /// (LoopCarriedLocalRegressionTests:$c 的槽被常量 10 复用,$prev := $c + /// 边副本读到 $val*10)。 + readonly Dictionary _freeSlotRelease = new(); + /// 取用守卫用的前向支配树(EmitBlocks 期惰性计算一次)。 + Dominators? _dom; + /// 当前发射块(EmitBlocks 循环置位;PoolAlloc 归还守卫按其判定)。 + SsaBlock _emittingBlock = null!; + /// 非常量池化值的剩余读取计数(= 全函数静态读取数;末次读取结算归零归还槽位, + /// 参考线性扫描寄存器分配的活动区间末端回收;归还守卫见 _freeSlotRelease)。 + readonly Dictionary _releaseLeft = new(); + /// 独占槽位全集(φ 结果/ForStep 目标/循环携带值/编码器保留临时): + /// 永不入池,池化归还侧 DEBUG 断言不与之相交。 + readonly HashSet _exclusiveSlots = new(); int _next; int _poolNext; int _scratch = -1; int _stagingBase; - int _maxArity; - int _receiveSlot; + int _stagingCount; + int _allocBase; int _neqTemp = -1; // Neq = Eq+Not 的共享中间槽(发射原子,不嵌套) int _tplGuard = -1; // 常量模板构建暂存槽(守卫标志/模板数组/当前分块;发射原子,不嵌套) int _tplArr = -1; @@ -207,7 +232,22 @@ public Encoder(SsaFunction fn, FunctionSymbol symbol, int localFid, ModuleEncode // ---- 槽位与发射工具 ---- - int Slot(SsaValue v) => _slots[v]; + /// 取值槽位。池化非常量值按需分配:定义发射(EmitBlocks)或首次读取触碰 + /// (先到先得)——发射序 ≠ 支配序时(CSE 改写 phi 臂/循环结构),读取点可能先于 + /// 定义点出现,按需分配保证任何触碰点都有槽;归还仍只发生在末次静态读取结算, + /// 分配时机不影响活动区间回收收益。 + int Slot(SsaValue v) + { + if (_slots.TryGetValue(v, out var s)) + return s; + if (_pooled.Contains(v) && !v.IsConstant) + { + s = PoolAlloc(); + _slots[v] = s; + return s; + } + throw Fail($"槽位未分配(非池化值定义发射前被读取): v{v.Id} {v.Op} def=块{v.Block?.Id}"); + } int SymSlot(LocalVariableSymbol s) { @@ -218,16 +258,16 @@ int SymSlot(LocalVariableSymbol s) return mapped; } - void Emit(uint word) => _code.Add(word); - - static uint Word(EcsOpcode op, int a, int b, int c) - => (uint)op | (uint)(a & 0xFF) << 8 | (uint)(b & 0xFF) << 16 | (uint)(c & 0xFF) << 24; + void Emit(in EcsInstruction ins) => _code.Add(ins); + // 格式感知发射:仍以 EcsFormat 表自检「操作码与发射形态匹配」(漏登 fail-fast 原样保留), + // 只是写出的目标从打包字变成解码形态结构体(SingleStreamFormat §2)。 void EmitIabc(EcsOpcode op, int a, int b, int c) { if (EcsFormat.Get(op) != EcsInsFormat.Iabc) throw Fail($"{op} 登记格式为 {EcsFormat.Get(op)},不能按 Iabc 发射(与 EcsFormat 表不一致)"); - Emit(Word(op, a, b, c)); + CheckIabcOperands(op, a, b, c); + Emit(new EcsInstruction(op, a, b, c)); } void EmitAbx(EcsOpcode op, int a, int bx) @@ -235,8 +275,8 @@ void EmitAbx(EcsOpcode op, int a, int bx) if (EcsFormat.Get(op) != EcsInsFormat.ABx) throw Fail($"{op} 登记格式为 {EcsFormat.Get(op)},不能按 ABx 发射(与 EcsFormat 表不一致)"); if (bx < 0 || bx > 0xFFFF) - throw Fail($"{op} 的 Bx 越界: {bx}"); - Emit(Word(op, a, bx & 0xFF, (bx >> 8) & 0xFF)); + throw Fail($"{op} 的 Bx 越界: {bx}"); // 常量池/类型表 16 位上限是语义约束(pinned 句柄编码前提) + Emit(new EcsInstruction(op, a, bx)); } void EmitAsBx(EcsOpcode op, int a, int sbx) @@ -244,30 +284,46 @@ void EmitAsBx(EcsOpcode op, int a, int sbx) if (EcsFormat.Get(op) != EcsInsFormat.AsBx) throw Fail($"{op} 登记格式为 {EcsFormat.Get(op)},不能按 AsBx 发射(与 EcsFormat 表不一致)"); if (sbx < short.MinValue || sbx > short.MaxValue) - throw Fail($"{op} 的 sBx 越界: {sbx}"); - Emit(Word(op, a, sbx & 0xFF, (sbx >> 8) & 0xFF)); + throw Fail($"{op} 的 sBx 越界: {sbx}(v3 定长 AsBx=s16)"); + Emit(new EcsInstruction(op, a, sbx)); } - // EXT 后随字是数据(唯一权威集合见 EcsFormat 表):发射即登记,扫描侧经 ExtWords 步进。 + // EXT 数据字是指令的一部分(解码形态字段),不再是独立后随字;发射即登记(HasExt 由 EcsFormat 表驱动)。 void EmitExt(EcsOpcode op, int a, int b, int c, uint ext) { if (EcsFormat.ExtWords(op) != 1) - throw Fail($"{op} 非 EXT 指令,不能携带后随数据字(与 EcsFormat 表不一致)"); - Emit(Word(op, a, b, c)); - Emit(ext); + throw Fail($"{op} 非数据字指令,不能携带数据字(与 EcsFormat 表不一致)"); + CheckIabcOperands(op, a, b, c); + Emit(new EcsInstruction(op, a, b, c, ext)); } + /// Iabc 头部操作数字节域守卫(v3 定长):槽位 u8 ≤254(255 = 无接收哨兵); + /// StickSet/StickP 的 B/C 是摇杆坐标(满 u8 0..255),不按槽位收紧。 + static void CheckIabcOperands(EcsOpcode op, int a, int b, int c) + { + if (a is < 0 or > 254) ThrowOp(op, $"A 槽位越界: {a}"); + bool coord = op is EcsOpcode.StickSet or EcsOpcode.StickP; + if (b < 0 || b > (coord ? 255 : 255)) ThrowOp(op, $"B 越界: {b}"); + if (c < -1 || c > (coord ? 255 : 254)) ThrowOp(op, $"C 越界: {c}"); + } + + static void ThrowOp(EcsOpcode op, string what) + => throw new BytecodeException(new[] { new BytecodeDiagnostic($"{op} {what}(v3 定长编码)", null, 0) }); + BytecodeException Fail(string message) => new(new[] { new BytecodeDiagnostic(message, _symbol.Name, _code.Count) }); // ---- 标签与跳转修复 ---- - const int FixJmpBlock = 0; // IsJ s24 → 目标块 - const int FixJptBlock = 1; // AsBx s16 → 目标块 + const int FixJmpBlock = 0; // → 目标块 + const int FixJptBlock = 1; const int FixJpfBlock = 2; - const int FixJmpLabel = 3; // IsJ s24 → 标签 - const int FixJptLabel = 4; // AsBx s16 → 标签 + const int FixJmpLabel = 3; // → 标签 + const int FixJptLabel = 4; const int FixJpfLabel = 5; + const int FixForStepBlock = 6; // ForStep:C = 回边目标(TrueSuccessor)偏移 + const int FixCmpJBlock = 7; // CmpJ:跳转目标块 + const int FixCmpJLabel = 8; // CmpJ:跳转目标标签(skip 形态) int NewLabel() { @@ -277,10 +333,39 @@ int NewLabel() void MarkLabel(int label) => _labels[label] = _code.Count; - void EmitJmpToBlock(SsaBlock target) { _fixups.Add(new Fixup(_code.Count, FixJmpBlock, target)); Emit(Word(EcsOpcode.Jmp, 0, 0, 0)); } - void EmitJptToBlock(int cond, SsaBlock target) { _fixups.Add(new Fixup(_code.Count, FixJptBlock, target)); Emit(Word(EcsOpcode.Jpt, cond, 0, 0)); } - void EmitJpfToBlock(int cond, SsaBlock target) { _fixups.Add(new Fixup(_code.Count, FixJpfBlock, target)); Emit(Word(EcsOpcode.Jpf, cond, 0, 0)); } - void EmitJmpToLabel(int label) { _fixups.Add(new Fixup(_code.Count, FixJmpLabel, label)); Emit(Word(EcsOpcode.Jmp, 0, 0, 0)); } + void EmitJmpToBlock(SsaBlock target) => EmitJump(EcsOpcode.Jmp, 0, FixJmpBlock, target); + + /// ForStep:A=当前值槽、B=上限槽、C=目标槽(写入 tmp=i+1)、Jump=出口(end)偏移; + /// tmp > limit → 跳出口($i 保持 ==upper);否则落入后继(arm 副本 + 回边)。 + void EmitFixForStep(int srcSlot, int limitSlot, int dstSlot, SsaBlock exitBlock) + { + _fixups.Add(new Fixup(_code.Count, FixForStepBlock, exitBlock)); + Emit(new EcsInstruction(EcsOpcode.ForStep, srcSlot, limitSlot, dstSlot)); + } + + /// CmpJ(P2′):A/B = 比较槽,C = kind,Jump = 成立时偏移(Patch 回填)。 + void EmitCmpJToBlock(SsaValue cmp, int kind, SsaBlock target) + { + _fixups.Add(new Fixup(_code.Count, FixCmpJBlock, target)); + Emit(new EcsInstruction(EcsOpcode.CmpJ, Slot(cmp.Arg0!), Slot(cmp.Arg1!), kind)); + } + + void EmitCmpJToLabel(SsaValue cmp, int kind, int label) + { + _fixups.Add(new Fixup(_code.Count, FixCmpJLabel, label)); + Emit(new EcsInstruction(EcsOpcode.CmpJ, Slot(cmp.Arg0!), Slot(cmp.Arg1!), kind)); + } + + void EmitJptToBlock(int cond, SsaBlock target) => EmitJump(EcsOpcode.Jpt, cond, FixJptBlock, target); + void EmitJpfToBlock(int cond, SsaBlock target) => EmitJump(EcsOpcode.Jpf, cond, FixJpfBlock, target); + void EmitJmpToLabel(int label) => EmitJump(EcsOpcode.Jmp, 0, FixJmpLabel, label); + + void EmitJump(EcsOpcode op, int cond, int kind, object target) + { + _fixups.Add(new Fixup(_code.Count, kind, target)); + // 偏移占位 0,Patch 统一回填(内存单位 = 相对指令下标;字节换算在 InstructionCodec.Project) + Emit(new EcsInstruction(op, cond, 0, 0, 0, 0)); + } // ---- 主流程 ---- @@ -290,16 +375,19 @@ public void Run() EmitBlocks(); Patch(); - if (_poolNext > 255) - throw Fail($"帧槽位超出 255 上限: {_poolNext}"); + if (_poolNext > EcsRegisters.MaxRegisters) + throw Fail($"寄存器溢出:函数 '{_symbol.Name}' 的寄存器需求 {_poolNext} 超过帧上限 {EcsRegisters.MaxRegisters}(拆分函数或简化深表达式)"); + Result = new EcsFunction { Name = _symbol.Name, Module = _module, NParams = _symbol.Parameters.Length, + // 帧长 = 寄存器高水位(每函数栈窗口):调用时按此开窗, + // 内联器 fresh 块基址与资源档案同源 NSlots = _poolNext, HasReturn = !_symbol.ReturnType.Equals(ScriptType.Void), - Code = _code, + Instructions = _code, }; var lineTable = new List(_linePcs.Count * 2); for (int i = 0; i < _linePcs.Count; i++) @@ -310,7 +398,7 @@ public void Run() Result.LineTable = lineTable; } - /// 记录发射行号(稀疏表:仅行变化时追加 (pc, line);0 行未知则继承上一登记)。 + /// 记录发射行号(稀疏表:仅行变化时追加 (指令下标, line);0 行未知则继承上一登记)。 void SetEmitLine(SsaValue? v) { var line = v?.Line ?? 0; @@ -323,13 +411,29 @@ void SetEmitLine(SsaValue? v) // ---- 块发射 ---- + /// 块发射序(= _fn.Blocks 表序):尾跳转消除的「下一块」判定依据。 + readonly Dictionary _blockOrder = new(); + void EmitBlocks() { + for (int i = 0; i < _fn.Blocks.Count; i++) + _blockOrder[_fn.Blocks[i]] = i; foreach (var block in _fn.Blocks) { + _emittingBlock = block; _blockStart[block] = _code.Count; var info = Info(block); + // 循环不变量外提常量:preheader 块首物化一次(专属槽已在 AssignSlots 定槽; + // 支配性保证运行期先于循环内任何读取) + if (_hoistedConsts.TryGetValue(block, out var hoisted)) + { + if (Environment.GetEnvironmentVariable("ECX_HOIST_TRACE") == "1") + Console.Error.WriteLine($"[hoist-emit-run] b{block.Id} 发射 {hoisted.Count} 条 (fn={_fn.Symbol.Name})"); + foreach (var c in hoisted) + EmitLoadConst(c); + } + // 块首惰性物化本块引用的常量(含 phi 臂读取)——每块独立池槽 foreach (var c in info.Consts) { @@ -341,11 +445,14 @@ void EmitBlocks() { if (!IsDirectlyEmitted(inst.Op)) continue; // 终结符/标记由 EmitTerminator 处理;常量占位由块首按需物化 + if (_fusedCmp.Contains(inst)) + continue; // P2′:比较已融合进终结符 CmpJ,不落槽发射 // 回放登记期推导的结算计划:先结算操作数末次读取(槽位可被结果复用),再为块内结果取槽 if (_instReleases.TryGetValue(inst, out var releases)) foreach (var v in releases) ReleaseDying(block, v); - if (_pooled.Contains(inst)) + // 池化结果取槽;读取点先行按需分配过的(发射序 ≠ 支配序)沿用已有槽 + if (_pooled.Contains(inst) && !_slots.ContainsKey(inst)) _slots[inst] = PoolAlloc(); EmitInst(inst); } @@ -354,8 +461,17 @@ void EmitBlocks() #if DEBUG foreach (var kv in _useInfo) foreach (var uc in kv.Value.UseCount) - if (uc.Value != 0 && _pooled.Contains(uc.Key)) - Console.Error.WriteLine($"[slots] 未结算: 块{kv.Key.Id} v{uc.Key.Id} {uc.Key.Op} remain={uc.Value} pooled={_pooled.Contains(uc.Key)}"); + { + if (uc.Key.IsConstant) + { + // 常量:块内计数归零结算 + if (uc.Value != 0 && _pooled.Contains(uc.Key)) + Console.Error.WriteLine($"[slots] 未结算: 块{kv.Key.Id} v{uc.Key.Id} {uc.Key.Op} remain={uc.Value} pooled={_pooled.Contains(uc.Key)}"); + } + // 非常量池化值:全函数剩余读取计数归零结算 + else if (_pooled.Contains(uc.Key) && _releaseLeft.TryGetValue(uc.Key, out var left) && left != 0) + Console.Error.WriteLine($"[slots] 未结算: v{uc.Key.Id} {uc.Key.Op} remain={left}"); + } #endif } @@ -368,8 +484,31 @@ void EmitTerminator(SsaBlock block) ReleaseDying(block, v); } + /// 目标块是否为当前块的下一发射块(P1a 尾跳转消除依据): + /// 直落进下一块的发射输出 = 逐条执行它的块首副本/指令,语义即跳转本身。 + bool NextIsBlock(SsaBlock block, SsaBlock target) + => _blockOrder.TryGetValue(target, out var o) && _blockOrder[block] + 1 == o; + void EmitTerminatorShape(SsaBlock block) { + // ForStep(FOR 快速路径尾块):终结符即指令本身——A/B 取条件操作数槽,C 由 fixup 回填回边偏移; + // 不等分支:i==upper 落入下一布局块(FalseSuccessor=end,创建序相邻) + if (block.BranchCondition is { Op: SsaOp.ForStep } forStep) + { + // ForStep src, limit, dst, exit:tmp=src+1 写入 dst;tmp>limit → 跳 end($i 保持 ==upper); + // 否则落入 body 的 arm 副本(携带新值)+ 回边 Jmp。 + // end 的 arm 副本(旧值 == upper)须在 ForStep 前发射:出口路径的最后一次拷贝即正确值 + var t = block.TrueSuccessor!; + var f = block.FalseSuccessor!; + if (f.Phis.Count > 0 && f.Predecessors.Contains(block)) + EmitEdgeCopies(f, block); + EmitFixForStep(Slot(forStep.Arg0!), Slot(forStep.Arg1!), Slot(forStep), f); + if (t.Phis.Count > 0 && t.Predecessors.Contains(block)) + EmitEdgeCopies(t, block); + EmitJmpToBlock(t); + return; + } + if (block.IsReturn) { SsaValue? retVal = null; @@ -384,7 +523,10 @@ void EmitTerminatorShape(SsaBlock block) if (block.BranchCondition != null) { - var cond = Slot(block.BranchCondition); + var condVal = block.BranchCondition; + bool fused = _fusedCmp.Contains(condVal); + int kind = fused ? CmpJKind(condVal.Op)!.Value : -1; + int cond = fused ? -1 : Slot(condVal); // 融合比较不落槽,Slot 查表必炸 var t = block.TrueSuccessor!; var f = block.FalseSuccessor!; bool tPhi = t.Phis.Count > 0 && t.Predecessors.Contains(block); @@ -393,40 +535,64 @@ void EmitTerminatorShape(SsaBlock block) if (t == f) { if (tPhi) EmitEdgeCopies(t, block); // 防御:只发一份(A-03) - EmitJmpToBlock(t); + if (!NextIsBlock(block, t)) // 尾跳转消除:目标即下一发射块 → 直落 + EmitJmpToBlock(t); } else if (!tPhi && !fPhi) { - EmitJptToBlock(cond, t); - EmitJpfToBlock(cond, f); + // 单跳转形态(P1a):某后继为下一发射块时,直落承担该臂,只发另一臂的条件跳转 + if (NextIsBlock(block, t) && !NextIsBlock(block, f)) + { + if (fused) EmitCmpJToBlock(condVal, InvertCmpJKind(kind), f); // 不成立才跳 f + else EmitJpfToBlock(cond, f); + } + else if (NextIsBlock(block, f) && !NextIsBlock(block, t)) + { + if (fused) EmitCmpJToBlock(condVal, kind, t); + else EmitJptToBlock(cond, t); + } + else + { + if (fused) EmitCmpJToBlock(condVal, kind, t); + else EmitJptToBlock(cond, t); + if (fused) EmitCmpJToBlock(condVal, InvertCmpJKind(kind), f); + else EmitJpfToBlock(cond, f); + } } else if (tPhi && !fPhi) { int skip = NewLabel(); - EmitFixJpf(cond, skip); + if (fused) EmitCmpJToLabel(condVal, InvertCmpJKind(kind), skip); + else EmitFixJpf(cond, skip); EmitEdgeCopies(t, block); - EmitJmpToBlock(t); + EmitJmpToBlock(t); // mid 跳转不可消除:直落会掉进 skip 后的 f 臂 MarkLabel(skip); - EmitJmpToBlock(f); + if (!NextIsBlock(block, f)) + EmitJmpToBlock(f); } else if (!tPhi && fPhi) { int skip = NewLabel(); - EmitFixJpt(cond, skip); + if (fused) EmitCmpJToLabel(condVal, kind, skip); + else EmitFixJpt(cond, skip); EmitEdgeCopies(f, block); - EmitJmpToBlock(f); + EmitJmpToBlock(f); // mid 跳转不可消除:同上 MarkLabel(skip); - EmitJmpToBlock(t); + if (!NextIsBlock(block, t)) + EmitJmpToBlock(t); } else { int skip = NewLabel(); - EmitFixJpf(cond, skip); + if (fused) EmitCmpJToLabel(condVal, InvertCmpJKind(kind), skip); + else EmitFixJpf(cond, skip); EmitEdgeCopies(t, block); - EmitJmpToBlock(t); + if (!NextIsBlock(block, t)) + EmitJmpToBlock(t); MarkLabel(skip); EmitEdgeCopies(f, block); - EmitJmpToBlock(f); + if (!NextIsBlock(block, f)) + EmitJmpToBlock(f); } return; } @@ -436,7 +602,8 @@ void EmitTerminatorShape(SsaBlock block) var j = block.JumpTarget; if (j.Phis.Count > 0 && j.Predecessors.Contains(block)) EmitEdgeCopies(j, block); - EmitJmpToBlock(j); + if (!NextIsBlock(block, j)) // 尾跳转消除 + EmitJmpToBlock(j); return; } @@ -444,8 +611,8 @@ void EmitTerminatorShape(SsaBlock block) } // 条件跳转到标签:Kind 必须用 Label 变体(Patch 按 Kind 区分块/标签转型) - void EmitFixJpf(int cond, int label) { _fixups.Add(new Fixup(_code.Count, FixJpfLabel, label)); Emit(Word(EcsOpcode.Jpf, cond, 0, 0)); } - void EmitFixJpt(int cond, int label) { _fixups.Add(new Fixup(_code.Count, FixJptLabel, label)); Emit(Word(EcsOpcode.Jpt, cond, 0, 0)); } + void EmitFixJpf(int cond, int label) => EmitJump(EcsOpcode.Jpf, cond, FixJpfLabel, label); + void EmitFixJpt(int cond, int label) => EmitJump(EcsOpcode.Jpt, cond, FixJptLabel, label); /// 出边 φ 副本发射(Sessa 并行拷贝见 EmitParallelCopy)。 /// 臂读取的结算不在本方法——统一由终结符结算计划回放(含死 φ 臂), @@ -510,41 +677,27 @@ void Patch() { int target = fixup.Kind switch { - FixJmpBlock or FixJptBlock or FixJpfBlock => _blockStart[(SsaBlock)fixup.Target], + FixJmpBlock or FixJptBlock or FixJpfBlock or FixForStepBlock or FixCmpJBlock => _blockStart[(SsaBlock)fixup.Target], _ => _labels[(int)fixup.Target], }; - int offset = target - (fixup.WordIdx + 1); - uint word = _code[fixup.WordIdx]; + int offset = target - (fixup.InsIdx + 1); + var word = _code[fixup.InsIdx]; - switch (fixup.Kind) - { - case FixJmpBlock or FixJmpLabel: - { - if (offset < -8388608 || offset > 8388607) - throw Fail("Jmp 偏移超出 s24(函数过大)"); - _code[fixup.WordIdx] = (word & 0xFF) | (uint)(offset & 0xFFFFFF) << 8; - break; - } - case FixJptBlock or FixJpfBlock or FixJptLabel or FixJpfLabel: - { - if (offset < short.MinValue || offset > short.MaxValue) - throw Fail("条件跳转偏移超出 s16(函数过大,A-04)"); - _code[fixup.WordIdx] = (word & 0xFFFF) | (uint)(offset & 0xFFFF) << 16; - break; - } - } + _code[fixup.InsIdx] = fixup.Kind == FixForStepBlock + ? word with { Jump = offset } + : word with { Jump = offset }; } } sealed class Fixup { - public readonly int WordIdx; + public readonly int InsIdx; public readonly int Kind; public readonly object Target; - public Fixup(int wordIdx, int kind, object target) + public Fixup(int insIdx, int kind, object target) { - WordIdx = wordIdx; Kind = kind; Target = target; + InsIdx = insIdx; Kind = kind; Target = target; } } diff --git a/src/EasyCon.Script/Bytecode/BytecodeModule.cs b/src/EasyCon.Script/Bytecode/BytecodeModule.cs index 48706f3c..b903f371 100644 --- a/src/EasyCon.Script/Bytecode/BytecodeModule.cs +++ b/src/EasyCon.Script/Bytecode/BytecodeModule.cs @@ -60,34 +60,52 @@ public sealed class EcsGlobal public int ImageSlot; } +/// +/// 唯一指令表示(解码形态,docs/SingleStreamFormat.md §2):完整宽度操作数,形态与线格式无关。 +/// 线格式的 iABC/ABx/AsBx/IsJ 打包与 EXT 数据字只是 v3 定长序列化投影(), +/// 内存中不存在第二套字节码。 +/// Jump 仅对 Jmp/Jpt/Jpf 有效,语义 = 相对**指令下标**偏移(前插平移不变; +/// 线格式投影时由 InstructionCodec 换算为相对字节偏移——内存解释器按下标取指,字节偏移无处落点)。 +/// Call/CallN 的接收槽 C 恒为寄存器号(0 = RET 固定落点)。 +/// +public readonly record struct EcsInstruction(EcsOpcode Op, int A, int B, int C = 0, uint Ext = 0, int Jump = 0) +{ + public bool IsJump => Op is EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf; + public bool HasExt => EcsFormat.ExtWords(Op) > 0; +} + /// 链接后的函数。 public sealed class EcsFunction { public required string Name; public required string Module; public int NParams; + /// 不再受 8 位限制;线格式字段为 u16。 public int NSlots; public bool HasReturn; - public List Code = new(); + /// 唯一真值:解码形态指令流(链接各 pass 与解释器的唯一消费形态)。 + public List Instructions = new(); /// 链接后在本镜像函数表中的下标。 public int ImageIndex; /// - /// 行号表(交错 [pc, line, ...],pc 严格递增;JVM LineNumberTable 同型稀疏表)。 - /// 运行错误经 LineAt(pc) 映射回源码行;仅进 ECM 缓存与桌面诊断,不写入 MCU .ecx。 + /// 行号表(交错 [insIndex, line, ...],insIndex 严格递增;单位 = 指令下标;JVM + /// LineNumberTable 同型稀疏表)。运行错误经 LineAt 映射回源码行; + /// 容器投影为行号块(字节偏移单位),仅诊断用,MCU 发布镜像省略。 /// public List LineTable = new(); - /// pc → 源码行(1 基;空表或 pc 早于首登记返回 0)。二分取 ≤pc 的最近登记。 - public int LineAt(int pc) + /// 指令下标 → 源码行(1 基;空表或早于首登记返回 0)。二分取 ≤idx 的最近登记。 + public int LineAt(int insIndex) { - int lo = 0, hi = LineTable.Count / 2 - 1, result = 0; + List table = LineTable; + int lo = 0, hi = table.Count / 2 - 1, result = 0; while (lo <= hi) { int mid = (lo + hi) / 2; - if (LineTable[mid * 2] <= pc) + if (table[mid * 2] <= insIndex) { - result = LineTable[mid * 2 + 1]; + result = table[mid * 2 + 1]; lo = mid + 1; } else @@ -103,7 +121,7 @@ public sealed class EcsNative public required string Name; } -/// 模块导入:需要的外部函数(按 名+参数个数 解析,docs/EcmEcxFormat.md §4.2)。 +/// 模块导入:需要的外部函数(按 名+参数个数 解析)。 public sealed class EcsImport { public required string Name; @@ -121,7 +139,7 @@ public sealed class EcsExport } /// -/// 模块产物(.ecm 的内存表示,docs/EcmEcxFormat.md §4)。 +/// 模块产物(.ecm 的内存表示)。 /// 函数 Call 目标:本模块 = 局部 fid;外部 = 0x80000000 | 导入表索引。 /// LoadG/StoreG Bx = 模块局部全局槽;CallN ext = 模块局部原生 idx;LoadK Bx = 模块常量池 idx。 /// @@ -180,7 +198,7 @@ public sealed class EcxImage /// u16 @0x0A)。加载规则:host 掩码缺位 → 拒跑(IL → ECS_ERR_IL,其余 → ECS_ERR_FEAT)。 /// public uint Features; - /// 全程序最大帧槽数(max(funcs.nslots),静态预分配帧区用,EcmEcxFormat §2.1)。 + /// 全程序最大帧槽数(max(funcs.nslots),静态预分配帧区用)。 public int MaxSlots; /// 静态调用图最长链(递归按 1 计;运行期上限另由 ECS_MAX_CALL_DEPTH 约束)。 public int MaxDepth; diff --git a/src/EasyCon.Script/Bytecode/DeadStoreSweep.cs b/src/EasyCon.Script/Bytecode/DeadStoreSweep.cs index 04a2a3da..a81cf474 100644 --- a/src/EasyCon.Script/Bytecode/DeadStoreSweep.cs +++ b/src/EasyCon.Script/Bytecode/DeadStoreSweep.cs @@ -7,7 +7,7 @@ namespace EasyCon.Script.Bytecode; /// 判定 = 反向活跃性数据流:可删指令的定义槽在后继所有路径上无读取(迭代至不动点, /// 删除可级联——死副本的源值随之死亡)。删除族严格限定无副作用的拷贝/物化六指令: /// Move / SetVar / LoadI / LoadBool / LoadK / LoadG。SetVar 的深拷贝对象在结果无人读取时 -/// 不可观察(副本随帧释放);LoadK 串常量经解释器驻留缓存,删除仅影响单次运行的分配时刻。 +/// 不可观察(副本随帧释放);LoadK 串常量为 pinned 静态值(S-20),删除不影响任何分配。 /// 域操作 / Call / CallN / StoreG / 控制流一律保留(副作用或控制依赖)。 /// /// use/def 表按 EcxInterpreter 逐 op 语义登记;未登记操作码 fail-closed(抛出), @@ -16,6 +16,9 @@ namespace EasyCon.Script.Bytecode; /// 非参数局部的帧槽无读取者;TRE 回边参数 store 保留——entry 循环头每轮重读参数槽—— /// 配合 AssignSlots 步骤 0 存活局部重编号,非参数局部不占帧槽); /// 死 φ 的槽由防线 1 在编码期免分配。 +/// +/// 单流化(docs/SingleStreamFormat.md §2):解码形态下不再有「槽位被截断的影子流」, +/// PcCode 早退随之移除——所有函数一视同仁(原 R6 风险由专项用例覆盖)。 /// public static class DeadStoreSweep { @@ -27,48 +30,25 @@ public static void Sweep(EcxImage image) static void SweepFunction(EcsFunction f) { - var code = f.Code; + var code = f.Instructions; if (code.Count == 0) return; - // ---- 1. 指令切分(EXT-aware 步进);跳转目标先记 pc,扫描完成后统一映射指令下标 ---- - int n = code.Count; - var ops = new List(); - var instrPc = new List(); - var extWords = new List(); - var targetPcs = new List(); // 跳转目标 pc;-1 = 非跳转 - int word = 0; - while (word < n) - { - uint ins = code[word]; - var op = (EcsOpcode)(ins & 0xFF); - ops.Add(op); - instrPc.Add(word); - extWords.Add(EcsFormat.ExtWords(op) > 0 ? code[word + 1] : 0); - targetPcs.Add(op switch - { - EcsOpcode.Jmp => word + 1 + Sign24(ins >> 8), - EcsOpcode.Jpt or EcsOpcode.Jpf => word + 1 + Sign16(ins >> 16), - _ => -1, - }); - word += EcsFormat.WordCount(op); - } - int count = ops.Count; - var pcToIdx = new Dictionary(count); - for (int i = 0; i < count; i++) - pcToIdx[instrPc[i]] = i; + int count = code.Count; + + // ---- 1. 跳转目标(解码形态:单位 = 相对指令下标)---- var targets = new int[count]; for (int i = 0; i < count; i++) { - int pc = targetPcs[i]; - if (pc < 0) + if (!code[i].IsJump && code[i].Op is not (EcsOpcode.ForStep or EcsOpcode.CmpJ)) { targets[i] = -1; continue; } - if (!pcToIdx.TryGetValue(pc, out var idx)) - throw new BytecodeException(new[] { new BytecodeDiagnostic($"死存储清扫:跳转目标 pc {pc} 不是指令边界", f.Name, pc) }); - targets[i] = idx; + int t = i + 1 + code[i].Jump; + if (t < 0 || t > count) + throw new BytecodeException(new[] { new BytecodeDiagnostic($"死存储清扫:跳转目标下标 {t} 越界", f.Name, i) }); + targets[i] = t; } // ---- 2. 基本块:leader = 指令 0 / 跳转目标 / 终结符后继 ---- @@ -77,8 +57,7 @@ static void SweepFunction(EcsFunction f) { if (targets[i] >= 0) leaders.Add(targets[i]); - bool terminator = ops[i] is EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf - or EcsOpcode.Ret or EcsOpcode.Ret0 or EcsOpcode.Halt; + bool terminator = ops(code[i].Op); if (terminator && i + 1 < count) leaders.Add(i + 1); } @@ -96,15 +75,22 @@ static void SweepFunction(EcsFunction f) succs[b] = new List(); for (int i = 0; i < count; i++) { - bool terminator = ops[i] is EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf - or EcsOpcode.Ret or EcsOpcode.Ret0 or EcsOpcode.Halt; - if (!terminator) + if (!ops(code[i].Op)) continue; int b = blockOf[i]; if (targets[i] >= 0) succs[b].Add(blockOf[targets[i]]); - if (ops[i] is EcsOpcode.Jpt or EcsOpcode.Jpf && i + 1 < count) - succs[b].Add(blockOf[i + 1]); // 条件分支 fall-through + if (code[i].Op is EcsOpcode.Jpt or EcsOpcode.Jpf or EcsOpcode.ForStep or EcsOpcode.CmpJ && i + 1 < count) + succs[b].Add(blockOf[i + 1]); // 条件分支 fall-through(ForStep:i==upper 出循环;CmpJ:比较不成立) + } + + // 无终结符的块(编码期尾跳转消除后的直落块,P1a):控制流入下一块 → 补 fall-through 边。 + // 漏边会让直落块的 liveOut 为空,块内定义被误判死指令(入口初始化被删,corpus arith 实证)。 + for (int b = 0; b < blockCount; b++) + { + int end = b + 1 < blockBegin.Count ? blockBegin[b + 1] : count; + if (end > blockBegin[b] && !ops(code[end - 1].Op) && b + 1 < blockCount) + succs[b].Add(b + 1); } // ---- 3. use/def 逐 op 登记(语义权威 = EcxInterpreter 的 ExecOther/主循环)---- @@ -112,54 +98,57 @@ static void SweepFunction(EcsFunction f) var defs = new int[count]; for (int i = 0; i < count; i++) { - uint ins = code[instrPc[i]]; - int a = (int)((ins >> 8) & 0xFF), b = (int)((ins >> 16) & 0xFF), c = (int)((ins >> 24) & 0xFF); + var ins = code[i]; var u = new List(); int d; - switch (ops[i]) + switch (ins.Op) { - case EcsOpcode.Move or EcsOpcode.SetVar: d = a; u.Add(b); break; - case EcsOpcode.LoadI or EcsOpcode.LoadBool or EcsOpcode.LoadK or EcsOpcode.LoadG: d = a; break; - case EcsOpcode.StoreG: u.Add(a); d = -1; break; - case EcsOpcode.Jpt or EcsOpcode.Jpf: u.Add(a); d = -1; break; - case EcsOpcode.Ret: u.Add(a); d = -1; break; + case EcsOpcode.Move or EcsOpcode.SetVar: d = ins.A; u.Add(ins.B); break; + case EcsOpcode.LoadI or EcsOpcode.LoadBool or EcsOpcode.LoadK or EcsOpcode.LoadG: d = ins.A; break; + case EcsOpcode.StoreG: u.Add(ins.A); d = -1; break; + case EcsOpcode.Jpt or EcsOpcode.Jpf: u.Add(ins.A); d = -1; break; + case EcsOpcode.Ret: u.Add(ins.A); d = -1; break; case EcsOpcode.Call or EcsOpcode.CallN: - for (int k = 0; k < b; k++) u.Add(a + k); // 实参窗 - d = c == 255 ? -1 : c; // C=255:无接收槽 + for (int k = 0; k < ins.B; k++) u.Add(ins.A + k); // 实参窗(STAGING) + d = ins.C; // 接收寄存器(0 = RET) break; case EcsOpcode.NewArrV: - d = a; - for (int k = 0; k < b; k++) u.Add(c + k); // 元素窗 + d = ins.A; + for (int k = 0; k < ins.B; k++) u.Add(ins.C + k); // 元素窗 break; - case EcsOpcode.SetI: u.AddRange(new[] { a, b, c }); d = -1; break; + case EcsOpcode.SetI: u.AddRange(new[] { ins.A, ins.B, ins.C }); d = -1; break; case EcsOpcode.Slice: - d = a; u.Add(b); u.Add(c); - if (extWords[i] != 0xFFFFFFFF) u.Add((int)extWords[i]); // 端点省略哨兵 + d = ins.A; u.Add(ins.B); u.Add(ins.C); + if (ins.Ext != 0xFFFFFFFF) u.Add((int)ins.Ext); // 端点省略哨兵 break; - case EcsOpcode.GetFI: d = a; u.Add(b); u.Add((int)extWords[i]); break; - case EcsOpcode.PutFI: u.AddRange(new[] { a, b, (int)extWords[i] }); d = -1; break; - case EcsOpcode.StickPv: u.Add(c); d = -1; break; - case EcsOpcode.KeyV: u.Add(b); d = -1; break; - case EcsOpcode.WaitV: u.Add(a); d = -1; break; - case EcsOpcode.Rand: d = a; u.Add(b); break; - case EcsOpcode.Img or EcsOpcode.NewArrE or EcsOpcode.NewSt: d = a; break; + case EcsOpcode.GetFI: d = ins.A; u.Add(ins.B); u.Add((int)ins.Ext); break; + case EcsOpcode.PutFI: u.AddRange(new[] { ins.A, ins.B, (int)ins.Ext }); d = -1; break; + case EcsOpcode.StickPv: u.Add(ins.C); d = -1; break; + case EcsOpcode.KeyV: u.Add(ins.B); d = -1; break; + case EcsOpcode.WaitV: u.Add(ins.A); d = -1; break; + case EcsOpcode.Rand: d = ins.A; u.Add(ins.B); break; + case EcsOpcode.Img or EcsOpcode.NewArrE or EcsOpcode.NewSt: d = ins.A; break; case EcsOpcode.Len or EcsOpcode.GetF or EcsOpcode.Not or EcsOpcode.NegI or EcsOpcode.NegD: - d = a; u.Add(b); break; - case EcsOpcode.PutF: u.AddRange(new[] { a, b }); d = -1; break; - case EcsOpcode.Conv: d = a; u.Add(b); break; // c = 转换类别立即数 + d = ins.A; u.Add(ins.B); break; + case EcsOpcode.PutF: u.AddRange(new[] { ins.A, ins.B }); d = -1; break; + case EcsOpcode.Conv: d = ins.A; u.Add(ins.B); break; // C = 转换类别立即数 case EcsOpcode.Nop or EcsOpcode.Jmp or EcsOpcode.Ret0 or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt or EcsOpcode.StickSet or EcsOpcode.StickP or EcsOpcode.Halt: d = -1; break; + case EcsOpcode.ForStep: + u.AddRange(new[] { ins.A, ins.B }); d = ins.C; break; // 写 dst(c),读 src/limit + case EcsOpcode.CmpJ: + u.AddRange(new[] { ins.A, ins.B }); d = -1; break; // 纯比较跳转:读 A/B,C=kind 码非槽位,无 def default: // 其余为 iABC 算术/比较族(AddI..GeL/EqS 等):def=a, use=b,c - if (EcsFormat.Get(ops[i]) == EcsInsFormat.Iabc) + if (EcsFormat.Get(ins.Op) == EcsInsFormat.Iabc) { - d = a; u.Add(b); u.Add(c); + d = ins.A; u.Add(ins.B); u.Add(ins.C); } else throw new BytecodeException(new[] { new BytecodeDiagnostic( - $"死存储清扫:未登记操作码 {ops[i]}(fail-closed,须显式登记 use/def)", f.Name, instrPc[i]) }); + $"死存储清扫:未登记操作码 {ins.Op}(fail-closed,须显式登记 use/def)", f.Name, i) }); break; } uses[i] = u; @@ -167,10 +156,6 @@ or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt } // ---- 4. 反向活跃性不动点 + 死指令标记 ---- - // 数据流方程(被标记指令按不存在参与): - // liveOut(B) = ∪ liveIn(s),liveIn(B) = gen(B) ∪ (liveOut(B) \ kill(B)) - // gen = 块内向上暴露使用;kill = 块内全部定义。删除可级联(死副本的源值随之死亡), - // 故外层迭代到无新标记为止。 var dead = new bool[count]; var liveIn = new HashSet[blockCount]; for (int b = 0; b < blockCount; b++) @@ -189,11 +174,11 @@ or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt changed = false; // gen/kill 随 dead 标记重算(块内正向:未定义先使用 ⇒ 向上暴露) - foreach (var b in Enumerable.Range(0, blockCount)) + for (int b = 0; b < blockCount; b++) { gen[b].Clear(); kill[b].Clear(); - int end = b + 1 < blockCount ? blockBegin[b + 1] : count; + int end = b + 1 < blockBegin.Count ? blockBegin[b + 1] : count; var defined = new HashSet(); for (int i = blockBegin[b]; i < end; i++) { @@ -217,8 +202,6 @@ or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt var liveOut = new HashSet(); foreach (var s in succs[b]) liveOut.UnionWith(liveIn[s]); - // liveIn = gen ∪ (liveOut \ kill):gen 是块内向上暴露的使用, - // 发生在本块任何定义之前,不受 kill 影响(kill 只作用于 liveOut) var next = new HashSet(liveOut); next.ExceptWith(kill[b]); next.UnionWith(gen[b]); @@ -233,7 +216,7 @@ or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt // 标记:块内反向扫描,live = liveOut(B) for (int b = 0; b < blockCount; b++) { - int end = b + 1 < blockCount ? blockBegin[b + 1] : count; + int end = b + 1 < blockBegin.Count ? blockBegin[b + 1] : count; var live = new HashSet(); foreach (var s in succs[b]) live.UnionWith(liveIn[s]); @@ -242,7 +225,7 @@ or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt if (dead[i]) continue; int d = defs[i]; - if (d >= 0 && Removable(ops[i]) && !live.Contains(d)) + if (d >= 0 && Removable(code[i].Op) && !live.Contains(d)) { dead[i] = true; changed = true; @@ -255,48 +238,52 @@ or EcsOpcode.WaitI or EcsOpcode.KeyI or EcsOpcode.KeySt } } - // ---- 5. 压缩重写:跳转偏移按 old→new pc 重映射(被删指令映射到其后继首个存活指令)---- - var newPcOf = new int[n]; - var newCode = new List(n); - int np = 0; + if (Environment.GetEnvironmentVariable("ECX_SWEEP_TRACE") == "2") + { + for (int i = 0; i < count; i++) + Console.Error.WriteLine($"[sweep2] f={f.Name} i={i} {code[i].Op} A={code[i].A} B={code[i].B} C={code[i].C} J={code[i].Jump} targets={targets[i]} block={blockOf[i]}"); + } + if (Environment.GetEnvironmentVariable("ECX_SWEEP_TRACE") == "1") + { + for (int b = 0; b < blockCount; b++) + { + int end = b + 1 < blockBegin.Count ? blockBegin[b + 1] : count; + Console.Error.WriteLine($"[sweep] B{b} [{blockBegin[b]}..{end - 1}] succs=[{string.Join(",", succs[b])}] liveIn=[{string.Join(",", liveIn[b].Order())}] dead=[{string.Join(",", Enumerable.Range(blockBegin[b], end - blockBegin[b]).Where(i => dead[i]))}]"); + } + } + + // ---- 5. 压缩重写:跳转偏移按下标重映射(被删指令映射到其后继首个存活指令)---- + var newIdxOf = new int[count]; + var newCode = new List(count); for (int i = 0; i < count; i++) { - newPcOf[instrPc[i]] = np; - if (dead[i]) - continue; - int wc = EcsFormat.WordCount(ops[i]); - for (int k = 0; k < wc; k++) - newCode.Add(code[instrPc[i] + k]); - np += wc; + newIdxOf[i] = newCode.Count; + if (!dead[i]) + newCode.Add(code[i]); } - int nextPc = np; + int nextIdx = newCode.Count; for (int i = count - 1; i >= 0; i--) { if (!dead[i]) - nextPc = newPcOf[instrPc[i]]; + nextIdx = newIdxOf[i]; else - newPcOf[instrPc[i]] = nextPc; + newIdxOf[i] = nextIdx; } for (int i = 0; i < count; i++) { if (dead[i] || targets[i] < 0) continue; - int oldW = instrPc[i]; - int newW = newPcOf[oldW]; - int newOffset = newPcOf[instrPc[targets[i]]] - (newW + 1); - uint w = newCode[newW]; - newCode[newW] = ops[i] == EcsOpcode.Jmp - ? (w & 0xFF) | (uint)(newOffset & 0xFFFFFF) << 8 - : (w & 0xFFFF) | (uint)(newOffset & 0xFFFF) << 16; + int newOffset = newIdxOf[targets[i]] - (newIdxOf[i] + 1); + newCode[newIdxOf[i]] = newCode[newIdxOf[i]] with { Jump = newOffset }; } - f.Code = newCode; + f.Instructions = newCode; } + static bool ops(EcsOpcode op) => op is EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf + or EcsOpcode.Ret or EcsOpcode.Ret0 or EcsOpcode.Halt or EcsOpcode.ForStep or EcsOpcode.CmpJ; + /// 可删族:纯拷贝/物化(定义槽死 ⇒ 指令死)。 static bool Removable(EcsOpcode op) => op is EcsOpcode.Move or EcsOpcode.SetVar or EcsOpcode.LoadI or EcsOpcode.LoadBool or EcsOpcode.LoadK or EcsOpcode.LoadG; - - static int Sign24(uint v) => (int)(v << 8) >> 8; - static int Sign16(uint v) => (int)(short)v; } \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/EcmFormat.cs b/src/EasyCon.Script/Bytecode/EcmFormat.cs deleted file mode 100644 index fd21923d..00000000 --- a/src/EasyCon.Script/Bytecode/EcmFormat.cs +++ /dev/null @@ -1,405 +0,0 @@ -using System.Collections.Immutable; -using System.Text; - -namespace EasyCon.Script.Bytecode; - -/// -/// .ecm 模块产物序列化(docs/EcmEcxFormat.md ECM 章节)。小端;元数据 UTF-8。 -/// -public static class EcmFormat -{ - public const uint Magic = 0x324D4345; // "ECM2" - public const ushort Version = 4; // v4:函数节增行号表(LineTable,pc→源码行,诊断/运行错误映射用) - - public static byte[] Write(ModuleArtifact module) - { - using var ms = new MemoryStream(); - using var w = new BinaryWriter(ms); - - w.Write(Magic); - w.Write(Version); - w.Write((ushort)(module.HasEval ? 1 : 0)); - // v3 链接标志:<init> 合成与宿主能力判定(KeyAction/NeedIL)必须跨缓存存活 - w.Write((byte)((module.HasEval ? 1 : 0) - | (module.HasInit ? 2 : 0) - | (module.KeyAction ? 4 : 0) - | (module.NeedIL ? 8 : 0))); - w.Write(module.InitFid); - WriteUtf8(w, module.Name); - - // ---- 接口区(长度前置,v2;无接口 = 0)---- - if (module.Interface is { } iface) - { - using var ibuf = new MemoryStream(); - using (var iw = new BinaryWriter(ibuf, Encoding.UTF8, leaveOpen: true)) - ModuleInterfaceFormat.Write(iw, iface); - w.Write((uint)ibuf.Length); - w.Write(ibuf.ToArray()); - } - else - { - w.Write(0u); - } - - // 类型表快照 - w.Write(module.Structs.Count); - foreach (var s in module.Structs) - { - WriteUtf8(w, s.Name); - w.Write((byte)s.Fields.Length); - foreach (var f in s.Fields) - { - WriteUtf8(w, f.Name); - w.Write((byte)f.Kind); - w.Write((byte)f.Type); - w.Write((byte)f.ElementType); - w.Write((ushort)(f.Kind == EcsFieldKind.NestedStruct ? f.NestedSid : f.Count)); // ext 槽:FixedArray=元素数;NestedStruct=嵌套 sid(内存表示存 NestedSid) - } - } - - // 模块私有全局 - w.Write(module.Globals.Count); - foreach (var g in module.Globals) - { - WriteUtf8(w, g.Name); - w.Write((byte)g.Type); - } - - // 原生名表 - w.Write(module.Natives.Count); - foreach (var n in module.Natives) - WriteUtf8(w, n.Name); - - // 函数表 + 代码区 - w.Write(module.Functions.Count); - uint codeOffset = 0; - foreach (var f in module.Functions) - { - WriteUtf8(w, f.Name); - w.Write((byte)f.NParams); - w.Write((byte)f.NSlots); - w.Write(f.HasReturn ? (byte)1 : (byte)0); - w.Write(codeOffset); - w.Write((uint)f.Code.Count); - codeOffset += (uint)f.Code.Count; - } - w.Write(codeOffset); - foreach (var f in module.Functions) - foreach (var word in f.Code) - w.Write(word); - - // 常量池 - w.Write(module.Pool.Consts.Count); - foreach (var c in module.Pool.Consts) - WriteConst(w, c); - - // 导入 / 导出 - w.Write(module.Imports.Count); - foreach (var i in module.Imports) - { - WriteUtf8(w, i.Name); - w.Write((byte)i.NParams); - w.Write(i.HasReturn ? (byte)1 : (byte)0); - } - w.Write(module.Exports.Count); - foreach (var e in module.Exports) - { - WriteUtf8(w, e.Name); - w.Write((uint)e.LocalFid); - w.Write((byte)e.NParams); - w.Write(e.HasReturn ? (byte)1 : (byte)0); - } - - // IL 名 - w.Write(module.ILNames.Count); - foreach (var il in module.ILNames) - WriteUtf8(w, il); - - // 行号表(v4;稀疏交错 [pc, line, ...],仅诊断用不进 MCU .ecx) - w.Write(module.Functions.Count); - foreach (var f in module.Functions) - { - w.Write(f.LineTable.Count); - foreach (var v in f.LineTable) - w.Write(v); - } - - w.Flush(); - return ms.ToArray(); - } - - public static ModuleArtifact Read(byte[] bytes) - { - using var r = new BinaryReader(new MemoryStream(bytes)); - if (r.ReadUInt32() != Magic) - throw new BytecodeException(new[] { new BytecodeDiagnostic("ECM 魔数不符", null, 0) }); - ushort fileVersion = r.ReadUInt16(); - if (fileVersion != Version) - throw new BytecodeException(new[] { new BytecodeDiagnostic($"ECM 版本不符 {fileVersion}(期望 {Version})", null, 0) }); - bool hasEval = r.ReadUInt16() != 0; - bool hasInit = false, keyAction = false, needIL = false; - int initFid = -1; - if (fileVersion >= 3) - { - var flags = r.ReadByte(); - hasInit = (flags & 2) != 0; - keyAction = (flags & 4) != 0; - needIL = (flags & 8) != 0; - initFid = r.ReadInt32(); - } - var name = ReadUtf8(r); - - // ---- 接口区(v2;v1 无此节)---- - ModuleInterface? iface = null; - if (fileVersion >= 2) - { - uint ifaceLen = r.ReadUInt32(); - if (ifaceLen > 0) - { - var ifaceBytes = r.ReadBytes((int)ifaceLen); - using var ir = new BinaryReader(new MemoryStream(ifaceBytes)); - iface = ModuleInterfaceFormat.Read(ir); - } - } - - // 类型表(布局偏移在读取完成后统一展开) - var structs = new List(); - int structCount = ReadCount(r); - for (int s = 0; s < structCount; s++) - { - var sname = ReadUtf8(r); - int fieldCount = r.ReadByte(); - var fields = new EcsFieldLayout[fieldCount]; - for (int fi = 0; fi < fieldCount; fi++) - { - var fname = ReadUtf8(r); - var kind = (EcsFieldKind)r.ReadByte(); - var type = (EcsTypeCode)r.ReadByte(); - var elem = (EcsTypeCode)r.ReadByte(); - var ext = r.ReadUInt16(); - fields[fi] = new EcsFieldLayout - { - Name = fname, - Kind = kind, - Type = type, - ElementType = elem, - Count = kind == EcsFieldKind.NestedStruct ? 0 : ext, - NestedSid = kind == EcsFieldKind.NestedStruct ? ext : 0, - }; - } - structs.Add(new EcsStructLayout { Name = sname, Fields = fields.ToImmutableArray() }); - } - var bySid = new Dictionary(); - for (int s = 0; s < structs.Count; s++) - bySid[s] = structs[s]; - // 布局展开须递归:sid 按声明名字母序分配,嵌套类型的 sid 可能大于父结构体, - // 单遍扫描会取到尚未展开的 SlotCount(SlotOffset/SlotCount 全盘错位) - var computing = new HashSet(); - int SlotCountOf(int sid) - { - var layout = structs[sid]; - if (layout.SlotCount > 0) - return layout.SlotCount; - if (!computing.Add(sid)) - throw new BytecodeException(new[] { new BytecodeDiagnostic($"结构体 {layout.Name} 存在自嵌套环(产物损坏)", name, 0) }); - int total = 0; - foreach (var f in layout.Fields) - { - f.SlotOffset = total; - total += f.Kind switch - { - EcsFieldKind.FixedArray => f.Count, - EcsFieldKind.NestedStruct => bySid.ContainsKey(f.NestedSid) ? SlotCountOf(f.NestedSid) : 1, - _ => 1, - }; - } - computing.Remove(sid); - layout.SlotCount = total; - return total; - } - for (int s = 0; s < structs.Count; s++) - SlotCountOf(s); - - // 模块私有全局 - var globals = new List(); - int globalCount = ReadCount(r); - for (int g = 0; g < globalCount; g++) - globals.Add(new EcsGlobal { Name = ReadUtf8(r), Module = name, Type = (EcsTypeCode)r.ReadByte() }); - - // 原生名表 - var natives = new List(); - int nativeCount = ReadCount(r); - for (int n = 0; n < nativeCount; n++) - natives.Add(new EcsNative { Name = ReadUtf8(r) }); - - // 函数表(记录 code_off/code_words)+ 代码区 - var funcMetas = new List<(string Name, byte NParams, byte NSlots, bool HasReturn, uint Off, uint Words)>(); - int funcCount = ReadCount(r); - for (int fi = 0; fi < funcCount; fi++) - { - var fname = ReadUtf8(r); - var nparams = r.ReadByte(); - var nslots = r.ReadByte(); - var hasret = r.ReadByte() != 0; - var off = r.ReadUInt32(); - var words = r.ReadUInt32(); - funcMetas.Add((fname, nparams, nslots, hasret, off, words)); - } - int codeWords = ReadCount(r); - var code = new List(codeWords); - for (int i = 0; i < codeWords; i++) - code.Add(r.ReadUInt32()); - - var functions = new List(); - foreach (var meta in funcMetas) - { - functions.Add(new EcsFunction - { - Name = meta.Name, - Module = name, - NParams = meta.NParams, - NSlots = meta.NSlots, - HasReturn = meta.HasReturn, - Code = code.Skip((int)meta.Off).Take((int)meta.Words).ToList(), - }); - } - - // 常量池 - var pool = new ModulePool(); - int constCount = ReadCount(r); - for (int c = 0; c < constCount; c++) - pool.Add(ReadConst(r)); - - // 导入 / 导出 - var imports = new List(); - int importCount = ReadCount(r); - for (int i = 0; i < importCount; i++) - imports.Add(new EcsImport { Name = ReadUtf8(r), NParams = r.ReadByte(), HasReturn = r.ReadByte() != 0 }); - - var exports = new List(); - int exportCount = ReadCount(r); - for (int e = 0; e < exportCount; e++) - { - var ename = ReadUtf8(r); - var localFid = (int)r.ReadUInt32(); - var nparams = r.ReadByte(); - var hasret = r.ReadByte() != 0; - exports.Add(new EcsExport { Name = ename, LocalFid = localFid, NParams = nparams, HasReturn = hasret }); - } - - // IL 名 - var ilNames = new List(); - int ilCount = ReadCount(r); - for (int i = 0; i < ilCount; i++) - ilNames.Add(ReadUtf8(r)); - - // 行号表(v4;与 Write 尾节同序) - if (fileVersion >= 4) - { - int lineFuncCount = ReadCount(r); - for (int fi = 0; fi < lineFuncCount && fi < functions.Count; fi++) - { - int count = ReadCount(r); - for (int i = 0; i < count; i++) - functions[fi].LineTable.Add(r.ReadInt32()); - } - } - - if (r.BaseStream.Position != r.BaseStream.Length) - throw new BytecodeException(new[] { new BytecodeDiagnostic("ECM 存在未消费的尾部字节", name, 0) }); - - return new ModuleArtifact - { - Name = name, - Functions = functions, - Pool = pool, - Imports = imports, - Exports = exports, - Globals = globals, - Natives = natives, - Structs = structs, - ILNames = ilNames, - HasEval = hasEval, - HasInit = hasInit, - InitFid = initFid, - KeyAction = keyAction, - NeedIL = needIL, - Interface = iface, - }; - } - - internal static int ReadCount(BinaryReader r) - { - var v = r.ReadUInt32(); - if (v > 100_000_000) - throw new BytecodeException(new[] { new BytecodeDiagnostic("ECM 计数异常(可能已损坏)", null, 0) }); - return (int)v; - } - - static void WriteConst(BinaryWriter w, EcsConst c) - { - w.Write(c.Tag); - switch (c.Tag) - { - case EcsTag.Int: - case EcsTag.UInt: - w.Write((int)c.Int64); - break; - case EcsTag.UInt64: - case EcsTag.Ptr: - w.Write(c.Int64); - break; - case EcsTag.Double: - w.Write(c.Float64); - break; - case EcsTag.String: - { - var units = Encoding.Unicode.GetBytes(c.Str ?? ""); - w.Write((ushort)(units.Length / 2)); - w.Write(units); - break; - } - default: - throw new BytecodeException(new[] { new BytecodeDiagnostic($"不支持的常量标签 {c.Tag}", null, 0) }); - } - } - - static EcsConst ReadConst(BinaryReader r) - { - var tag = r.ReadByte(); - switch (tag) - { - case EcsTag.Int: - case EcsTag.UInt: - return new EcsConst { Tag = tag, Int64 = r.ReadInt32() }; - case EcsTag.UInt64: - case EcsTag.Ptr: - return new EcsConst { Tag = tag, Int64 = r.ReadInt64() }; - case EcsTag.Double: - return new EcsConst { Tag = tag, Float64 = r.ReadDouble() }; - case EcsTag.String: - { - int units = r.ReadUInt16(); - var bytes = r.ReadBytes(units * 2); - return new EcsConst { Tag = tag, Str = Encoding.Unicode.GetString(bytes) }; - } - default: - throw new BytecodeException(new[] { new BytecodeDiagnostic($"ECM 常量标签非法 {tag}", null, 0) }); - } - } - - internal static void WriteUtf8(BinaryWriter w, string s) - { - var bytes = Encoding.UTF8.GetBytes(s); - if (bytes.Length > 0xFFFF) - throw new BytecodeException(new[] { new BytecodeDiagnostic($"标识符过长: {s}", null, 0) }); - w.Write((ushort)bytes.Length); - w.Write(bytes); - } - - internal static string ReadUtf8(BinaryReader r) - { - int len = r.ReadUInt16(); - return Encoding.UTF8.GetString(r.ReadBytes(len)); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/EcsCapabilityDefaults.cs b/src/EasyCon.Script/Bytecode/EcsCapabilityDefaults.cs new file mode 100644 index 00000000..b9e158fd --- /dev/null +++ b/src/EasyCon.Script/Bytecode/EcsCapabilityDefaults.cs @@ -0,0 +1,121 @@ +using EasyCon.Script.Symbols; + +namespace EasyCon.Script.Bytecode; + +/// +/// S-21 能力降级缺省值表(契约见 docs/VM2.md §6.3)—— +/// 桌面宿主专属:可选能力(推理/OCR/文件/FFI)未装配时,L2 syscall 未实现 / +/// L3 原生缺失按本表返回类型正确的中性缺省值,脚本不响亮失败。 +/// C/MCU 侧没有本表(MCU 不支持桌面原生与 FFI):C VM native 缺失一律 +/// ECS_ERR_NOSUCHNATIVE 响亮失败;仅 L2 syscall 保留双端锁步的参考桩降级 +/// (C ecs_cap_syscall_default,CapabilityMatrixTests 锁定)。 +/// +/// 规则:降级不是旁路——按指令的接收槽语义走正常槽写原语,「换一个结果值」; +/// 表外条目(未知编号 / 未知原生名)不降级,保持 ECS_ERR_NOSUCHNATIVE 响亮失败; +/// L1 域操作(wait/key/stick/rand)不降级;strict_caps 宿主整体绕过本表恢复响亮行为。 +/// +public static class EcsCapabilityDefaults +{ + /// + /// L2 syscall 缺省值(宿主 syscall 处理器缺失/返回未实现时)。 + /// 返回 null = 表外(未知编号)→ 调用方按 strict/非 strict 决定报错。 + /// 无接收槽语义的 Void 类 syscall 返回 (CallN 对 Void + /// syscall 恒无接收槽,等价 no-op)。 + /// + public static TaggedValue? GetSyscallDefault(int id, TaggedValue[] args, EcxNativeContext ctx) + { + switch (id) + { + case EcsSyscall.FWrite: /* PRINT 降级:静默仍返 len(S-14) */ + return TaggedValue.FromInt(ctx.Str(args[1]).Length); + case EcsSyscall.FRead: + case EcsSyscall.ReadFile: + return ctx.Str(""); + case EcsSyscall.FOpen: + return TaggedValue.FromPtr(-1); /* 无效句柄(PTR 类型——AsPtr 消费方契约) */ + case EcsSyscall.FEof: + case EcsSyscall.FileExists: + return TaggedValue.FromInt(0); + case EcsSyscall.FClose: + case EcsSyscall.WriteFile: + case EcsSyscall.AppendFile: + case EcsSyscall.Alert: + case EcsSyscall.Beep: + case EcsSyscall.Amiibo: + return TaggedValue.Void; + case EcsSyscall.Arg: + case EcsSyscall.Env: + case EcsSyscall.App: + return ctx.Str(""); + case EcsSyscall.Time: + return TaggedValue.FromInt(0); + case EcsSyscall.OcrConf: + return TaggedValue.FromInt(0); + default: + return null; /* 未知编号不降级 */ + } + } + + /// + /// L3 名表原生缺省值(宿主 Native 缺失或对名字返回未实现时)。 + /// 名含 ! = EXTERN FFI("库!导出名")→ int 0 中性缺省; + /// 表外未知名返回 null → 调用方报错(打错名不静默)。 + /// + public static TaggedValue? GetNativeDefault(string name, EcxNativeContext ctx) + { + switch (name) + { + case "__CAPTURE__": /* 采集/ROI 帧 → 空图串 */ + case "__ROI__": + case "__TESSERACT__": /* Tesseract OCR → 空串 */ + case "__CTC_FOLD__": /* CTC 折叠 → 空串 */ + case "__ENCODE__": + case "__JQ__": + return ctx.Str(""); + case "__TESSERACT_INIT__": /* 初始化失败语义:false */ + return TaggedValue.FromInt(0); + case "NET_LOAD": /* 会话句柄:PTR -1 = 失败(VisionInferenceTests 缺能力档同值) */ + return TaggedValue.FromPtr(-1); + case "__NET_IMAGE__": /* 张量句柄:PTR 0 = 无效句柄(与各 Impl 的 null-inference 分支同值) */ + case "NET_SCALE": + case "__NET_IMAGE_FILE__": + case "NET_NORM": + return TaggedValue.FromPtr(0); + case "NET_RUNH": /* 输出长度:INT */ + case "NET_ROWS": + case "NET_FREE": /* 状态:INT */ + case "NET_UNLOAD": + return TaggedValue.FromInt(0); + case "NET_ARGMAX": + return TaggedValue.FromInt(-1); + case "NET_OUT": + return TaggedValue.FromDouble(0.0); + case "NET_LABELS": /* 标签表:加载失败语义 -1(与 Impl 读失败同值) */ + return TaggedValue.FromInt(-1); + case "NET_LABEL": /* 未加载/越界 → 空串 */ + return ctx.Str(""); + case "__YOLO_DECODE__": /* YOLO 解码:无推理能力 → 0 命中(无目标语义) */ + case "YOLO_COUNT": + case "__DET_BOXES__": /* det 找框:无推理能力 → 0 框(同无目标语义) */ + case "DET_COUNT": + return TaggedValue.FromInt(0); + case "YOLO_CLS": /* 访问器越界语义:-1(与 NET_ARGMAX 同约定) */ + case "YOLO_X1": + case "YOLO_Y1": + case "YOLO_X2": + case "YOLO_Y2": + case "DET_X": + case "DET_Y": + case "DET_W": + case "DET_H": + return TaggedValue.FromInt(-1); + case "YOLO_SCORE": + return TaggedValue.FromDouble(-1.0); + default: + break; + } + if (name.Contains('!')) /* EXTERN FFI → int 0(类型经后续 Conv 决定) */ + return TaggedValue.FromInt(0); + return null; + } +} \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/EcsContainerFormat.cs b/src/EasyCon.Script/Bytecode/EcsContainerFormat.cs new file mode 100644 index 00000000..13d3dffa --- /dev/null +++ b/src/EasyCon.Script/Bytecode/EcsContainerFormat.cs @@ -0,0 +1,806 @@ +using System.Collections.Immutable; +using System.Text; + +namespace EasyCon.Script.Bytecode; + +/// +/// 产物容器(v2.3 零期平铺化): +/// .ecx 镜像 = ECX1 平铺(),.ecm 模块缓存 = ECM1 平铺 +/// ()。同一纪律:定长 36B 头 + 自计数表依序排列 + 头内全量 CRC; +/// 无逐段框架——格式演进 = 版本断代 + 全量重编(旧产物经魔数不符自然失效)。 +/// 指令流经 投影为 v3 定长字节流。 +/// 容量上限不是格式字段;产物只携带关于自身静态极值的事实(ZeroAllocVm.md §4.1)。 +/// +public static class EcsContainer +{ + public const uint FlatMagic = 0x31584345; // "ECX1"(平铺镜像容器) + public const ushort FlatFormat = 1; + public const uint ModuleFlatMagic = 0x314D4345; // "ECM1"(平铺模块缓存容器) + public const ushort ModuleFlatFormat = 1; + + public const byte HeaderFlagDebug = 0x01; // ECX1 flags:bit0=调试块在(ECM1 flags 见 WriteModule) + + /// + /// ECX1 平铺镜像容器(v2.3 零期,2026-10-05):无逐段框架——定长 36B 头 + 主模块名 + + /// 自计数表依序排列(常量/类型/全局/原生/函数表/.text/可选调试块)+ 全量 CRC。 + /// 调试块 = [linesLen u32][lines][namesLen u32][names];dbg_size=0 即省略。 + /// + public static byte[] WriteImage(EcxImage image, bool stripDebug = false, bool withCrc = true) + { + // .text 投影(函数 code_off 依赖字节布局) + var codeOffsets = new uint[image.Functions.Count]; + var codeBuf = new List(); + for (int i = 0; i < image.Functions.Count; i++) + { + codeOffsets[i] = (uint)codeBuf.Count; + codeBuf.AddRange(InstructionCodec.Project(image.Functions[i].Instructions)); + } + var extreme = ComputeStaticExtremes(image.Functions, image.Consts, image.Structs); + + // 调试块(可选) + byte[] dbg = []; + if (!stripDebug) + { + var p = new MemoryStream(); + using (var dw = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true)) + { + var lines = new MemoryStream(); + using (var lw = new BinaryWriter(lines, Encoding.UTF8, leaveOpen: true)) + { + lw.Write(image.Functions.Count); + for (int i = 0; i < image.Functions.Count; i++) + { + var offs = InstructionLayout(image.Functions[i].Instructions); + var table = image.Functions[i].LineTable; + lw.Write(table.Count / 2); + for (int k = 0; k < table.Count; k += 2) + { + int idx = table[k]; + lw.Write(idx >= 0 && idx < offs.Length ? offs[idx] : 0); + lw.Write(table[k + 1]); + } + } + } + var lineBytes = lines.ToArray(); + dw.Write(lineBytes.Length); + dw.Write(lineBytes); + var names = new MemoryStream(); + using (var nw = new BinaryWriter(names, Encoding.UTF8, leaveOpen: true)) + { + nw.Write(image.Functions.Count + image.Globals.Count); + foreach (var f in image.Functions) + WriteUtf8(nw, f.Name); + foreach (var g in image.Globals) + WriteUtf8(nw, g.Name); + } + var dbgNameBytes = names.ToArray(); + dw.Write(dbgNameBytes.Length); + dw.Write(dbgNameBytes); + } + dbg = p.ToArray(); + } + + // 各表载荷(镜像与模块共用编码:utf8 名 = u16 长度 + UTF-8,常量 = tag + 定宽负载) + byte[] ConstsPayload() { var p = new MemoryStream(); using var cw = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true); cw.Write(image.Consts.Count); foreach (var c in image.Consts) WriteConst(cw, c); return p.ToArray(); } + byte[] StructsPayload() { var p = new MemoryStream(); using var sw2 = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true); sw2.Write(image.Structs.Count); foreach (var st in image.Structs) { WriteUtf8(sw2, st.Name); sw2.Write((byte)st.Fields.Length); foreach (var f in st.Fields) { WriteUtf8(sw2, f.Name); sw2.Write((byte)f.Kind); sw2.Write((byte)f.Type); sw2.Write((byte)f.ElementType); sw2.Write((ushort)(f.Kind == EcsFieldKind.NestedStruct ? f.NestedSid : f.Count)); } } return p.ToArray(); } + byte[] GlobalsPayload() { var p = new MemoryStream(); using var gw = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true); gw.Write(image.Globals.Count); foreach (var g in image.Globals) { gw.Write((byte)Math.Max(0, image.Modules.IndexOf(g.Module))); gw.Write((byte)g.Type); } return p.ToArray(); } + byte[] NativesPayload() { var p = new MemoryStream(); using var nw2 = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true); nw2.Write(image.Natives.Count); foreach (var n in image.Natives) WriteUtf8(nw2, n.Name); return p.ToArray(); } + byte[] FuncsPayload() { var p = new MemoryStream(); using var fw = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true); fw.Write(image.Functions.Count); for (int i = 0; i < image.Functions.Count; i++) { fw.Write((ushort)image.Functions[i].NSlots); fw.Write(codeOffsets[i]); } return p.ToArray(); } + + var nameBytes = Encoding.UTF8.GetBytes(image.Modules.Count > 0 ? image.Modules[^1] : ""); + int codeSize = codeBuf.Count; + + using var ms = new MemoryStream(); + var w = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); + w.Write(FlatMagic); + w.Write(FlatFormat); + w.Write((ushort)EcsSyscall.AbiRevision); + w.Write((ushort)(image.Features & 0xFFFF)); + w.Write((ushort)image.Entry); + w.Write((byte)((stripDebug ? 0 : HeaderFlagDebug) | (image.KeyAction ? 0x2 : 0) | (image.NeedIL ? 0x4 : 0))); + w.Write((byte)nameBytes.Length); + w.Write((ushort)image.Functions.Count); + w.Write((uint)codeSize); + w.Write((uint)dbg.Length); + w.Write((ushort)Math.Min(extreme.MaxLiteralStringUnits, 0xFFFF)); + w.Write((ushort)Math.Min(extreme.MaxLiteralArrayElems, 0xFFFF)); + w.Write((ushort)Math.Min(extreme.MaxStructSlots, 0xFFFF)); + w.Write((ushort)0); + w.Write(0u); // all_crc32 占位 + w.Write(nameBytes); + w.Write(ConstsPayload()); + w.Write(StructsPayload()); + w.Write(GlobalsPayload()); + w.Write(NativesPayload()); + w.Write(FuncsPayload()); + w.Write(codeBuf.ToArray()); + if (dbg.Length > 0) + w.Write(dbg); + w.Flush(); + + var bytes = ms.ToArray(); + if (withCrc) + ResealCrc32(bytes); + return bytes; + } + + /// + /// ECM1 平铺模块缓存容器:36B 头(magic/format/abi/flags/名长/函数数/codeSize/initFid/ + /// metaSize/静态极值/CRC)+ 模块名 + 自计数表序(常量/类型/全局/原生/函数表/.text) + /// + 元数据尾块([linesLen][lines][imports][exports][ilnames][ifaceLen][iface])+ 全量 CRC。 + /// flags:bit0=hasEval bit1=hasInit bit2=keyAction bit3=needIL。 + /// 仅 PC 侧编译缓存消费(进程缓存 + obj/ 磁盘缓存);不携带入口/链接结果(链接期内存隐式)。 + /// + public static byte[] WriteModule(ModuleArtifact module) + { + // .text 投影(函数 code_off 依赖字节布局) + var codeOffsets = new uint[module.Functions.Count]; + var codeBuf = new List(); + for (int i = 0; i < module.Functions.Count; i++) + { + codeOffsets[i] = (uint)codeBuf.Count; + codeBuf.AddRange(InstructionCodec.Project(module.Functions[i].Instructions)); + } + + // 行号块(缓存命中路径保留运行错误行号映射;单位 = 函数内相对字节偏移,与镜像调试块同布局) + var lines = new MemoryStream(); + using (var lw = new BinaryWriter(lines, Encoding.UTF8, leaveOpen: true)) + { + lw.Write(module.Functions.Count); + for (int i = 0; i < module.Functions.Count; i++) + { + var offs = InstructionLayout(module.Functions[i].Instructions); + var table = module.Functions[i].LineTable; + lw.Write(table.Count / 2); + for (int k = 0; k < table.Count; k += 2) + { + int idx = table[k]; + lw.Write(idx >= 0 && idx < offs.Length ? offs[idx] : 0); + lw.Write(table[k + 1]); + } + } + } + var lineBytes = lines.ToArray(); + + byte[] ifaceBytes = []; + if (module.Interface is { } iface) + { + var p = new MemoryStream(); + using (var iw = new BinaryWriter(p, Encoding.UTF8, leaveOpen: true)) + ModuleInterfaceFormat.Write(iw, iface); + ifaceBytes = p.ToArray(); + } + + // 元数据尾块 + var meta = new MemoryStream(); + using (var mw = new BinaryWriter(meta, Encoding.UTF8, leaveOpen: true)) + { + mw.Write(lineBytes.Length); + mw.Write(lineBytes); + mw.Write(module.Imports.Count); + foreach (var i in module.Imports) + { + WriteUtf8(mw, i.Name); + mw.Write((byte)i.NParams); + mw.Write((byte)(i.HasReturn ? 1 : 0)); + } + mw.Write(module.Exports.Count); + foreach (var e in module.Exports) + { + WriteUtf8(mw, e.Name); + mw.Write((uint)e.LocalFid); + mw.Write((byte)e.NParams); + mw.Write((byte)(e.HasReturn ? 1 : 0)); + } + mw.Write(module.ILNames.Count); + foreach (var ilName in module.ILNames) + WriteUtf8(mw, ilName); + mw.Write(ifaceBytes.Length); + mw.Write(ifaceBytes); + } + var metaBytes = meta.ToArray(); + + var nameBytes = Encoding.UTF8.GetBytes(module.Name); + using var ms = new MemoryStream(); + var w = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); + w.Write(ModuleFlatMagic); + w.Write(ModuleFlatFormat); + w.Write((ushort)EcsSyscall.AbiRevision); + w.Write((byte)((module.HasEval ? 0x1 : 0) | (module.HasInit ? 0x2 : 0) + | (module.KeyAction ? 0x4 : 0) | (module.NeedIL ? 0x8 : 0))); + w.Write((byte)nameBytes.Length); + w.Write((ushort)module.Functions.Count); + w.Write((uint)codeBuf.Count); + w.Write(module.HasInit ? module.InitFid : -1); + w.Write((uint)metaBytes.Length); + var extreme = ComputeStaticExtremes(module.Functions, module.Pool.Consts, module.Structs); + w.Write((ushort)Math.Min(extreme.MaxLiteralStringUnits, 0xFFFF)); + w.Write((ushort)Math.Min(extreme.MaxLiteralArrayElems, 0xFFFF)); + w.Write((ushort)Math.Min(extreme.MaxStructSlots, 0xFFFF)); + w.Write((ushort)0); + w.Write(0u); // all_crc32 占位 + w.Write(nameBytes); + w.Write(module.Pool.Consts.Count); + foreach (var c in module.Pool.Consts) + WriteConst(w, c); + w.Write(module.Structs.Count); + foreach (var st in module.Structs) + { + WriteUtf8(w, st.Name); + w.Write((byte)st.Fields.Length); + foreach (var f in st.Fields) + { + WriteUtf8(w, f.Name); + w.Write((byte)f.Kind); + w.Write((byte)f.Type); + w.Write((byte)f.ElementType); + w.Write((ushort)(f.Kind == EcsFieldKind.NestedStruct ? f.NestedSid : f.Count)); + } + } + w.Write(module.Globals.Count); + foreach (var g in module.Globals) + { + WriteUtf8(w, g.Name); + w.Write((byte)g.Type); + } + w.Write(module.Natives.Count); + foreach (var n in module.Natives) + WriteUtf8(w, n.Name); + w.Write(module.Functions.Count); + for (int i = 0; i < module.Functions.Count; i++) + { + WriteUtf8(w, module.Functions[i].Name); + w.Write((byte)module.Functions[i].NParams); + w.Write((byte)(module.Functions[i].HasReturn ? 1 : 0)); + w.Write((ushort)module.Functions[i].NSlots); + w.Write(codeOffsets[i]); + } + w.Write(codeBuf.ToArray()); + w.Write(metaBytes); + w.Flush(); + + var bytes = ms.ToArray(); + ResealCrc32(bytes); + return bytes; + } + + /// SEC_LINES 投影所需的指令字节布局(v3 定长:尺寸只依赖操作码,一遍即得)。 + static int[] InstructionLayout(IReadOnlyList instructions) + { + int n = instructions.Count; + var offsets = new int[n + 1]; + int off = 0; + for (int i = 0; i < n; i++) + { + offsets[i] = off; + off += InstructionCodec.SizeOf(instructions[i].Op); + } + offsets[n] = off; + return offsets; + } + + /// 程序静态极值(ZeroAllocVm.md §4.1):只记录关于自身的事实,不做容量判定。 + public readonly record struct StaticExtremes(int MaxLiteralStringUnits, int MaxLiteralArrayElems, int MaxStructSlots); + + public static StaticExtremes ComputeStaticExtremes(List functions, List consts, + List structs) + { + int maxStr = 0, maxArr = 0, maxSt = 0; + foreach (var c in consts) + if (c.Tag == EcsTag.String) + maxStr = Math.Max(maxStr, (c.Str ?? "").Length); + foreach (var f in functions) + foreach (var ins in f.Instructions) + if (ins.Op == EcsOpcode.NewArrV) + maxArr = Math.Max(maxArr, ins.B); + foreach (var s in structs) + { + maxSt = Math.Max(maxSt, s.SlotCount); + foreach (var f in s.Fields) + if (f.Kind == EcsFieldKind.FixedArray) + maxArr = Math.Max(maxArr, f.Count); + } + return new StaticExtremes(maxStr, maxArr, maxSt); + } + + // ============ 读取 ============ + + public static EcxImage ReadImage(byte[] bytes) + { + if (bytes.Length < 36 || BitConverter.ToUInt32(bytes, 0) != FlatMagic) + throw Err("magic 不是 ECX1(平铺镜像)", null); + int format = BitConverter.ToUInt16(bytes, 4); + if (format != FlatFormat) + throw Err($"ECX1 format {format} 不受支持", null); + int abi = BitConverter.ToUInt16(bytes, 6); + ushort feats = BitConverter.ToUInt16(bytes, 8); + int entry = BitConverter.ToUInt16(bytes, 10); + byte flagsB = bytes[12]; + byte nameLen = bytes[13]; + int funcCount = BitConverter.ToUInt16(bytes, 14); + int codeSize = (int)BitConverter.ToUInt32(bytes, 16); + int dbgSize = (int)BitConverter.ToUInt32(bytes, 20); + if (nameLen > bytes.Length - 36) + throw Err("主模块名越界(产物损坏)", null); + string mainModule = Encoding.UTF8.GetString(bytes, 36, nameLen); + + // CRC 校验(字段清零后全量) + var savedCrc = BitConverter.ToUInt32(bytes, 32); + var check = (byte[])bytes.Clone(); + Array.Clear(check, 32, 4); + if (Crc32IEEE(check.AsSpan()) != savedCrc) + throw Err("ECX1 全量 CRC 校验失败(产物损坏)", null); + + var modules = new List(); + if (nameLen > 0) + modules.Add(mainModule); + + var image = new EcxImage + { + Entry = entry, + KeyAction = (flagsB & 0x2) != 0, + NeedIL = (flagsB & 0x4) != 0, + Features = feats, + Modules = modules, + }; + + int pos = 36 + nameLen; + var r = new BinaryReader(new MemoryStream(bytes), Encoding.UTF8); + r.BaseStream.Position = pos; + + // 常量池 + int constCount = r.ReadInt32(); + for (int i = 0; i < constCount; i++) + image.Consts.Add(ReadConst(r)); + + // 类型表 + image.Structs = ReadStructTable(r); + + // 全局表 + int globalCount = r.ReadInt32(); + for (int i = 0; i < globalCount; i++) + { + int moduleIdx = r.ReadByte(); + var type = (EcsTypeCode)r.ReadByte(); + image.Globals.Add(new EcsGlobal + { + Name = $"g{i}", + Module = moduleIdx >= 0 && moduleIdx < image.Modules.Count ? image.Modules[moduleIdx] : "?", + Type = type, + }); + } + + // 原生名表 + int nativeCount = r.ReadInt32(); + for (int i = 0; i < nativeCount; i++) + image.Natives.Add(new EcsNative { Name = ReadUtf8(r) }); + + // 函数表 + .text + if (funcCount != r.ReadInt32()) + throw Err("函数表计数与头部不一致(产物损坏)", null); + var codeOffsets = new uint[funcCount]; + var slotCounts = new int[funcCount]; + for (int i = 0; i < funcCount; i++) + { + slotCounts[i] = r.ReadUInt16(); + codeOffsets[i] = r.ReadUInt32(); + } + int codeStart = (int)r.BaseStream.Position; + var codeBytes = new byte[codeSize]; + Array.Copy(bytes, codeStart, codeBytes, 0, codeSize); + r.BaseStream.Position = codeStart + codeSize; + + var functions = new List(funcCount); + for (int i = 0; i < funcCount; i++) + { + int end = i + 1 < funcCount ? (int)codeOffsets[i + 1] : codeSize; + var instrs = InstructionCodec.Lift(codeBytes, (int)codeOffsets[i], end, $"func{i}"); + functions.Add(new EcsFunction + { + Name = $"f{i}", + Module = mainModule, + NSlots = slotCounts[i], + Instructions = instrs, + ImageIndex = i, + }); + } + image.Functions = functions; + + // 调试块(可选):行号表(字节偏移→指令下标还原)+ 名字表 + if (dbgSize > 0) + { + int linesLen = r.ReadInt32(); + var lr = new BinaryReader(new MemoryStream(bytes, (int)r.BaseStream.Position, linesLen)); + r.BaseStream.Position += linesLen; + int lineFuncCount = lr.ReadInt32(); + for (int fi = 0; fi < funcCount && fi < lineFuncCount; fi++) + { + int pairs = lr.ReadInt32(); + // 起始字节表(函数内相对偏移,与写入侧 InstructionLayout 同基准) + var startsList = new List(); + int acc = 0; + foreach (var ins in functions[fi].Instructions) + { + startsList.Add(acc); + acc += InstructionCodec.SizeOf(ins.Op); + } + var table = new List(pairs * 2); + for (int k = 0; k < pairs; k++) + { + int byteOff = lr.ReadInt32(); + int line = lr.ReadInt32(); + int idx = startsList.BinarySearch(byteOff); + if (idx >= 0) + { + table.Add(idx); + table.Add(line); + } + } + functions[fi].LineTable = table; + } + int namesLen = r.ReadInt32(); + var nr = new BinaryReader(new MemoryStream(bytes, (int)r.BaseStream.Position, namesLen)); + r.BaseStream.Position += namesLen; + int nameCount = nr.ReadInt32(); + for (int i = 0; i < nameCount && i < functions.Count; i++) + functions[i].Name = ReadUtf8(nr); + for (int i = functions.Count; i < nameCount; i++) + { + if (i - functions.Count < image.Globals.Count) + image.Globals[i - functions.Count].Name = ReadUtf8(nr); + else + ReadUtf8(nr); + } + } + else + { + for (int i = 0; i < functions.Count; i++) + functions[i].Name = i == entry ? "
" : $"f{i}"; + } + + if (entry < 0 || entry >= functions.Count) + throw Err($"entry 越界 {entry}", null); + if (functions[entry].Name == "
" || string.IsNullOrEmpty(functions[entry].Name)) + functions[entry].Name = "
"; + EcxPipeline.ComputeResourceRequirements(image); + return image; + } + + public static ModuleArtifact ReadModule(byte[] bytes) + { + if (bytes.Length < 36 || BitConverter.ToUInt32(bytes, 0) != ModuleFlatMagic) + throw Err("magic 不是 ECM1(平铺模块缓存;旧 ECSC kind=module 产物已断代,按缓存未命中重编)", null); + int format = BitConverter.ToUInt16(bytes, 4); + if (format != ModuleFlatFormat) + throw Err($"ECM1 format {format} 不受支持", null); + + // CRC 校验(字段清零后全量) + var savedCrc = BitConverter.ToUInt32(bytes, 32); + var check = (byte[])bytes.Clone(); + Array.Clear(check, 32, 4); + if (Crc32IEEE(check.AsSpan()) != savedCrc) + throw Err("ECM1 全量 CRC 校验失败(产物损坏)", null); + + byte flags = bytes[8]; + int nameLen = bytes[9]; + int funcCount = BitConverter.ToUInt16(bytes, 10); + int codeSize = (int)BitConverter.ToUInt32(bytes, 12); + int initFid = BitConverter.ToInt32(bytes, 16); + int metaSize = (int)BitConverter.ToUInt32(bytes, 20); + if (nameLen > bytes.Length - 36) + throw Err("模块名越界(产物损坏)", null); + var name = Encoding.UTF8.GetString(bytes, 36, nameLen); + + var r = new BinaryReader(new MemoryStream(bytes), Encoding.UTF8); + r.BaseStream.Position = 36 + nameLen; + + // 常量池(ModulePool 重建去重索引) + var pool = new ModulePool(); + int constCount = r.ReadInt32(); + for (int i = 0; i < constCount; i++) + pool.Add(ReadConst(r)); + + var structs = ReadStructTable(r); + + // 全局表(模块味:名字 + 类型;Module 归一为本模块名) + var globals = new List(); + int globalCount = r.ReadInt32(); + for (int i = 0; i < globalCount; i++) + globals.Add(new EcsGlobal { Name = ReadUtf8(r), Module = name, Type = (EcsTypeCode)r.ReadByte() }); + + var natives = new List(); + int nativeCount = r.ReadInt32(); + for (int i = 0; i < nativeCount; i++) + natives.Add(new EcsNative { Name = ReadUtf8(r) }); + + // 函数表 + .text + if (funcCount != r.ReadInt32()) + throw Err("函数表计数与头部不一致(产物损坏)", null); + var metas = new List<(string Name, int NParams, bool HasReturn, int NSlots, uint Off)>(funcCount); + for (int i = 0; i < funcCount; i++) + { + var fname = ReadUtf8(r); + int nparams = r.ReadByte(); + bool hasret = r.ReadByte() != 0; + int nslots = r.ReadUInt16(); + uint off = r.ReadUInt32(); + metas.Add((fname, nparams, hasret, nslots, off)); + } + int codeStart = (int)r.BaseStream.Position; + var codeBytes = new byte[codeSize]; + Array.Copy(bytes, codeStart, codeBytes, 0, codeSize); + r.BaseStream.Position = codeStart + codeSize; + + var functions = new List(funcCount); + var startsPerFunc = new List>(funcCount); + for (int i = 0; i < funcCount; i++) + { + int end = i + 1 < funcCount ? (int)metas[i + 1].Off : codeSize; + var (fname, nparams, hasret, nslots, off) = metas[i]; + functions.Add(new EcsFunction + { + Name = fname, + Module = name, + NParams = nparams, + NSlots = nslots, + HasReturn = hasret, + Instructions = InstructionCodec.LiftWithStarts(codeBytes, (int)off, end, out var starts, fname), + }); + for (int k = 0; k < starts.Count; k++) + starts[k] -= (int)off; // 行号表基准 = 函数内相对偏移(写入侧 InstructionLayout 同基准) + startsPerFunc.Add(starts); + } + + // 元数据尾块:[linesLen][lines][imports][exports][ilnames][ifaceLen][iface] + long metaEnd = (long)r.BaseStream.Position + metaSize; + int linesLen = r.ReadInt32(); + if (linesLen > 0) + { + using var lr = new BinaryReader(new MemoryStream(bytes, (int)r.BaseStream.Position, linesLen)); + r.BaseStream.Position += linesLen; + int lineFuncCount = lr.ReadInt32(); + for (int fi = 0; fi < functions.Count && fi < lineFuncCount; fi++) + { + int pairs = lr.ReadInt32(); + var starts = startsPerFunc[fi]; + var table = functions[fi].LineTable; + for (int k = 0; k < pairs; k++) + { + int byteOff = lr.ReadInt32(); + int line = lr.ReadInt32(); + int idx = starts.BinarySearch(byteOff); + if (idx >= 0) + { + table.Add(idx); + table.Add(line); + } + } + } + } + var imports = new List(); + int importCount = r.ReadInt32(); + for (int i = 0; i < importCount; i++) + imports.Add(new EcsImport { Name = ReadUtf8(r), NParams = r.ReadByte(), HasReturn = r.ReadByte() != 0 }); + var exports = new List(); + int exportCount = r.ReadInt32(); + for (int i = 0; i < exportCount; i++) + { + var ename = ReadUtf8(r); + var localFid = (int)r.ReadUInt32(); + var nparams = r.ReadByte(); + var hasret = r.ReadByte() != 0; + exports.Add(new EcsExport { Name = ename, LocalFid = localFid, NParams = nparams, HasReturn = hasret }); + } + var ilNames = new List(); + int ilCount = r.ReadInt32(); + for (int i = 0; i < ilCount; i++) + ilNames.Add(ReadUtf8(r)); + ModuleInterface? iface = null; + int ifaceLen = r.ReadInt32(); + if (ifaceLen > 0) + { + using var ir = new BinaryReader(new MemoryStream(bytes, (int)r.BaseStream.Position, ifaceLen)); + r.BaseStream.Position += ifaceLen; + iface = ModuleInterfaceFormat.Read(ir); + } + if (r.BaseStream.Position != metaEnd || r.BaseStream.Position != bytes.Length) + throw Err("ECM1 元数据尾块长度不符(产物损坏)", null); + + return new ModuleArtifact + { + Name = name, + Functions = functions, + Pool = pool, + Imports = imports, + Exports = exports, + Globals = globals, + Natives = natives, + Structs = structs, + ILNames = ilNames, + HasEval = (flags & 0x1) != 0, + HasInit = (flags & 0x2) != 0, + InitFid = initFid, + KeyAction = (flags & 0x4) != 0, + NeedIL = (flags & 0x8) != 0, + Interface = iface, + }; + } + + // ---- 读取辅助 ---- + + /// 结构体类型表(ECX1/ECM1 共用布局):u32 计数 + 每项 utf8 名 + 字段集;读后展开槽布局。 + static List ReadStructTable(BinaryReader r) + { + int count = r.ReadInt32(); + var structs = new List(count); + for (int i = 0; i < count; i++) + { + var sname = ReadUtf8(r); + int nfields = r.ReadByte(); + var fields = new EcsFieldLayout[nfields]; + for (int f = 0; f < nfields; f++) + { + var fname = ReadUtf8(r); + var kind = (EcsFieldKind)r.ReadByte(); + var type = (EcsTypeCode)r.ReadByte(); + var elem = (EcsTypeCode)r.ReadByte(); + var ext = r.ReadUInt16(); + fields[f] = new EcsFieldLayout + { + Name = fname, + Kind = kind, + Type = type, + ElementType = elem, + Count = kind == EcsFieldKind.NestedStruct ? 0 : ext, + NestedSid = kind == EcsFieldKind.NestedStruct ? ext : 0, + }; + } + structs.Add(new EcsStructLayout { Name = sname, Fields = fields.ToImmutableArray() }); + } + ExpandLayouts(structs); + return structs; + } + + /// 布局展开须递归:嵌套类型的 sid 可能大于父结构体(名字字母序),单遍扫描会错位。 + internal static void ExpandLayouts(List structs) + { + var bySid = new Dictionary(); + for (int s = 0; s < structs.Count; s++) + bySid[s] = structs[s]; + var computing = new HashSet(); + int SlotCountOf(int sid) + { + var layout = structs[sid]; + if (layout.SlotCount > 0) + return layout.SlotCount; + if (!computing.Add(sid)) + throw Err($"结构体 {layout.Name} 存在自嵌套环(产物损坏)", null); + int total = 0; + foreach (var f in layout.Fields) + { + f.SlotOffset = total; + total += f.Kind switch + { + EcsFieldKind.FixedArray => f.Count, + EcsFieldKind.NestedStruct => bySid.ContainsKey(f.NestedSid) ? SlotCountOf(f.NestedSid) : 1, + _ => 1, + }; + } + computing.Remove(sid); + layout.SlotCount = total; + return total; + } + for (int s = 0; s < structs.Count; s++) + SlotCountOf(s); + } + + + // ---- 基础编码 ---- + + internal static void WriteConst(BinaryWriter w, EcsConst c) + { + w.Write(c.Tag); + switch (c.Tag) + { + case EcsTag.Int: + case EcsTag.UInt: + w.Write((int)c.Int64); + break; + case EcsTag.UInt64: + case EcsTag.Ptr: + w.Write(c.Int64); + break; + case EcsTag.Double: + w.Write(c.Float64); + break; + case EcsTag.String: + { + var units = Encoding.Unicode.GetBytes(c.Str ?? ""); + if (units.Length / 2 > 0xFFFF) + throw Err($"常量池字符串超过 65535 code units: {c.Str?[..64]}…", null); + w.Write((ushort)(units.Length / 2)); + w.Write(units); + break; + } + default: + throw Err($"不支持的常量标签 {c.Tag}", null); + } + } + + static EcsConst ReadConst(BinaryReader r) + { + var tag = r.ReadByte(); + switch (tag) + { + case EcsTag.Int: + case EcsTag.UInt: + return new EcsConst { Tag = tag, Int64 = r.ReadInt32() }; + case EcsTag.UInt64: + case EcsTag.Ptr: + return new EcsConst { Tag = tag, Int64 = r.ReadInt64() }; + case EcsTag.Double: + return new EcsConst { Tag = tag, Float64 = r.ReadDouble() }; + case EcsTag.String: + { + int units = r.ReadUInt16(); + var bytes = r.ReadBytes(units * 2); + return new EcsConst { Tag = tag, Str = Encoding.Unicode.GetString(bytes) }; + } + default: + throw Err($"常量标签非法 {tag}", null); + } + } + + internal static void WriteUtf8(BinaryWriter w, string s) + { + var bytes = Encoding.UTF8.GetBytes(s); + if (bytes.Length > 0xFFFF) + throw Err($"标识符过长: {s}", null); + w.Write((ushort)bytes.Length); + w.Write(bytes); + } + + internal static string ReadUtf8(BinaryReader r) + { + int len = r.ReadUInt16(); + return Encoding.UTF8.GetString(r.ReadBytes(len)); + } + + internal static int ReadCount(BinaryReader r) + { + var v = r.ReadUInt32(); + if (v > 100_000_000) + throw Err("容器计数异常(可能已损坏)", null); + return (int)v; + } + + internal static BytecodeException Err(string message, string? context) + => new(new[] { new BytecodeDiagnostic(context == null ? message : $"{context}: {message}", null, 0) }); + + // CRC-32(IEEE 802.3,反射多项式 0xEDB88320) + internal static uint Crc32IEEE(ReadOnlySpan data) + { + uint crc = 0xFFFFFFFFu; + foreach (var b in data) + { + crc ^= b; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & ~(uint)((crc & 1) - 1)); + } + return ~crc; + } + + /// + /// 重算并写回 ECX1 全量 CRC(u32@32,计算前先清零该字段)。 + /// 供镜像字节被外部工具/测试改写后重封;加载期 CRC 校验覆盖整个镜像,补丁不重封必被拒。 + /// + public static void ResealCrc32(byte[] flatImage) + { + Array.Clear(flatImage, 32, 4); + uint crc = Crc32IEEE(flatImage.AsSpan()); + flatImage[32] = (byte)crc; + flatImage[33] = (byte)(crc >> 8); + flatImage[34] = (byte)(crc >> 16); + flatImage[35] = (byte)(crc >> 24); + } +} \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/EcsFormat.cs b/src/EasyCon.Script/Bytecode/EcsFormat.cs index a50477f2..c605e251 100644 --- a/src/EasyCon.Script/Bytecode/EcsFormat.cs +++ b/src/EasyCon.Script/Bytecode/EcsFormat.cs @@ -1,6 +1,6 @@ namespace EasyCon.Script.Bytecode; -/// 指令编码格式(docs/VM2.md §4.1)。 +/// 指令编码格式(docs/VM2.md §4.1;v3 定长编码——线格式与内存布局一一对应)。 public enum EcsInsFormat : byte { /// op:8 | A:8 | B:8 | C:8(4 字节) @@ -9,10 +9,12 @@ public enum EcsInsFormat : byte AsBx, /// op:8 | A:8 | Bx:16(4 字节,无符号) ABx, - /// op:8 | s24:24(4 字节,有符号,单位=指令字) + /// op:8 | sJ:24(4 字节,有符号,单位=字节) IsJ, /// iABC + 后随 ext:32(8 字节) Ext, + /// iABC + 后随跳转偏移字:32(8 字节;ForStep/CmpJ 融合指令的第 4 操作数) + IabcJ, } /// EXT 后随 32 位数据字的语义(docs/VM2.md §4.1/§4.2)。 @@ -34,6 +36,10 @@ public enum EcsExtKind : byte StickDuration, /// StickPv:高16位x | 低16位y 的 packed 坐标。 StickXY, + /// WaitI:持续毫秒立即数。 + WaitDuration, + /// KeyI:持续毫秒立即数。 + KeyDuration, } /// 结果槽写集(docs/VM2.md §4.2):指令执行后写回的帧槽位字段。 @@ -52,7 +58,7 @@ public enum EcsResultSlot : byte /// 指令格式单一事实源(docs/VM2.md §4.1/§4.2):每操作码恰一行的编码格式、EXT 数据字语义、 /// 结果槽写集。发射侧(BytecodeEncoder.Emit* 自检)、扫描侧(EcxLinker 各 pass 经 ExtWords /// 步进、InstructionScanner)、反汇编(EcxDisassembler)、体积分析(BytecodeSizeAnalysisTests) -/// 与 C VM 的 ecs_op_has_ext 表(ecs_vm.c,紧邻 OP_ 枚举)均以本表为对齐基准。 +/// 与 C VM 的 ecs_op_words 表(ecs_vm.h,紧邻 OP_ 枚举)均以本表为对齐基准。 /// 关键不变量:EXT 后随字是「数据」,其数值可能恰好等于某个操作码(如元素类型码 3 == LoadK), /// 一切线性扫描必须按 ExtWords 步进跳过,否则把数据误读为指令(轻则静默改坏 Slice 槽位/ /// StickPv 时长,重则越界崩溃——历史 F4 缺陷即此类)。 @@ -68,11 +74,12 @@ public static class EcsFormat /// 结果槽写集(执行语义的编码侧投影,见 EcsResultSlot)。 public static EcsResultSlot ResultSlot(EcsOpcode op) => Table[(int)op].Result; - /// 指令总字数(4 字节字为单位;EXT=2,其余=1)。 - public static int WordCount(EcsOpcode op) => Get(op) == EcsInsFormat.Ext ? 2 : 1; + /// 指令总字数(4 字节字为单位;Ext/IabcJ=2,其余=1)。 + public static int WordCount(EcsOpcode op) => Get(op) is EcsInsFormat.Ext or EcsInsFormat.IabcJ ? 2 : 1; - /// EXT 后随 32 位数据字个数(非 EXT 指令为 0)。线性扫描器的唯一步进依据。 - public static int ExtWords(EcsOpcode op) => Get(op) == EcsInsFormat.Ext ? 1 : 0; + /// 后随 32 位数据字个数(4 字节指令为 0)。定长线性扫描的唯一步进依据; + /// IabcJ 的数据字 = 跳转偏移(ForStep 出口 / CmpJ 跳转),同样是「数据不可误读为指令」。 + public static int ExtWords(EcsOpcode op) => WordCount(op) - 1; struct InsInfo { @@ -196,9 +203,9 @@ void F(EcsOpcode op, EcsInsFormat format, EcsResultSlot result = EcsResultSlot.A F(EcsOpcode.PutFI, EcsInsFormat.Ext, EcsResultSlot.None, EcsExtKind.FieldElemSlot); // ---- 域操作 ---- - F(EcsOpcode.WaitI, EcsInsFormat.ABx, EcsResultSlot.None); + F(EcsOpcode.WaitI, EcsInsFormat.Ext, EcsResultSlot.None, EcsExtKind.WaitDuration); // v3:时长进数据字(无上限) F(EcsOpcode.WaitV, EcsInsFormat.Iabc, EcsResultSlot.None); - F(EcsOpcode.KeyI, EcsInsFormat.ABx, EcsResultSlot.None); + F(EcsOpcode.KeyI, EcsInsFormat.Ext, EcsResultSlot.None, EcsExtKind.KeyDuration); // v3:时长进数据字(无上限) F(EcsOpcode.KeyV, EcsInsFormat.Iabc, EcsResultSlot.None); F(EcsOpcode.KeySt, EcsInsFormat.Iabc, EcsResultSlot.None); F(EcsOpcode.StickSet, EcsInsFormat.Iabc, EcsResultSlot.None); @@ -207,6 +214,10 @@ void F(EcsOpcode op, EcsInsFormat format, EcsResultSlot result = EcsResultSlot.A F(EcsOpcode.Img, EcsInsFormat.ABx); F(EcsOpcode.Rand, EcsInsFormat.Iabc); + // ---- FOR 快速路径 ---- + F(EcsOpcode.ForStep, EcsInsFormat.IabcJ, EcsResultSlot.A); // 数据字 = 出口偏移(Patch 期回填) + F(EcsOpcode.CmpJ, EcsInsFormat.IabcJ, EcsResultSlot.None); // C = kind 码,数据字 = 跳转偏移(Patch 期回填) + // 完整性自检:每个枚举值必须显式登记。新增 EcsOpcode 值漏登时在此抛出 //(而不是静默按 Iabc 编码/扫描——那正是历史 EXT 误读缺陷的成因形态)。 if (registered != Enum.GetValues().Length) diff --git a/src/EasyCon.Script/Bytecode/EcsOpcode.cs b/src/EasyCon.Script/Bytecode/EcsOpcode.cs index f8f2e986..9679aedf 100644 --- a/src/EasyCon.Script/Bytecode/EcsOpcode.cs +++ b/src/EasyCon.Script/Bytecode/EcsOpcode.cs @@ -90,6 +90,9 @@ public enum EcsOpcode : byte StickPv, // iABC + ext32: A=side,B=0,C=持续毫秒槽位,ext=高16位x|低16位y(packed) Img, // ABx : R[A] = 图像标签匹配置信度,标签名 = 常量池[Bx] Rand, // iABC: R[A] = rand() % R[B](B=0→0;B<0→错误,对齐 Random.Next) + ForStep, // iABC: FOR 快速路径——R[A]==R[B] → 落入下一块(出循环);否则 R[A]+=1 跳 C 目标(回 body) + CmpJ, // iABC+J: 比较跳转融合(P2′,Lua LE/EQI 模式)——R[A] kind R[B] 成立 → 跳 Jump; + // C=kind:typeBlock*6+op(0-5 i32 / 6-11 u32 / 12-17 f64 / 18-23 i64;op = Eq,Neq,Lt,Le,Gt,Ge) } /// @@ -97,16 +100,17 @@ public enum EcsOpcode : byte /// CallN 的 EXT 字带上 0x80000000 旗标时,低 31 位 = 编号,直接双端分发,不经原生名表。 /// 语义在宿主参考实现(C# EcxHost.Syscall / C harness 桩),引擎只做取号分发; /// 文件实现转发宿主时使用 中的规范名(宿主委托签名不变)。 -/// ABI 分配规则:新增追加号段、废弃不回收(VmSemanticContract §四)。 +/// ABI 分配规则:新增追加号段、废弃不回收(AbiContractTests 锁定)。 /// public static class EcsSyscall { /// CallN EXT 旗标:置位 = syscall 编号调用(与 Call 的导入标记共用位,操作码不同不冲突)。 public const uint CallFlag = 0x80000000u; - /// 编码 ABI 修订号(进缓存键 ProductFingerprint;编号分配/编码语义变化时递增)。 - /// 1 = 名表原生时代;2 = L2 全集 syscall 编号化。 - public const int AbiRevision = 2; + /// 镜像格式修订号(缓存键成分;格式语义变化时改值,无兼容梯—— + /// 全新实现不做历史版本分级)。当前 = 每函数寄存器栈窗口 + 帧内固定 RET/ARG/STAGING 布局 + /// ()+ 定长指令编码 + 无接收哨兵。 + public const int AbiRevision = 1; // ---- 文件族(1..9 连续,IsFileFamily 依赖)---- public const int FWrite = 1; @@ -126,7 +130,7 @@ public static class EcsSyscall public const int App = 13; public const int Time = 14; public const int Beep = 15; - public const int Amiibo = 16; + public const int Amiibo = 16; // v2.3:Amiibo 槽位选择(0–19,宿主判越界静默;S-13) public const int OcrConf = 17; /// 文件族上界(含)。 @@ -168,10 +172,10 @@ public static bool TryGetTarget(string name, out uint target) } /// -/// 镜像特征需求掩码(镜像头保留位 u16 @0x0A,docs/VM2.md §9.1):表达「本镜像需要宿主提供 -/// 的高级能力」,加载规则 `host_feats & image_feats != image_feats` → 拒跑。IL 位由 flags.I -/// 投影(旧镜像掩码缺省 = 仅 IL 位,兼容);加载期 IL 越界 → ECS_ERR_IL(既有码),其余越界 -/// → ECS_ERR_FEAT。 +/// 镜像特征需求掩码(镜像头 feats u16 @0x08,docs/VM2.md §9.1):表达「本镜像使用到 +/// 的高级能力」。S-21 起 feats 为元信息——加载器缺省不据此拒跑,能力缺失按 +/// EcsCapabilityDefaults 缺省值表降级;宿主 strict_caps 置位时恢复响亮校验 +/// (IL → ECS_ERR_IL,其余缺位 → ECS_ERR_FEAT)。烧录前预检(McuBytecodeDelivery)仍消费本掩码。 /// public static class EcsImageFeatures { @@ -179,7 +183,8 @@ public static class EcsImageFeatures public const uint Capture = 0x2; // 可达采集洞(__CAPTURE__ 系) public const uint Ffi = 0x4; // EXTERN FFI("库!导出名" 动态原生) public const uint File = 0x8; // 文件族 syscall - public const uint Vision = 0x10; // ONNX 推理实验函数(NET_LOAD/NET_RUN);MCU 参考桩不支持 → 拒跑 + public const uint Vision = 0x10; // ONNX 推理函数(NET_LOAD/NET_RUNH/NET_OUT 句柄族);缺能力宿主按缺省值表降级 + public const uint Audio = 0x20; // AUDIO_LOAD/AUDIO_SCORE/AUDIO_UNLOAD;缺能力必须拒跑,不能降级为 0 分 } /// @@ -240,4 +245,32 @@ public enum EcsFieldKind : byte internal static class EcsJump { public const int NoTarget = -1; +} + +/// +/// 帧内寄存器布局约定(与 native/ecs_vm.h 的 ECS_REG_* 一一对应,双端手工镜像)。 +/// 帧模型 = Dalvik/Lua 式每函数栈窗口:帧长 = 函数自身寄存器高水位(EcsFunction.NSlots, +/// ≤ ,指令域 u8 是唯一上限——项目增长不再牵动寄存器堆), +/// 调用时在共享值栈上按被调方帧长开窗、返回时弹出。 +/// 帧内布局:[R0 = RET][R1 起 = NParams 个参数][按需 STAGING][分配区 +/// (独占:φ/ForStep/循环携带/编码器临时 → 池区)]。仅 RET/参数入口位置固定; +/// STAGING 容量由本函数的最大出站调用/数组分块需求决定,不另设 7 参数上限。 +/// 生命周期分配(SlotAllocator)负责池化与循环活性独占。 +/// +public static class EcsRegisters +{ + /// 寄存器号上限(指令 A/B/C 域为 u8;Lua 同款天花板)。帧长 ≤ 此值。 + public const int MaxRegisters = 255; + + /// R0 = RET:无显式接收寄存器调用的返回值固定落点(Call 的 C=0)。 + public const int Ret = 0; + + /// R1 起 = 参数窗:Call 从 A 指定的出站窗口深拷贝 NParams 个实参(S-17)。 + public const int ArgBase = 1; + + /// 参数数的编码上限;参数、出站窗口与临时值还须共同满足函数帧预算。 + public const int ArgCount = MaxRegisters - ArgBase; + + /// 常量数组模板的出站分块预算;无数组构建时不预留这些槽位。 + public const int ArrayStagingCount = 16; } \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/EcxDisassembler.cs b/src/EasyCon.Script/Bytecode/EcxDisassembler.cs index 6e7f0cbe..b341d337 100644 --- a/src/EasyCon.Script/Bytecode/EcxDisassembler.cs +++ b/src/EasyCon.Script/Bytecode/EcxDisassembler.cs @@ -3,14 +3,15 @@ namespace EasyCon.Script.Bytecode; /// -/// ECX 镜像反汇编器(调试/CI 黄金快照,docs/VM2.md §10.3)。 +/// 镜像反汇编器(调试/CI 黄金快照,docs/VM2.md §10.3)。 +/// 单流化后直接读解码形态——顺带显示真实槽位(不再有影子流截断的失真)。 /// -public static class EcxDisassembler +internal static class EcxDisassembler { public static string Disassemble(EcxImage image) { var sb = new StringBuilder(); - sb.AppendLine($"; ECX image: {image.Functions.Count} funcs, {image.Consts.Count} consts, " + + sb.AppendLine($"; ECX1 image: {image.Functions.Count} funcs, {image.Consts.Count} consts, " + $"{image.Structs.Count} structs, {image.Globals.Count} globals, {image.Natives.Count} natives, " + $"entry={image.Entry} keyAction={image.KeyAction} needIL={image.NeedIL}"); sb.AppendLine($"; modules: {string.Join(", ", image.Modules)}"); @@ -30,53 +31,43 @@ public static string Disassemble(EcxImage image) { sb.AppendLine(); sb.AppendLine($"func [{image.Functions.IndexOf(f)}] {f.Name} (module={f.Module}, params={f.NParams}, slots={f.NSlots}, hasret={f.HasReturn}):"); - for (int i = 0; i < f.Code.Count;) - { - var op = (EcsOpcode)(f.Code[i] & 0xFF); - sb.AppendLine($" {i,4}: {FormatInstruction(image, f.Code, i, out int words)}"); - i += words; - } + for (int i = 0; i < f.Instructions.Count; i++) + sb.AppendLine($" {i,4}: {FormatInstruction(image, f.Instructions[i])}"); } return sb.ToString(); } - static string FormatInstruction(EcxImage image, List code, int i, out int words) + static string FormatInstruction(EcxImage image, in EcsInstruction ins) { - var word = code[i]; - var op = (EcsOpcode)(word & 0xFF); - int a = (int)((word >> 8) & 0xFF); - int b = (int)((word >> 16) & 0xFF); - int c = (int)((word >> 24) & 0xFF); - uint ext = 0; - bool hasExt = EcsFormat.Get(op) == EcsInsFormat.Ext; - if (hasExt && i + 1 < code.Count) - ext = code[i + 1]; - words = EcsFormat.WordCount(op); + int a = ins.A, b = ins.B, c = ins.C; + uint ext = ins.Ext; + string extText = ins.HasExt ? $" {FormatExt(ins.Op, ext, image)}" : ""; + string tail = ins.IsJump ? $" {ins.Jump:+0;-0}" : extText; - string extText = hasExt ? $" {FormatExt(op, ext, image)}" : ""; - - return op switch + return ins.Op switch { - EcsOpcode.LoadI => $"{op} r{a}, {Sign16(word >> 16)}", - EcsOpcode.LoadK => $"{op} r{a}, K[{b | (c << 8)}]{ConstText(image, b | (c << 8))}", - EcsOpcode.LoadG => $"{op} r{a}, G[{b | (c << 8)}]{GlobalText(image, b | (c << 8))}", - EcsOpcode.StoreG => $"{op} G[{b | (c << 8)}], r{a}{GlobalText(image, b | (c << 8))}", - EcsOpcode.NewArrE => $"{op} r{a}, type={b | (c << 8)}", - EcsOpcode.NewSt => $"{op} r{a}, struct[{b | (c << 8)}]", - EcsOpcode.Img => $"{op} r{a}, {ConstText(image, b | (c << 8))}", - EcsOpcode.WaitI => $"{op} {b | (c << 8)}ms", - EcsOpcode.KeyI => $"{op} key={a}, {b | (c << 8)}ms", - EcsOpcode.Jmp => $"{op} {Sign24(word >> 8):+0;-0}", - EcsOpcode.Jpt => $"{op} r{a}, {Sign16(word >> 16):+0;-0}", - EcsOpcode.Jpf => $"{op} r{a}, {Sign16(word >> 16):+0;-0}", - EcsOpcode.Call => $"{op} args=r{a}..+{b}, recv={(c == 255 ? "-" : $"r{c}")}, func[{ext}]{FuncText(image, ext)}", - EcsOpcode.CallN => $"{op} args=r{a}..+{b}, recv={(c == 255 ? "-" : $"r{c}")}, {CallNTargetText(image, ext)}", - EcsOpcode.Slice => $"{op} r{a}, r{b}, r{c}, end={(ext == 0xFFFFFFFF ? "-" : $"r{ext}")}", - EcsOpcode.GetFI => $"{op} r{a}, r{b}.field[{c}], idx=r{ext}", - EcsOpcode.PutFI => $"{op} r{b}.field[{c}] = r{a}, idx=r{ext}", - EcsOpcode.StickP => $"{op} side={a}, ({b},{c}), {ext}ms", - EcsOpcode.StickPv => $"{op} side={a}, dur=r{c}, xy=({ext & 0xFF},{(ext >> 16) & 0xFF})", - _ => $"{op} r{a}, r{b}, r{c}", + EcsOpcode.LoadI => $"{ins.Op} r{a}, {b}", + EcsOpcode.LoadK => $"{ins.Op} r{a}, K[{b}]{ConstText(image, b)}", + EcsOpcode.LoadG => $"{ins.Op} r{a}, G[{b}]{GlobalText(image, b)}", + EcsOpcode.StoreG => $"{ins.Op} G[{b}], r{a}{GlobalText(image, b)}", + EcsOpcode.NewArrE => $"{ins.Op} r{a}, type={b}", + EcsOpcode.NewSt => $"{ins.Op} r{a}, struct[{b}]", + EcsOpcode.Img => $"{ins.Op} r{a}, {ConstText(image, b)}", + EcsOpcode.WaitI => $"{ins.Op} {ins.Ext}ms", + EcsOpcode.KeyI => $"{ins.Op} key={a}, {ins.Ext}ms", + EcsOpcode.Jmp => $"{ins.Op}{tail}", + EcsOpcode.ForStep => $"{ins.Op} r{a}, limit=r{b}, dst=r{c}, exit={ins.Jump:+0;-0}", + EcsOpcode.Jpt => $"{ins.Op} r{a}{tail}", + EcsOpcode.Jpf => $"{ins.Op} r{a}{tail}", + EcsOpcode.CmpJ => $"{ins.Op} r{a}, r{b}, kind={c}, {ins.Jump:+0;-0}", + EcsOpcode.Call => $"{ins.Op} args=r{a}..+{b}, recv={(c < 0 ? "-" : $"r{c}")}, func[{ext}]{FuncText(image, ext)}", + EcsOpcode.CallN => $"{ins.Op} args=r{a}..+{b}, recv={(c < 0 ? "-" : $"r{c}")}, {CallNTargetText(image, ext)}", + EcsOpcode.Slice => $"{ins.Op} r{a}, r{b}, r{c}, end={(ext == 0xFFFFFFFF ? "-" : $"r{ext}")}", + EcsOpcode.GetFI => $"{ins.Op} r{a}, r{b}.field[{c}], idx=r{ext}", + EcsOpcode.PutFI => $"{ins.Op} r{b}.field[{c}] = r{a}, idx=r{ext}", + EcsOpcode.StickP => $"{ins.Op} side={a}, ({b},{c}), {ext}ms", + EcsOpcode.StickPv => $"{ins.Op} side={a}, dur=r{c}, xy=({ext & 0xFF},{(ext >> 16) & 0xFF})", + _ => $"{ins.Op} r{a}, r{b}, r{c}{extText}", }; } @@ -92,9 +83,6 @@ static string FormatInstruction(EcxImage image, List code, int i, out int _ => $"0x{ext:X}", }; - static int Sign16(uint v) => (int)(ushort)v << 16 >> 16; - static int Sign24(uint v) => (int)(v & 0xFFFFFF) << 8 >> 8; - static string ConstText(EcxImage image, int idx) => idx < image.Consts.Count ? $" ; {image.Consts[idx]}" : " ; ?const"; diff --git a/src/EasyCon.Script/Bytecode/EcxInterpreter.cs b/src/EasyCon.Script/Bytecode/EcxInterpreter.cs index b3886164..39df5713 100644 --- a/src/EasyCon.Script/Bytecode/EcxInterpreter.cs +++ b/src/EasyCon.Script/Bytecode/EcxInterpreter.cs @@ -1,13 +1,14 @@ using EasyCon.Script.Runtime; using EasyCon.Script.Symbols; +using System.Runtime.InteropServices; using System.Text; namespace EasyCon.Script.Bytecode; /// -/// 模拟解释器宿主:与 C VM 的 ecs_host vtable 同构(docs/VmSemanticContract.md §四)。 +/// 模拟解释器宿主:与 C VM 的 ecs_host vtable 同构。 /// -/// 平台能力位(对齐 ecs_vm.h 的 ECS_CAP_*;MODULE_DESIGN.md §5 注入模式)。 +/// 平台能力位(对齐 ecs_vm.h 的 ECS_CAP_*)。 [Flags] public enum EcsCaps { @@ -67,6 +68,7 @@ public sealed class EcxHost public Func Rand { get; set; } = _ => 0; public Func TimeMs { get; set; } = () => 0; public Action Beep { get; set; } = (_, _) => { }; + /// S-13 AMIIBO:槽位选择回调(0–19 合法,越界宿主静默——参考实现已判)。 public Action Amiibo { get; set; } = _ => { }; /// FWRITE stdout 行断协议的落点(message 已按协议剥尾、newline 已解析)。 public Action Print { get; set; } = (_, _) => { }; @@ -82,6 +84,12 @@ public sealed class EcxHost /// (行断协议 + Caps 门控 + 文件族转发 Native)。返回 null = 未实现。 /// public Func? Syscall { get; set; } + /// + /// S-21 strict_caps(默认 off):off = 能力缺失按 缺省值表 + /// 降级(feats 仅元信息);on = 恢复响亮行为(L2/L3 miss 即 ERR_NOSUCHNATIVE, + /// 图像标签 ERR_IL)——迁移保底、诊断模式、「同宿主能力集」对拍严格档。 + /// + public bool StrictCaps { get; set; } public string[] Args { get; set; } = []; public string AppDir { get; set; } = ""; @@ -129,12 +137,20 @@ public EcxHost() return TaggedValue.FromInt(TimeMs()); case EcsSyscall.OcrConf: return Native?.Invoke(EcsSyscall.Names[id], args, ctx) ?? TaggedValue.FromInt(0); + case EcsSyscall.FOpen: + case EcsSyscall.FClose: + case EcsSyscall.FEof: + case EcsSyscall.FileExists: + // 文件族其余:句柄/存在性语义在 BuiltinMap(IFileSystem);缺 File 能力 → 缺省值表 + return Caps.HasFlag(EcsCaps.File) + ? Native?.Invoke(EcsSyscall.Names[id], args, ctx) + : EcsCapabilityDefaults.GetSyscallDefault(id, args, ctx); case EcsSyscall.Beep: if (Caps.HasFlag(EcsCaps.Beep)) Beep(args[0].I32, args[1].I32); return TaggedValue.Void; // 无 Beep 能力:静默忽略 case EcsSyscall.Amiibo: - if (args[0].I32 <= 9) // S-13:n>9 静默忽略 + if (args[0].I32 is >= 0 and <= 19) // S-13:槽位 0–19,越界静默(宿主内判) Amiibo(args[0].I32); return TaggedValue.Void; default: @@ -194,7 +210,7 @@ public void EnableRecording() } /// -/// ECX 模拟解释器:字节码语义的 C# 参考实现——语义契约见 docs/VmSemanticContract.md(S-01..S-18)。 +/// ECX 模拟解释器:字节码语义的 C# 参考实现——双端语义由 corpus/FullChain 对拍测试锁定。 /// 在 C 虚拟机落地前承担"模拟验证";C VM 完成后作为交叉对比的第二方。 /// public sealed class EcxInterpreter @@ -232,7 +248,6 @@ sealed class Frame readonly List _framePool = new(); // 弹帧回收池:Slots 容量只增,出租时 Array.Clear 复位 readonly TaggedValue[] _globals; readonly EcxNativeContext _nativeCtx; - TaggedValue[]? _internedStrings; // 常量池串驻留(下标对齐 Consts;缓存持常驻引用) CancellationToken _token; // 当前 Run 的取消令牌(Step 装载) int _steps; int _budget = 1_000_000; @@ -273,10 +288,24 @@ internal bool HeapCountsConsistent public const int OK = 0; public const int YIELD = 1; public const int CANCELLED = 2; - public const int ERR_DIVZERO = 8; - public const int ERR_INDEX = 7; + // 错误码 ABI 与 native/ecs_vm.h 结果码枚举一致(VmHeaderContractTests 锁定头文件侧) + public const int ERR_IMAGE = 3; + public const int ERR_OPCODE = 4; + public const int ERR_SLOT = 5; public const int ERR_TYPE = 6; + public const int ERR_INDEX = 7; + public const int ERR_DIVZERO = 8; + public const int ERR_DEPTH = 9; public const int ERR_NOSUCHNATIVE = 10; + public const int ERR_HOST = 11; + public const int ERR_OOM = 12; + public const int ERR_IL = 13; + public const int ERR_FEAT = 14; + + /// 调用深度上限(与 C 侧 ECS_MAX_CALL_DEPTH 一致:深递归双端同样报 ERR_DEPTH, + /// 避免同一镜像在 MCU 报错而 PC 端 OOM 的对拍分裂)。帧为每函数栈窗口(典型高水位 + /// 20–40 槽 ≈ 0.3–0.6KB),64 深度在 C VM 帧段(128KB)内余量充足;深递归由尾递归消除(TRE)兜底。 + public const int MaxCallDepth = 64; EcxInterpreter(EcxImage image, EcxHost host) { @@ -358,8 +387,11 @@ int Store(object obj) return h; } - string Str(int h) => h > 0 && _heap[h] is string s ? s : throw new SimError(ERR_TYPE, "解引用非字符串句柄"); - string? StrOrNull(int h) => h > 0 && _heap[h] is string s ? s : null; + /// 静态(pinned)常量串的数据源(S-20):直读镜像常量池,零拷贝零分配(对齐 C 侧 str_or_null)。 + string? StaticStr(int kx) => kx >= 0 && kx < _image.Consts.Count ? _image.Consts[kx].Str ?? "" : null; + + string Str(int h) => h < 0 ? StaticStr(h & 0x7FFFFFFF) ?? "" : h > 0 && _heap[h] is string s ? s : throw new SimError(ERR_TYPE, "解引用非字符串句柄"); + string? StrOrNull(int h) => h < 0 ? StaticStr(h & 0x7FFFFFFF) : h > 0 && _heap[h] is string s ? s : null; SimArray Arr(int h) => _heap[h] as SimArray ?? throw new SimError(ERR_TYPE, "解引用非数组句柄"); SimStruct St(int h) => _heap[h] as SimStruct ?? throw new SimError(ERR_TYPE, "解引用非结构体句柄"); @@ -564,8 +596,9 @@ string ToStringNested(TaggedValue v) // ---- 主循环 ---- - /// 帧出租:池空新建;复用时槽位 Array.Clear 复位为 Void(StoreFresh「无旧值」直写契约), - /// 容量不足则扩容(容量只增不缩,递归峰值即池上限——池随弹帧回收,天然 ≤ 峰值深度)。 + /// 帧出租(每函数栈窗口):帧长 = 函数寄存器高水位(NSlots ≤255), + /// 池空新建;复用时槽位 Array.Clear 复位为 Void(StoreFresh「无旧值」直写契约), + /// 容量不足则扩容(容量只增,峰值深度即池上限——池随弹帧回收)。 Frame RentFrame(int nslots) { if (_framePool.Count > 0) @@ -573,7 +606,7 @@ Frame RentFrame(int nslots) var f = _framePool[^1]; _framePool.RemoveAt(_framePool.Count - 1); if (f.Slots.Length < nslots) - f.Slots = new TaggedValue[nslots]; + f.Slots = new TaggedValue[nslots]; // 新数组自带零值 else Array.Clear(f.Slots); return f; @@ -581,20 +614,11 @@ Frame RentFrame(int nslots) return new Frame { Slots = new TaggedValue[nslots] }; } - /// 常量池串驻留:同一串常量全镜像生命周期仅分配一次堆串(缓存自身持引用,句柄永不归零)。 - /// 返回值为缓存借用,接收槽经 retain 补引用——可观察语义不变: - /// 串不可变、EqS 按内容比较(EcxInterpreter.cs EqS case)。 - TaggedValue InternedString(int kx) - { - var cache = _internedStrings ??= new TaggedValue[_image.Consts.Count]; - var v = cache[kx]; - if (v.Tag == EcsTag.String) - return v; - v = NewString(_image.Consts[kx].Str ?? ""); - _refCounts[v.Handle]++; // 缓存常驻引用(Store 的出生引用归缓存) - cache[kx] = v; - return v; - } + /// 常量池串 → 静态(pinned)值(契约 S-20 / docs/ZeroAllocVm.md §2):**不再新建堆对象**, + /// 直接返回指向镜像常量池的句柄(bit31 置位);不参与 RC,因此无需驻留表、无需补引用。 + /// 语义不变:串不可变、EqS/Cont/GetI/Len/Cat/TOSTR 一律按内容处理 + /// ( / 同时服务动态与静态句柄)。 + TaggedValue InternedString(int kx) => TaggedValue.FromStaticString(kx); int Step(CancellationToken token) { @@ -611,31 +635,50 @@ int Step(CancellationToken token) if (_token.IsCancellationRequested) return CANCELLED; + // 预算计数局部化:字段读改写 → JIT 寄存器驻留(每指令省 2 次内存访问; + // 预算值 Run 期内不变、_steps 仅本方法读写,回写点 = 切片边界) + var steps = _steps; + var budget = _budget; + // 取指通路局部化(P0b):frame/指令流 span/R/pc 提升为循环局部,JIT 可驻留寄存器, + // 每指令省 4 次 _frames/frame 字段链解引用。不变量:指令流运行期只读(链接后不可变), + // span 恒指向当前帧函数的指令列表;换帧点(Call/Ret/Ret0)置 frameSwitched 由循环头重载; + // 切片出口(YIELD/宿主后 CANCELLED)写回 frame.Pc——Run 吸收 YIELD 后重入 Step 续跑。 + Frame frame = null!; + ReadOnlySpan code = default; + TaggedValue[] R = null!; + int pc = 0; + bool frameSwitched = true; while (true) { + if (frameSwitched) + { + frame = _frames[^1]; + code = CollectionsMarshal.AsSpan(frame.Fn.Instructions); + R = frame.Slots; + pc = frame.Pc; + frameSwitched = false; + } + // 取消检查合并:每指令只做预算计数(1 分支);token 在入口、预算边界与宿主调用后检查 // (纯计算最坏 1M 步 ≈ 十几毫秒感知;等待/按键型脚本在宿主调用返回后立即感知) - if (++_steps >= _budget) + if (++steps >= budget) { + frame.Pc = pc; _steps = 0; if (_token.IsCancellationRequested) return CANCELLED; return YIELD; } - var frame = _frames[^1]; - var code = frame.Fn.Code; - var ins = code[frame.Pc++]; - var op = (EcsOpcode)(ins & 0xFF); - int a = (int)((ins >> 8) & 0xFF); - int b = (int)((ins >> 16) & 0xFF); - int c = (int)((ins >> 24) & 0xFF); - // EXT 后随数据字按 EcsFormat 表预取(权威集合/语义见 ExtKind):执行 case 只消费 - // ext,不再各自负责「记得读后随字」——漏读会把数据误读为下一条指令(历史 F4 缺陷形态)。 - uint ext = 0; - if (EcsFormat.ExtWords(op) > 0) - ext = code[frame.Pc++]; - var R = frame.Slots; + int instructionPc = pc; + // 单一分派臂(SingleStreamFormat §2):解码形态是唯一指令表示, + // 不再有 Code/PcCode 双流与宽窄两条取指路径。 + var ins = code[pc++]; + var op = ins.Op; + int a = ins.A; + int b = ins.B; + int c = ins.C; + uint ext = ins.Ext; try { @@ -645,11 +688,11 @@ int Step(CancellationToken token) // ---- 常量与移动 ---- case EcsOpcode.LoadI: - StoreFresh(ref R[a], TaggedValue.FromInt((short)(ins >> 16))); + StoreFresh(ref R[a], TaggedValue.FromInt(b)); break; case EcsOpcode.LoadK: { - var kx = b | (c << 8); + int kx = b; var k = _image.Consts[kx]; if (k.Tag == EcsTag.String) MoveToSlot(ref R[a], InternedString(kx)); // 驻留串:借用 + retain,槽旧值照常释放 @@ -674,39 +717,72 @@ int Step(CancellationToken token) StoreFresh(ref R[a], DeepCopyCopyOnWrite(R[b])); break; case EcsOpcode.LoadG: - MoveToSlot(ref R[a], _globals[b | (c << 8)]); + MoveToSlot(ref R[a], _globals[b]); break; case EcsOpcode.StoreG: { var nv = DeepCopyCopyOnWrite(R[a]); - var gx = b | (c << 8); + int gx = b; Release(_globals[gx]); _globals[gx] = nv; break; } case EcsOpcode.Jmp: - frame.Pc += Sign24(ins >> 8); + pc += ins.Jump; break; case EcsOpcode.Jpt: - frame.Pc += R[a].I32 != 0 ? Sign16(ins >> 16) : 0; + pc += R[a].I32 != 0 ? ins.Jump : 0; break; case EcsOpcode.Jpf: - frame.Pc += R[a].I32 == 0 ? Sign16(ins >> 16) : 0; + pc += R[a].I32 == 0 ? ins.Jump : 0; break; + // ---- 比较跳转融合(P2′):R[a] kind R[b] 成立 → 跳 Jump;否则落入后继 ---- + case EcsOpcode.CmpJ: + { + bool taken; + switch (c / 6) + { + case 0: { int l = R[a].I32, r = R[b].I32; taken = (c % 6) switch { 0 => l == r, 1 => l != r, 2 => l < r, 3 => l <= r, 4 => l > r, _ => l >= r }; break; } + case 1: { uint l = (uint)R[a].I32, r = (uint)R[b].I32; taken = (c % 6) switch { 0 => l == r, 1 => l != r, 2 => l < r, 3 => l <= r, 4 => l > r, _ => l >= r }; break; } + case 2: { double l = R[a].F64, r = R[b].F64; taken = (c % 6) switch { 0 => l == r, 1 => l != r, 2 => l < r, 3 => l <= r, 4 => l > r, _ => l >= r }; break; } + default: { long l = R[a].I64, r = R[b].I64; taken = (c % 6) switch { 0 => l == r, 1 => l != r, 2 => l < r, 3 => l <= r, 4 => l > r, _ => l >= r }; break; } + } + if (taken) + pc += ins.Jump; + break; + } + + // ---- FOR 快速路径:tmp=i+1 写入 R[c];tmp>limit → 跳 Jump(出循环,$i 保持 ==upper);否则落入后继 ---- + // 信任字节码:跳转目标不校验 + case EcsOpcode.ForStep: + { + int next = R[a].I32 + 1; + StoreFresh(ref R[c], TaggedValue.FromInt(next)); + if (next > R[b].I32) + pc = instructionPc + 1 + ins.Jump; + break; + } + // ---- 调用 ---- + // 每函数栈窗口帧:实参从调用方 STAGING(A)深拷贝播种进被调方 + // R1 起的参数窗(S-17 语义);C = 接收寄存器(0 = RET 固定落点) case EcsOpcode.Call: { uint target = ext; var callee = _image.Functions[(int)target]; + if (_frames.Count >= MaxCallDepth) + throw new SimError(ERR_DEPTH, $"调用深度超过上限 {MaxCallDepth}"); var nf = RentFrame(callee.NSlots); nf.Fn = callee; - nf.RetSlot = c == 255 ? -1 : c; // C=255:无接收槽(结果未使用的调用) + nf.RetSlot = c; nf.Pc = 0; for (int i = 0; i < b; i++) - StoreFresh(ref nf.Slots[i], DeepCopyCopyOnWrite(R[a + i])); // S-17 实参(COW:唯一引用移交;池出租槽已清零,StoreFresh 恒等价) + StoreFresh(ref nf.Slots[EcsRegisters.ArgBase + i], DeepCopyCopyOnWrite(R[a + i])); // S-17 实参(COW:唯一引用移交) + frame.Pc = pc; // 调用者续跑点写回(Ret 时经重载读回) _frames.Add(nf); + frameSwitched = true; break; } case EcsOpcode.CallN: @@ -719,58 +795,68 @@ int Step(CancellationToken token) if ((target & EcsSyscall.CallFlag) != 0) { // L2:编号 syscall,语义在宿主参考实现(VM 核纯调度) - var handler = _host.Syscall; - ret = handler is { } h && h((int)(target & 0x7FFFFFFFu), args, _nativeCtx) is { } v - ? v - : throw new SimError(ERR_NOSUCHNATIVE, $"syscall 未实现: 编号 {target & 0x7FFFFFFFu}"); + int id = (int)(target & 0x7FFFFFFFu); + var resolved = _host.Syscall?.Invoke(id, args, _nativeCtx); + if (resolved is null) + { + // S-21:miss → 缺省值表降级;strict 或表外编号 → 响亮 + resolved = EcsCapabilityDefaults.GetSyscallDefault(id, args, _nativeCtx); + if (resolved is null || _host.StrictCaps) + throw new SimError(ERR_NOSUCHNATIVE, $"syscall 未实现: 编号 {id}"); + } + ret = resolved.Value; } else { - if (target >= (uint)_image.Natives.Count) - throw new SimError(ERR_NOSUCHNATIVE, $"原生索引越界 {target}"); - ret = HostNative(_image.Natives[(int)target].Name, args); // L3 名表路径:FFI/采集洞/ENCODE/JQ + ret = HostNative(_image.Natives[(int)target].Name, args); // L3 名表路径:FFI/采集洞/ENCODE/JQ(信任字节码,目标不校验) } - if (c != 255) - StoreFresh(ref R[c], ret); // C=255:无接收槽;原生返回值恒为新建对象/标量(EcxNativeContext 契约),出生引用即接收槽引用 + if (c >= 0) + StoreFresh(ref R[c], ret); // 接收寄存器(0 = RET 固定落点);原生返回值恒为新建对象/标量(EcxNativeContext 契约),出生引用即接收槽引用 if (_token.IsCancellationRequested) + { + frame.Pc = pc; return CANCELLED; // 宿主调用后即时取消(原生内含 PRINT/READ/WAIT 类长延迟) + } break; } case EcsOpcode.Ret: { - var callee = _frames[^1]; - var val = callee.Slots[a]; + var val = R[a]; if (_frames.Count == 1) { Result = DeepCopyCopyOnWrite(val); // 入口返回值(顶层 RETURN):Result 持独立引用,跨弹帧存活 - PopFrame(callee); + PopFrame(frame); return OK; } - TaggedValue copy = default; - var hasCopy = callee.RetSlot >= 0; - if (hasCopy) - copy = DeepCopyCopyOnWrite(val); // S-17 返回(COW:唯一引用移交;先于弹帧:val 所属槽位将被释放) - PopFrame(callee); - if (hasCopy) - StoreFresh(ref _frames[^1].Slots[callee.RetSlot], copy); + TaggedValue copy = DeepCopyCopyOnWrite(val); // S-17 返回(COW:唯一引用移交;先于弹帧:val 所属槽位将被释放) + int retSlot = frame.RetSlot; + PopFrame(frame); + StoreFresh(ref _frames[^1].Slots[retSlot], copy); + frameSwitched = true; break; } case EcsOpcode.Ret0: - PopFrame(_frames[^1]); + PopFrame(frame); if (_frames.Count == 0) return OK; + frameSwitched = true; break; default: - if (ExecOther(op, ins, ext, R)) + if (ExecOther(op, a, b, c, ext, R)) + { + frame.Pc = pc; return CANCELLED; + } break; } } catch (SimError e) { + if (e.Code == OK) + return OK; // Halt 正常停机:不留错误现场(与 C VM 对齐) ErrorFunc = _image.Functions.IndexOf(frame.Fn); - ErrorPc = frame.Pc - 1; // 取指后已自增,回退到失败指令下标(与 C VM 对齐) + ErrorPc = instructionPc; return e.Code; } } @@ -778,12 +864,8 @@ int Step(CancellationToken token) /// 非控制流/非调用的算术与数据指令。返回 true = 本指令调用了宿主且取消已请求, /// 调用方立即终止(等待/按键类长延迟宿主调用的取消感知点)。 - bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) + bool ExecOther(EcsOpcode op, int a, int b, int c, uint ext, TaggedValue[] R) { - int a = (int)((ins >> 8) & 0xFF); - int b = (int)((ins >> 16) & 0xFF); - int c = (int)((ins >> 24) & 0xFF); - switch (op) { // ---- 算术(S-02/S-05)---- @@ -820,28 +902,31 @@ bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) case EcsOpcode.BnotI: StoreFresh(ref R[a], TaggedValue.FromInt(~R[b].I32)); break; // ---- 比较 ---- - case EcsOpcode.EqI: CmpI(R, a, b, c, x => x == 0); break; - case EcsOpcode.LtI: CmpI(R, a, b, c, x => x < 0); break; - case EcsOpcode.LeI: CmpI(R, a, b, c, x => x <= 0); break; - case EcsOpcode.GtI: CmpI(R, a, b, c, x => x > 0); break; - case EcsOpcode.GeI: CmpI(R, a, b, c, x => x >= 0); break; - case EcsOpcode.EqU: CmpU(R, a, b, c, x => x == 0); break; - case EcsOpcode.LtU: CmpU(R, a, b, c, x => x < 0); break; - case EcsOpcode.LeU: CmpU(R, a, b, c, x => x <= 0); break; - case EcsOpcode.GtU: CmpU(R, a, b, c, x => x > 0); break; - case EcsOpcode.GeU: CmpU(R, a, b, c, x => x >= 0); break; - case EcsOpcode.EqD: CmpD(R, a, b, c, x => x == 0); break; - case EcsOpcode.LtD: CmpD(R, a, b, c, x => x < 0); break; - case EcsOpcode.LeD: CmpD(R, a, b, c, x => x <= 0); break; - case EcsOpcode.GtD: CmpD(R, a, b, c, x => x > 0); break; - case EcsOpcode.GeD: CmpD(R, a, b, c, x => x >= 0); break; - case EcsOpcode.EqL: CmpL(R, a, b, c, x => x == 0); break; - case EcsOpcode.LtL: CmpL(R, a, b, c, x => x < 0); break; - case EcsOpcode.LeL: CmpL(R, a, b, c, x => x <= 0); break; - case EcsOpcode.GtL: CmpL(R, a, b, c, x => x > 0); break; - case EcsOpcode.GeL: CmpL(R, a, b, c, x => x >= 0); break; + // 比较族直写(P0):委托间接调用 + CompareTo 是热点路径纯开销; + // 浮点直接用 C# 比较符(NaN → false),与 C VM 的 C 语义一致 + //(原 CmpD 的 CompareTo 把 NaN 排在最前,与 C VM 存在潜在分歧) + case EcsOpcode.EqI: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I32 == R[c].I32)); break; + case EcsOpcode.LtI: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I32 < R[c].I32)); break; + case EcsOpcode.LeI: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I32 <= R[c].I32)); break; + case EcsOpcode.GtI: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I32 > R[c].I32)); break; + case EcsOpcode.GeI: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I32 >= R[c].I32)); break; + case EcsOpcode.EqU: StoreFresh(ref R[a], TaggedValue.FromBool((uint)R[b].I32 == (uint)R[c].I32)); break; + case EcsOpcode.LtU: StoreFresh(ref R[a], TaggedValue.FromBool((uint)R[b].I32 < (uint)R[c].I32)); break; + case EcsOpcode.LeU: StoreFresh(ref R[a], TaggedValue.FromBool((uint)R[b].I32 <= (uint)R[c].I32)); break; + case EcsOpcode.GtU: StoreFresh(ref R[a], TaggedValue.FromBool((uint)R[b].I32 > (uint)R[c].I32)); break; + case EcsOpcode.GeU: StoreFresh(ref R[a], TaggedValue.FromBool((uint)R[b].I32 >= (uint)R[c].I32)); break; + case EcsOpcode.EqD: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].F64 == R[c].F64)); break; + case EcsOpcode.LtD: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].F64 < R[c].F64)); break; + case EcsOpcode.LeD: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].F64 <= R[c].F64)); break; + case EcsOpcode.GtD: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].F64 > R[c].F64)); break; + case EcsOpcode.GeD: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].F64 >= R[c].F64)); break; + case EcsOpcode.EqL: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I64 == R[c].I64)); break; + case EcsOpcode.LtL: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I64 < R[c].I64)); break; + case EcsOpcode.LeL: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I64 <= R[c].I64)); break; + case EcsOpcode.GtL: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I64 > R[c].I64)); break; + case EcsOpcode.GeL: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I64 >= R[c].I64)); break; case EcsOpcode.EqS: StoreFresh(ref R[a], TaggedValue.FromBool(string.Equals(StrOrNull(R[b].Handle) ?? "", StrOrNull(R[c].Handle) ?? "", StringComparison.Ordinal))); break; - case EcsOpcode.EqP: CmpL(R, a, b, c, x => x == 0); break; + case EcsOpcode.EqP: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I64 == R[c].I64)); break; case EcsOpcode.Not: StoreFresh(ref R[a], TaggedValue.FromBool(R[b].I32 == 0)); break; case EcsOpcode.NegI: StoreFresh(ref R[a], TaggedValue.FromInt(-R[b].I32)); break; @@ -861,7 +946,7 @@ bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) break; } case EcsOpcode.NewArrE: // ABx:元素类型码在 Bx - StoreFresh(ref R[a], TaggedValue.FromArrayHandle(Store(new SimArray { ElemTag = (byte)(b | (c << 8)) }))); + StoreFresh(ref R[a], TaggedValue.FromArrayHandle(Store(new SimArray { ElemTag = (byte)b }))); break; case EcsOpcode.GetI: { @@ -992,7 +1077,7 @@ bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) // ---- 结构体 ---- case EcsOpcode.NewSt: // ABx:类型表索引在 Bx { - var layout = _image.Structs[b | (c << 8)]; + var layout = _image.Structs[b]; var st = new SimStruct { Layout = layout, Slots = new TaggedValue[layout.SlotCount] }; ZeroFill(layout, st.Slots); StoreFresh(ref R[a], TaggedValue.FromStructHandle(Store(st))); @@ -1022,9 +1107,9 @@ bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) } // ---- 域操作(宿主调用:返回取消状态,取消感知点)---- - case EcsOpcode.WaitI: _host.WaitMs(b | (c << 8)); return _token.IsCancellationRequested; + case EcsOpcode.WaitI: _host.WaitMs(unchecked((int)ext)); return _token.IsCancellationRequested; case EcsOpcode.WaitV: _host.WaitMs(R[a].I32); return _token.IsCancellationRequested; - case EcsOpcode.KeyI: _host.Key(a, b | (c << 8)); return _token.IsCancellationRequested; + case EcsOpcode.KeyI: _host.Key(a, unchecked((int)ext)); return _token.IsCancellationRequested; case EcsOpcode.KeyV: _host.Key(a, R[b].I32); return _token.IsCancellationRequested; case EcsOpcode.KeySt: _host.KeyState(a, b); return _token.IsCancellationRequested; case EcsOpcode.StickSet: _host.StickSet(a, b, c); return _token.IsCancellationRequested; @@ -1040,7 +1125,9 @@ bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) } case EcsOpcode.Img: // ABx:标签名 = 常量池[Bx](EcsOpcode.cs 注释为权威) { - var name = _image.Consts[b | (c << 8)].Str ?? ""; + if (_host.StrictCaps) + throw new SimError(ERR_IL, $"图像标签能力缺失(strict_caps): {_image.Consts[b].Str}"); + var name = _image.Consts[b].Str ?? ""; StoreFresh(ref R[a], TaggedValue.FromInt(_host.ImgLabel(name))); return _token.IsCancellationRequested; } @@ -1055,7 +1142,7 @@ bool ExecOther(EcsOpcode op, uint ins, uint ext, TaggedValue[] R) case EcsOpcode.Halt: throw new SimError(OK, "halt"); default: - throw new SimError(4, $"未实现操作码 {op}"); + throw new SimError(ERR_OPCODE, $"未实现操作码 {op}"); } return false; } @@ -1250,15 +1337,6 @@ static void RequireDivisor(TaggedValue v) throw new SimError(ERR_DIVZERO, "整数除零"); } - void CmpI(TaggedValue[] R, int a, int b, int c, Func f) - => StoreFresh(ref R[a], TaggedValue.FromBool(f(R[b].I32.CompareTo(R[c].I32)))); - void CmpU(TaggedValue[] R, int a, int b, int c, Func f) - => StoreFresh(ref R[a], TaggedValue.FromBool(f(((uint)R[b].I32).CompareTo((uint)R[c].I32)))); - void CmpD(TaggedValue[] R, int a, int b, int c, Func f) - => StoreFresh(ref R[a], TaggedValue.FromBool(f(R[b].F64.CompareTo(R[c].F64)))); - void CmpL(TaggedValue[] R, int a, int b, int c, Func f) - => StoreFresh(ref R[a], TaggedValue.FromBool(f(R[b].I64.CompareTo(R[c].I64)))); - bool ValueEquals(TaggedValue x, TaggedValue y) { if (x.Tag != y.Tag) @@ -1276,11 +1354,13 @@ bool ValueEquals(TaggedValue x, TaggedValue y) TaggedValue HostNative(string name, TaggedValue[] args) { - if (_host.Native == null) - throw new SimError(ERR_NOSUCHNATIVE, $"原生函数未注册: {name}"); - if (_host.Native(name, args, _nativeCtx) is { } ret) + // S-21:miss(无处理器/未实现名)→ strict 恢复响亮;非 strict 查缺省值表(表外未知名仍响亮) + if (_host.Native is { } native && native(name, args, _nativeCtx) is { } ret) return ret; - throw new SimError(ERR_NOSUCHNATIVE, $"原生函数未实现: {name}"); + if (_host.StrictCaps) + throw new SimError(ERR_NOSUCHNATIVE, $"原生函数未实现: {name}"); + return EcsCapabilityDefaults.GetNativeDefault(name, _nativeCtx) + ?? throw new SimError(ERR_NOSUCHNATIVE, $"原生函数未注册: {name}"); } /// 堆上下文实现:宿主原生回调读写解释器堆的唯一通道。 @@ -1319,7 +1399,8 @@ public override TaggedValue FromValue(Value v) EcsTag.Bool => ScriptType.Bool, EcsTag.Byte => ScriptType.Byte, EcsTag.UInt => ScriptType.UInt, - EcsTag.Double => ScriptType.UInt64, + EcsTag.Double => ScriptType.Double, + EcsTag.UInt64 => ScriptType.UInt64, EcsTag.String => ScriptType.String, EcsTag.Ptr => ScriptType.Ptr, _ => ScriptType.Int, @@ -1430,8 +1511,6 @@ _ when type.Equals(ScriptType.String) => StrOrNullPub(v), string? StrOrNullPub(TaggedValue v) => v.IsString ? StrOrNull(v.Handle) : null; - static int Sign16(uint v) => (int)(ushort)v << 16 >> 16; - static int Sign24(uint v) => (int)(v & 0xFFFFFF) << 8 >> 8; sealed class SimError : Exception { diff --git a/src/EasyCon.Script/Bytecode/EcxLinker.cs b/src/EasyCon.Script/Bytecode/EcxLinker.cs index e4bbb918..0d00505d 100644 --- a/src/EasyCon.Script/Bytecode/EcxLinker.cs +++ b/src/EasyCon.Script/Bytecode/EcxLinker.cs @@ -7,14 +7,15 @@ namespace EasyCon.Script.Bytecode; /// -/// 链接器:模块产物(.ecm)合并 → EcxImage(docs/EcmEcxFormat.md §4)。 +/// 链接器:模块产物(.ecm)合并 → EcxImage(位级规格见 EcsContainerFormat.cs 头注)。 /// 类型表合并、导入解析、入口合成、死函数/死表项消除、采集洞可达性、资源需求计算。 -/// 指令扫描全部经 (EXT 步进由 EcsFormat 表驱动); -/// 模块编码(SsaProgram → ModuleArtifact)见 。 +/// 扫描全部经 (解码形态单流——EXT 数据字是指令字段, +/// 不再有「数据字可能等于操作码」的步进铁律)。模块编码(SsaProgram → ModuleArtifact) +/// 见 。 /// -public static class EcxPipeline +internal static class EcxPipeline { - // ============ 阶段二:链接模块产物 → EcxImage(docs/EcmEcxFormat.md §4.1 重写矩阵)============ + // ============ 阶段二:链接模块产物 → EcxImage ============ public static EcxImage Link(IReadOnlyList artifacts, bool keyAction, bool needIL) { @@ -24,10 +25,10 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct const uint ImportFlag = 0x80000000u; // ---- 导出索引:(name, nparams) → (moduleIdx, localFid),首匹配优先(遮蔽语义)---- - var exportIndex = new Dictionary<(string Name, int NParams), (int Module, int LocalFid)>(); + var exportIndex = new Dictionary<(string Module, string Name, int NParams), (int Module, int LocalFid)>(); for (int m = 0; m < artifacts.Count; m++) foreach (var e in artifacts[m].Exports) - exportIndex.TryAdd((e.Name, e.NParams), (m, e.LocalFid)); + exportIndex.TryAdd((artifacts[m].Name, e.Name, e.NParams), (m, e.LocalFid)); var imageFunctions = new List(); var imageGlobals = new List(); @@ -40,7 +41,6 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct // ---- 类型表合并(ModuleSystem.md §5.4-1):模块模式各产物携带本模块声明集, // 全局 sid 按模块序首现分配;同名结构体形状必须一致(跨模块值语义的前提)。 - // v1 各产物共享同一全程序快照,合并后退化为单表,行为不变。 var globalStructs = new List(); var globalStructSids = new Dictionary(StringComparer.Ordinal); var moduleSidMaps = new List>(); @@ -96,11 +96,9 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct int funcBase = imageFunctions.Count; int globalBase = imageGlobals.Count; - // 函数表拷贝 + 单遍 EXT-aware 全重写。EXT 后随字是「数据」(集合/语义的权威定义 - // 见 EcsFormat 表,扫描经 ExtWords 步进),其数值可能恰好等于某个操作码—— - // 不跳过会把数据误读为指令(历史 F4 缺陷即此类)。 - // Code 深拷贝:重写在镜像副本上进行,不污染调用方的模块产物(产物可能随后被 - // .ecm 序列化/二次链接——模块局部索引与镜像全局索引不容二次重映射)。 + // 函数表拷贝 + 单遍全重写。Code 深拷贝:重写在镜像副本上进行,不污染调用方的 + // 模块产物(产物可能随后被 .ecm 序列化/二次链接——模块局部索引与镜像全局索引 + // 不容二次重映射)。EcsInstruction 是 readonly record struct,值拷贝即深拷贝。 var moduleCopies = new List(); foreach (var f in art.Functions) { @@ -111,7 +109,7 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct NParams = f.NParams, NSlots = f.NSlots, HasReturn = f.HasReturn, - Code = f.Code.ToList(), + Instructions = f.Instructions.ToList(), LineTable = [.. f.LineTable], // 行号表随镜像携带(运行错误 pc→行映射) }; moduleCopies.Add(copy); @@ -119,32 +117,25 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct } foreach (var f in moduleCopies) { - InstructionScanner.Scan(f.Code, new InstructionScanner.Callbacks + InstructionScanner.Scan(f.Instructions, new InstructionScanner.Callbacks { // ABx 绝对索引重写:全局槽(+globalBase)、类型 sid(模块→镜像表)、常量池合并 - OnInstruction = (code, w, op) => + OnInstruction = (ins, op) => { switch (op) { case EcsOpcode.LoadG or EcsOpcode.StoreG: { - int bx = (int)((code[w] >> 16) & 0xFFFF) + globalBase; + int bx = ins.B + globalBase; if (bx > 0xFFFF) - throw new BytecodeException(new[] { new BytecodeDiagnostic("镜像全局槽超出 65535", f.Name, w) }); - code[w] = (code[w] & 0xFFFF) | (uint)bx << 16; - break; + throw new BytecodeException(new[] { new BytecodeDiagnostic("镜像全局槽超出 65535", f.Name, 0) }); + return ins with { B = bx }; } case EcsOpcode.NewSt: - { - int bx = (int)((code[w] >> 16) & 0xFFFF); - code[w] = (code[w] & 0xFFFF) | (uint)moduleSidMaps[m][bx] << 16; - break; - } + return ins with { B = moduleSidMaps[m][ins.B] }; case EcsOpcode.LoadK or EcsOpcode.Img: { - // Img 与 LoadK 同用模块常量池 Bx,链接期一并重映射 - int oldBx = (int)((code[w] >> 16) & 0xFFFF); - var cst = art.Pool.Consts[oldBx]; + var cst = art.Pool.Consts[ins.B]; var bits = cst.Tag == EcsTag.Double ? BitConverter.DoubleToInt64Bits(cst.Float64) : cst.Int64; var key = (cst.Tag, bits, cst.Str); if (!imageConstIndex.TryGetValue(key, out var newBx)) @@ -154,25 +145,22 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct imageConsts.Add(cst); } if (newBx > 0xFFFF) - throw new BytecodeException(new[] { new BytecodeDiagnostic("镜像常量池超出 65535(BC_CONST_OVERFLOW)", f.Name, w) }); - code[w] = (code[w] & 0xFFFF) | (uint)newBx << 16; - break; + throw new BytecodeException(new[] { new BytecodeDiagnostic("镜像常量池超出 65535(BC_CONST_OVERFLOW)", f.Name, 0) }); + return ins with { B = newBx }; } + default: + return null; } }, - // Call 目标:导入标记解析 / 模块局部 fid → 镜像全局 fid(EXT 后随字) - OnCall = (code, w, ext) => - { - uint target = (ext & ImportFlag) != 0 - ? ResolveImport(artifacts, exportIndex, art, ext & ~ImportFlag) - : unchecked((uint)(funcBase + (int)ext)); - code[w + 1] = target; - }, - // CallN 目标:原生名表按名合并(EXT 后随字);旗标置位 = syscall 编号直传(VM2.md §9.1) - OnCallN = (code, w, oldId) => + // Call 目标:导入标记解析 / 模块局部 fid → 镜像全局 fid + OnCall = ext => (ext & ImportFlag) != 0 + ? ResolveImport(artifacts, exportIndex, art, ext & ~ImportFlag) + : unchecked((uint)(funcBase + (int)ext)), + // CallN 目标:原生名表按名合并;旗标置位 = syscall 编号直传(VM2.md §9.1) + OnCallN = oldId => { if ((oldId & 0x80000000u) != 0) - return; + return null; var name = art.Natives[(int)oldId].Name; if (!imageNativeIds.TryGetValue(name, out var newId)) { @@ -180,7 +168,7 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct imageNativeIds[name] = newId; imageNatives.Add(new EcsNative { Name = name }); } - code[w + 1] = unchecked((uint)newId); + return unchecked((uint)newId); }, }); } @@ -200,11 +188,10 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct throw new BytecodeException(new[] { new BytecodeDiagnostic("没有模块承载 $eval 入口", null, 0) }); // ---- 入口合成(ModuleSystem.md §5.4-4,链接优化):依赖模块带 时,把 init 调用 - // 序列前插进 $eval 本体,入口 = $eval——不再合成
壳(CALL init… → CALL $eval → - // RET r0)。前插只平移既有指令的绝对位置:Jmp/Jpt/Jpf 均为 PC 相对偏移(平移不变), - // Call/LoadK/LoadG/NewSt/Img 引用的 fid/常量池/全局槽/类型 sid 均为绝对索引(不受影响); + // 序列前插进 $eval 本体,入口 = $eval——不再合成
壳。前插只平移既有指令的下标: + // Jump 为相对指令下标(平移不变),Call/LoadK/LoadG/NewSt/Img 引用的 fid/常量池/全局槽/ + // 类型 sid 均为绝对索引(不受影响);行号表(单位=指令下标)同步平移。 // $eval 自身的 Ret 已承载顶层 RETURN 透传(替代原
的 RET r0)。 - // v1 产物无 HasInit → 不前插,行为不变。 var initFids = new List(); for (int m = 0; m < artifacts.Count; m++) { @@ -217,15 +204,11 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct } if (initFids.Count > 0) { - var header = new List(); - foreach (var fid in initFids) - { - header.Add((uint)EcsOpcode.Call | 255u << 24); // C=255:无接收槽 - header.Add(fid); - } + var header = initFids + .Select(fid => new EcsInstruction(EcsOpcode.Call, EcsRegisters.Ret, 0, EcsRegisters.Ret, fid)) + .ToList(); var entryFn = imageFunctions[entry]; - entryFn.Code.InsertRange(0, header); - // 行号表 pc 随前插同步平移(与指令绝对位置同迁) + entryFn.Instructions.InsertRange(0, header); for (int i = 0; i < entryFn.LineTable.Count; i += 2) entryFn.LineTable[i] += header.Count; } @@ -249,6 +232,10 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct KeyAction = keyAction, NeedIL = needIL, }; + // std/vision 包装函数强制内联(v2.3 对齐):print/FRAME/ROI 等单块透传壳在调用点 + // 展开——先内联、后清扫,被展开的壳与内部残留由可达性清除 + DeadStoreSweep 收尾。 + if (Environment.GetEnvironmentVariable("ECS_NO_STD_INLINE") != "1") + InlineStdWrappers(image.Functions); // 链接期死函数消除:stdlib/vision 供给的未调用函数体不进镜像(入口可达性为准) StripUnreachableFunctions(image); // 死存储清扫(防线 3,LLVM DeadMachineInstructionElim 的镜像级等价):SSA φ 降级/变量 @@ -257,11 +244,11 @@ public static EcxImage Link(IReadOnlyList artifacts, bool keyAct DeadStoreSweep.Sweep(image); // 死元素消除:常量池/原生名表中仅被死函数引用的条目不进镜像(保序压缩 + 索引重映射)。 // 全局表不参与:每个模块全局的声明即顶层语句,必被 /
的 StoreG 引用 - //(init 恒保留),不存在死项;结构体类型表同理暂不裁剪(真实产物为 0 条,见 EcmEcxFormat §6.1)。 + //(init 恒保留),不存在死项;结构体类型表同理暂不裁剪。 StripUnreferencedTables(image); // v1 语义对齐(CaptureAnalyzer 全程序视角):采集卡需求 = 任意模块含图像标签(乐观) // ∪ 自入口可达采集洞函数(精确)。各模块的 SSA 分析器看不见跨模块调用链 - // (接口符号无函数体),这里在合并镜像上补全调用图可达性。 + //(接口符号无函数体),这里在合并镜像上补全调用图可达性。 bool reachesHole = ImageReachesCaptureHole(image); image.NeedIL = needIL || reachesHole; @@ -296,7 +283,7 @@ static uint ComputeFeatures(EcxImage image, bool reachesHole) } var scan = new InstructionScanner.Callbacks(); - scan.OnCallN = (code, w, target) => + scan.OnCallN = target => { if ((target & EcsSyscall.CallFlag) != 0) { @@ -311,9 +298,10 @@ static uint ComputeFeatures(EcxImage image, bool reachesHole) else if (nativeBits.TryGetValue(name, out var bit)) feats |= bit; } + return null; }; foreach (var f in image.Functions) - InstructionScanner.Scan(f.Code, scan); + InstructionScanner.Scan(f.Instructions, scan); return feats; } @@ -324,18 +312,195 @@ static uint ComputeFeatures(EcxImage image, bool reachesHole) /// 索引只缩不增(16 位 Bx 无溢出风险);重映射发生在函数消除之后(引用集已最终), /// 且先于 (其按重映射后的 nid 查新表,自洽)。 ///
+ // ============ std/vision 包装函数强制内联(v2.3 对齐,2026-10-05) ============ + // + // std/vision 的内建包装(print/PRINT/time/TIME/FRAME/ROI…)都是单块透传壳:参数转发给 + // syscall/native 后返回。链接期在调用点展开,消除 CALL/RET 往返、壳函数体;被展开的壳 + // 由随后的可达性清除删除。守卫(违一即放弃该调用点,保持调用形态): + // 壳体:std/vision 模块、单块无跳转/无体内 Call/无中间 Ret、≤24 条、末条 Ret/Ret0; + // 调用点:NParams == 实参数、内联后调用者槽位 ≤254(v3 槽位 u8); + // 壳体指令的操作数重映射按「槽位性」白名单执行(立即数/坐标/长度不映射); + // 体内含函数调用的壳(vision OCR→OCR_INIT 链)不内联,保持包装形态。 + // 不动点迭代 ≤4 轮。行号表(指令下标单位)与跳转(指令下标增量)随拼接平移重算。 + static void InlineStdWrappers(List fns) + { + // 每调用者一个共享 fresh 块(首内联点从 NSlots 起建):不同内联点的临时区在直线 + // 代码中互不重叠、且出拼接区即死——跨点复用同一块槽位是安全的,NSlots 只涨一次。 + var freshBases = new Dictionary(); + for (int round = 0; round < 4; round++) + { + bool any = false; + foreach (var caller in fns) + { + for (int ci = 0; ci < caller.Instructions.Count; ci++) + { + var call = caller.Instructions[ci]; + if (call.Op != EcsOpcode.Call || call.Ext >= (uint)fns.Count) + continue; + var g = fns[(int)call.Ext]; + if (ReferenceEquals(g, caller) || !IsInlineableWrapper(g)) + continue; + if (!freshBases.TryGetValue(caller, out var freshBase)) + freshBase = caller.NSlots; + if (TryInlineStdWrapperAt(caller, ci, g, freshBase)) + { + any = true; + freshBases[caller] = freshBase; + ci--; // 重扫拼接体首条 + } + } + } + if (!any) + return; + } + } + + static bool IsInlineableWrapper(EcsFunction g) + { + // 字面量对齐 ProjectCompiler.StdModule/VisionModule(Bytecode 层不反向依赖 Modules 层) + if (g.Module is not ("std" or "vision")) + return false; + int n = g.Instructions.Count; + if (n == 0 || n > 24) + return false; + if (g.Instructions[^1].Op is not (EcsOpcode.Ret or EcsOpcode.Ret0)) + return false; + for (int i = 0; i < n - 1; i++) + { + var op = g.Instructions[i].Op; + if (op is EcsOpcode.Ret or EcsOpcode.Ret0 + or EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf + or EcsOpcode.ForStep or EcsOpcode.CmpJ + or EcsOpcode.Call) // 体内函数调用链不展开(OCR→OCR_INIT 保持包装) + return false; + } + return true; + } + + /// 操作数重映射:按槽位性白名单;未列操作码 → false(放弃该调用点内联)。 + static bool TryRemapForInline(EcsInstruction ins, int freshBase, + out EcsInstruction remapped) + { + // 整个壳体窗口(含 RET/参数/出站区)移到调用者未使用的区间,布局无需固定常量。 + int M(int s) => freshBase + s; + switch (ins.Op) + { + // A/B/C 全槽位族 + case EcsOpcode.Move or EcsOpcode.SetVar + or EcsOpcode.AddI or EcsOpcode.SubI or EcsOpcode.MulI or EcsOpcode.DivI or EcsOpcode.ModI or EcsOpcode.RDivI + or EcsOpcode.AddU or EcsOpcode.SubU or EcsOpcode.MulU or EcsOpcode.DivU or EcsOpcode.ModU + or EcsOpcode.AddL or EcsOpcode.SubL or EcsOpcode.MulL or EcsOpcode.DivL or EcsOpcode.ModL + or EcsOpcode.AddD or EcsOpcode.SubD or EcsOpcode.MulD or EcsOpcode.DivD + or EcsOpcode.BandI or EcsOpcode.BorI or EcsOpcode.BxorI or EcsOpcode.ShlI or EcsOpcode.ShrI or EcsOpcode.BnotI + or EcsOpcode.EqI or EcsOpcode.LtI or EcsOpcode.LeI or EcsOpcode.GtI or EcsOpcode.GeI + or EcsOpcode.EqU or EcsOpcode.LtU or EcsOpcode.LeU or EcsOpcode.GtU or EcsOpcode.GeU + or EcsOpcode.EqD or EcsOpcode.LtD or EcsOpcode.LeD or EcsOpcode.GtD or EcsOpcode.GeD + or EcsOpcode.EqL or EcsOpcode.LtL or EcsOpcode.LeL or EcsOpcode.GtL or EcsOpcode.GeL + or EcsOpcode.EqS or EcsOpcode.EqP or EcsOpcode.Not or EcsOpcode.NegI or EcsOpcode.NegD + or EcsOpcode.GetI or EcsOpcode.SetI or EcsOpcode.Cont or EcsOpcode.Append or EcsOpcode.Cat or EcsOpcode.Len + or EcsOpcode.Rand or EcsOpcode.WaitV + or EcsOpcode.Ret: + remapped = ins with { A = M(ins.A), B = M(ins.B), C = M(ins.C) }; + return true; + + // A/B/C 槽位 + ext 槽位/哨兵 + case EcsOpcode.Slice: + remapped = ins with + { + A = M(ins.A), + B = M(ins.B), + C = M(ins.C), + Ext = ins.Ext == 0xFFFFFFFFu ? ins.Ext : unchecked((uint)M(unchecked((int)ins.Ext))), + }; + return true; + case EcsOpcode.GetFI or EcsOpcode.PutFI: + remapped = ins with { A = M(ins.A), B = M(ins.B), C = M(ins.C), Ext = unchecked((uint)M(unchecked((int)ins.Ext))) }; + return true; + + // A/B 槽位、C = 立即数(Conv 种类 / 字段下标) + case EcsOpcode.Conv or EcsOpcode.GetF or EcsOpcode.PutF: + remapped = ins with { A = M(ins.A), B = M(ins.B) }; + return true; + + // A/C 槽位(B = 参数个数/计数);出站基址与返回落点一起重映射 + case EcsOpcode.Call or EcsOpcode.CallN: + remapped = ins with { A = M(ins.A), C = M(ins.C) }; + return true; + + // 仅 A 槽位(B/C = 常量/索引/立即数) + case EcsOpcode.LoadI or EcsOpcode.LoadBool or EcsOpcode.LoadK + or EcsOpcode.LoadG or EcsOpcode.StoreG or EcsOpcode.NewArrE or EcsOpcode.NewSt or EcsOpcode.Img: + remapped = ins with { A = M(ins.A) }; + return true; + + default: // KeySt/Stick*/WaitI/KeyI(坐标/时长/键码)/跳转类 → 不重映射(出现在壳体即放弃) + remapped = default; + return false; + } + } + + static bool TryInlineStdWrapperAt(EcsFunction caller, int ci, EcsFunction g, int freshBase) + { + var code = caller.Instructions; + var call = code[ci]; + if (call.B != g.NParams) + return false; // 实参数与壳参数不匹配 → 保持调用 + // 先将实参快照放入独立壳体窗口,再展开其指令,避免内外两次调用的出站区重叠。 + int need = freshBase + g.NSlots; + if (need > EcsRegisters.MaxRegisters) + return false; // 帧预算不足 → 保持调用 + + var body = g.Instructions.Take(g.Instructions.Count - 1).ToList(); + var spliced = new List(body.Count + call.B + 1); + for (int i = 0; i < call.B; i++) + spliced.Add(new EcsInstruction(EcsOpcode.Move, freshBase + EcsRegisters.ArgBase + i, call.A + i, 0)); + foreach (var ins in body) + { + if (!TryRemapForInline(ins, freshBase, out var remapped)) + return false; + spliced.Add(remapped); + } + var tail = g.Instructions[^1]; + if (tail.Op == EcsOpcode.Ret) + spliced.Add(new EcsInstruction(EcsOpcode.Move, call.C, freshBase + tail.A, 0)); + + // 跳转快照(壳体无跳转,仅调用者侧);拼接后重算下标增量 + var jumps = new List<(int J, int T)>(); + for (int j = 0; j < code.Count; j++) + { + var ins = code[j]; + if (ins.Op is EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf or EcsOpcode.ForStep or EcsOpcode.CmpJ) + jumps.Add((j, j + 1 + ins.Jump)); + } + + int d = spliced.Count - 1; + caller.NSlots = Math.Max(caller.NSlots, need); + code.RemoveAt(ci); + code.InsertRange(ci, spliced); + foreach (var (j, t) in jumps) + { + int nj = j + (j > ci ? d : 0); + int nt = t + (t > ci ? d : 0); + code[nj] = code[nj] with { Jump = nt - (nj + 1) }; + } + for (int i = 0; i < caller.LineTable.Count; i += 2) + if (caller.LineTable[i] > ci) + caller.LineTable[i] += d; + return true; + } + static void StripUnreferencedTables(EcxImage image) { var usedConsts = new HashSet(); var usedNatives = new HashSet(); var refs = new InstructionScanner.Callbacks { - OnConstRef = (code, w, bx) => usedConsts.Add(bx), + OnConstRef = bx => usedConsts.Add(bx), // 旗标置位 = syscall 编号,非名表引用 - OnCallN = (code, w, nid) => { if ((nid & 0x80000000u) == 0) usedNatives.Add((int)nid); }, + OnCallN = nid => { if ((nid & 0x80000000u) == 0) usedNatives.Add((int)nid); return null; }, }; foreach (var fn in image.Functions) - InstructionScanner.Scan(fn.Code, refs); + InstructionScanner.Scan(fn.Instructions, refs); if (usedConsts.Count == image.Consts.Count && usedNatives.Count == image.Natives.Count) return; // 无死条目:零重映射开销 @@ -360,22 +525,22 @@ static void StripUnreferencedTables(EcxImage image) var remapTable = new InstructionScanner.Callbacks { - OnConstRef = (code, w, bx) => code[w] = (code[w] & 0xFFFF) | (uint)constNewIndex[bx] << 16, + OnConstRef = null, + OnInstruction = (ins, op) => op is EcsOpcode.LoadK or EcsOpcode.Img ? ins with { B = constNewIndex[ins.B] } : null, // 旗标置位 = syscall 编号直传,不参与名表重映射 - OnCallN = (code, w, nid) => { if ((nid & 0x80000000u) == 0) code[w + 1] = unchecked((uint)nativeNewIndex[(int)nid]); }, + OnCallN = nid => (nid & 0x80000000u) == 0 ? unchecked((uint)nativeNewIndex[(int)nid]) : null, }; foreach (var fn in image.Functions) - InstructionScanner.Scan(fn.Code, remapTable); + InstructionScanner.Scan(fn.Instructions, remapTable); image.Consts = keptConsts; image.Natives = keptNatives; } /// /// 链接期死函数消除:自入口 BFS 调用图(Call 边;CallN 经原生名表分发,不构成函数边), - /// 仅保留可达函数并重映射 fid(Call EXT 后随字)——stdlib/vision 供给的未调用函数体 - /// (OCR/FRAME/采集洞等)不进镜像。不可达函数无其他引用途径(ECS 无函数指针/间接调用, - /// 全部调用在编码期闭合为 Call fid),消除是安全的。EXT 后随字步进约定与 - /// / 一致。 + /// 仅保留可达函数并重映射 fid——stdlib/vision 供给的未调用函数体(OCR/FRAME/采集洞等) + /// 不进镜像。不可达函数无其他引用途径(ECS 无函数指针/间接调用, + /// 全部调用在编码期闭合为 Call fid),消除是安全的。 /// <init:module> 经入口头部的调用序列必然可达;原地重写在镜像副本上进行。 /// static void StripUnreachableFunctions(EcxImage image) @@ -389,14 +554,15 @@ static void StripUnreachableFunctions(EcxImage image) } var callEdges = new InstructionScanner.Callbacks { - OnCall = (code, w, target) => + OnCall = target => { if (keep.Add((int)target)) queue.Enqueue((int)target); + return null; }, }; while (queue.Count > 0) - InstructionScanner.Scan(image.Functions[queue.Dequeue()].Code, callEdges); + InstructionScanner.Scan(image.Functions[queue.Dequeue()].Instructions, callEdges); if (keep.Count == image.Functions.Count) return; // 全可达:零重映射开销 @@ -412,10 +578,10 @@ static void StripUnreachableFunctions(EcxImage image) } var remapFid = new InstructionScanner.Callbacks { - OnCall = (code, w, target) => code[w + 1] = unchecked((uint)newIndexOf[(int)target]), + OnCall = target => unchecked((uint)newIndexOf[(int)target]), }; foreach (var fn in kept) - InstructionScanner.Scan(fn.Code, remapFid); + InstructionScanner.Scan(fn.Instructions, remapFid); image.Functions = kept; image.Entry = newIndexOf[image.Entry]; } @@ -423,7 +589,7 @@ static void StripUnreachableFunctions(EcxImage image) /// 自镜像入口 BFS 调用图,判断是否可达任何采集洞函数(CallN "__CAPTURE__" 等)。 static bool ImageReachesCaptureHole(EcxImage image) { - var holeNames = Binding.BuiltinFunctions.GetCaptureHoles() + var holeNames = Binding.BuiltinFunctions.GetLibOnlyHoles() .Select(h => h.Name) .ToHashSet(StringComparer.Ordinal); @@ -436,39 +602,56 @@ static bool ImageReachesCaptureHole(EcxImage image) } bool foundHole = false; var holeScan = new InstructionScanner.Callbacks(); - holeScan.OnCallN = (code, w, nid) => + holeScan.OnCallN = nid => { if ((nid & 0x80000000u) == 0 && nid < (uint)image.Natives.Count && holeNames.Contains(image.Natives[(int)nid].Name)) { foundHole = true; holeScan.Stop = true; // 命中即停(等价原实现的即时 return true) } + return null; }; - holeScan.OnCall = (code, w, target) => + holeScan.OnCall = target => { if (target < (uint)image.Functions.Count && visited.Add((int)target)) queue.Enqueue((int)target); + return null; }; while (queue.Count > 0 && !foundHole) - InstructionScanner.Scan(image.Functions[queue.Dequeue()].Code, holeScan); + InstructionScanner.Scan(image.Functions[queue.Dequeue()].Instructions, holeScan); return foundHole; } static uint ResolveImport( IReadOnlyList artifacts, - Dictionary<(string Name, int NParams), (int Module, int LocalFid)> exportIndex, + Dictionary<(string Module, string Name, int NParams), (int Module, int LocalFid)> exportIndex, ModuleArtifact importer, uint importIdx) { var imp = importer.Imports[(int)importIdx]; - if (!exportIndex.TryGetValue((imp.Name, imp.NParams), out var resolved)) - throw new BytecodeException(new[] { new BytecodeDiagnostic( - $"LC_MISSING_FUNC:模块 {importer.Name} 导入的函数 {imp.Name}/{imp.NParams} 无可解析导出", importer.Name, 0) }); + // 导入名模块限定(N2):"模块!函数" → 按目标模块精确解析(跨模块同名可区分); + // 裸名 → 按名/参数数首命中(防御路径,现管线导入恒带模块限定) + var bang = imp.Name.IndexOf('!'); + (string Module, string Name, int NParams) key = bang > 0 + ? (imp.Name[..bang], imp.Name[(bang + 1)..], imp.NParams) + : (null!, imp.Name, imp.NParams); + if (!exportIndex.TryGetValue(key, out var resolved)) + { + if (key.Module != null) + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"LC_MISSING_FUNC:模块 {importer.Name} 导入的函数 {imp.Name}/{imp.NParams} 在目标模块中无导出", importer.Name, 0) }); + var hit = exportIndex.FirstOrDefault(kv => kv.Key.Name == imp.Name && kv.Key.NParams == imp.NParams); + if (hit.Key.Name == null) + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"LC_MISSING_FUNC:模块 {importer.Name} 导入的函数 {imp.Name}/{imp.NParams} 无可解析导出", importer.Name, 0) }); + resolved = hit.Value; + } int funcBase = BaseFid(artifacts, resolved.Module); return unchecked((uint)(funcBase + resolved.LocalFid)); } - /// 静态资源需求:max_slots 与调用图最长链(EcmEcxFormat §2.1,静态预分配用)。 - static void ComputeResourceRequirements(EcxImage image) + /// 静态资源需求:max_slots 与调用图最长链(静态预分配用)。 + /// 容量元数据不进产物(R-3);内存模型保留供宿主预检/诊断,序列化侧省略。 + internal static void ComputeResourceRequirements(EcxImage image) { image.MaxSlots = image.Functions.Count == 0 ? 0 : image.Functions.Max(f => f.NSlots); @@ -478,8 +661,8 @@ static void ComputeResourceRequirements(EcxImage image) for (int i = 0; i < image.Functions.Count; i++) { var edges = new List(); - edgeScan.OnCall = (code, w, target) => edges.Add(target); - InstructionScanner.Scan(image.Functions[i].Code, edgeScan); + edgeScan.OnCall = target => { edges.Add(target); return null; }; + InstructionScanner.Scan(image.Functions[i].Instructions, edgeScan); calls[i] = edges; } diff --git a/src/EasyCon.Script/Bytecode/EcxModuleEncoder.cs b/src/EasyCon.Script/Bytecode/EcxModuleEncoder.cs index 82aeaa65..d85f9ac5 100644 --- a/src/EasyCon.Script/Bytecode/EcxModuleEncoder.cs +++ b/src/EasyCon.Script/Bytecode/EcxModuleEncoder.cs @@ -6,12 +6,12 @@ namespace EasyCon.Script.Bytecode; /// -/// 模块编码器(docs/EcmEcxFormat.md §4):把「只含本模块函数」的 SSA 全量编码为 +/// 模块编码器:把「只含本模块函数」的 SSA 全量编码为 /// 独立模块产物(ModuleArtifact,可持久化为 .ecm)。外部依赖符号经 externalFunctions /// 落导入标记(链接期解析);类型表布局见 ; /// 产物合并 → EcxImage 见 。 /// -public static class EcxModuleEncoder +internal static class EcxModuleEncoder { /// @@ -51,7 +51,7 @@ public static ModuleArtifact CompileWholeProgramAsModule( Imports = new List(), - ImportIds = new Dictionary<(string, int), int>(), + ImportIds = new Dictionary<(string?, string, int), int>(), Globals = new List(), diff --git a/src/EasyCon.Script/Bytecode/EcxWriter.cs b/src/EasyCon.Script/Bytecode/EcxWriter.cs deleted file mode 100644 index 06047e4b..00000000 --- a/src/EasyCon.Script/Bytecode/EcxWriter.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System.Text; - -namespace EasyCon.Script.Bytecode; - -/// -/// ECX 镜像序列化——严格按 docs/EcmEcxFormat.md §2 冻结规范: -/// 0x24 头部(含 max_slots/max_depth)、核心表无名字(名字进调试区)、 -/// 代码区 4 字节对齐、entry 收尾。小端。 -/// v2(uvm32 式纯净镜像):核心全局表项 = 模块 idx:u8 + 类型码:u8 定长 2 字节, -/// 全局名与函数名同住调试区,受 flags.D / stripDebug 门控——MCU 发布镜像零名字重量。 -/// -public static class EcxWriter -{ - public const uint Magic = 0x32435845; // "ECX2" - public const ushort FormatVersion = 2; - - /// - /// 序列化 ECX 镜像。 = true 时剥离调试区(函数名表), - /// flags.D=0、debug_count=0(EcmEcxFormat.md §2.2/§2.9),供 MCU 发布版减小镜像。 - /// - public static byte[] Write(EcxImage image, bool stripDebug = false) - { - // 槽位在 ECX 冻结格式里是 u8(max_slots、FuncDef.nslots):超出即无法表示。 - // 不做静默截断——按设计,编译产物超出 MCU 容量应响亮失败(编译产物过大天然无法执行)。 - if (image.MaxSlots > 255) - throw new BytecodeException(new[] { new BytecodeDiagnostic( - $"镜像 max_slots {image.MaxSlots} 超出 ECX 冻结格式上限 255(产物过大,单片机无法执行)", null, 0) }); - foreach (var f in image.Functions) - if (f.NSlots > 255) - throw new BytecodeException(new[] { new BytecodeDiagnostic( - $"函数 {f.Name} 帧槽位 {f.NSlots} 超出 ECX 冻结格式上限 255", f.Name, 0) }); - - using var ms = new MemoryStream(); - using var w = new BinaryWriter(ms); - - // ---- 头部(0x24 字节)---- - w.Write(Magic); - w.Write(FormatVersion); - ushort flags = 0; - if (!stripDebug && image.Functions.Count > 0) flags |= 0x1; // D:调试区(函数名表) - if (image.KeyAction) flags |= 0x2; // K - if (image.NeedIL) flags |= 0x4; // I - w.Write(flags); - w.Write((byte)image.MaxSlots); - w.Write((byte)Math.Min(image.MaxDepth, 255)); - w.Write((ushort)(image.Features & 0xFFFF)); // 特征需求掩码(原保留位,VM2.md §9.1) - w.Write(image.Consts.Count); - w.Write(image.Structs.Count); - w.Write(image.Globals.Count); - w.Write(image.Natives.Count); - w.Write(image.Functions.Count); - w.Write(stripDebug ? 0 : image.Functions.Count + image.Globals.Count); // debug_count(§2.2:D=0 时为 0;v2 = 函数名 + 全局名) - - // ---- 常量池 ---- - foreach (var c in image.Consts) - WriteConst(w, c); - - // ---- 类型表 ---- - foreach (var s in image.Structs) - { - WriteUtf8(w, s.Name); - w.Write((byte)s.Fields.Length); - foreach (var f in s.Fields) - { - WriteUtf8(w, f.Name); - w.Write((byte)f.Kind); - w.Write((byte)f.Type); - w.Write((byte)f.ElementType); - w.Write((ushort)(f.Kind == EcsFieldKind.NestedStruct ? f.NestedSid : f.Count)); // ext 槽:FixedArray=元素数;NestedStruct=嵌套 sid(内存表示存 NestedSid) - } - } - - // ---- 全局表(v2:核心表项无名字,定长 2 字节;名字在调试区)---- - foreach (var g in image.Globals) - { - w.Write((byte)image.Modules.IndexOf(g.Module)); - w.Write((byte)g.Type); - } - - // ---- 原生名表 ---- - foreach (var n in image.Natives) - WriteUtf8(w, n.Name); - - // ---- 函数表(11 字节定长,无名字;code_off 单位=指令字,相对代码区起始)---- - uint codeOffset = 0; - foreach (var f in image.Functions) - { - w.Write((byte)f.NParams); - w.Write((byte)f.NSlots); - w.Write(f.HasReturn ? (byte)1 : (byte)0); - w.Write(codeOffset); - w.Write((uint)f.Code.Count); - codeOffset += (uint)f.Code.Count; - } - - // ---- 代码区(4 字节对齐——代码区之前均为 4 字节倍数内字段,天然对齐)---- - foreach (var f in image.Functions) - foreach (var word in f.Code) - w.Write(word); - - // ---- 调试区(v2:函数名表 + 全局名表,按各自表序;stripDebug 时整区不存在)---- - if (!stripDebug) - { - foreach (var f in image.Functions) - WriteUtf8(w, f.Name); - foreach (var g in image.Globals) - WriteUtf8(w, g.Name); - } - - // ---- entry 收尾 ---- - w.Write(image.Entry); - w.Flush(); - return ms.ToArray(); - } - - static void WriteConst(BinaryWriter w, EcsConst c) - { - w.Write(c.Tag); - switch (c.Tag) - { - case EcsTag.Int: - case EcsTag.UInt: - w.Write((int)c.Int64); - break; - case EcsTag.UInt64: - case EcsTag.Ptr: - w.Write(c.Int64); - break; - case EcsTag.Double: - w.Write(c.Float64); - break; - case EcsTag.String: - { - var units = Encoding.Unicode.GetBytes(c.Str ?? ""); - w.Write((ushort)(units.Length / 2)); - w.Write(units); - break; - } - default: - throw new BytecodeException(new[] { new BytecodeDiagnostic($"不支持的常量标签 {c.Tag}", null, 0) }); - } - } - - static void WriteUtf8(BinaryWriter w, string s) - { - var bytes = Encoding.UTF8.GetBytes(s); - if (bytes.Length > 0xFFFF) - throw new BytecodeException(new[] { new BytecodeDiagnostic($"标识符过长: {s}", null, 0) }); - w.Write((ushort)bytes.Length); - w.Write(bytes); - } -} \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/InstructionCodec.cs b/src/EasyCon.Script/Bytecode/InstructionCodec.cs new file mode 100644 index 00000000..2a4e2f4f --- /dev/null +++ b/src/EasyCon.Script/Bytecode/InstructionCodec.cs @@ -0,0 +1,273 @@ +namespace EasyCon.Script.Bytecode; + +/// +/// 解码形态 ↔ 定长字节流的单处换算(docs/SingleStreamFormat.md §2/§5,v3 定长编码)。 +/// +/// 线格式 = 定长指令:4B(Iabc / ABx / AsBx / IsJ)或 8B(Ext / IabcJ = iABC + u32 数据字), +/// 尺寸由 唯一决定;操作数位宽由格式锁定—— +/// 槽位/立即字节 = u8(Call/CallN 的接收槽 255 = 无)、ABx = u16、AsBx = s16、IsJ = s24、 +/// 数据字 = u32(AsBx/IsJ/IabcJ 的「数据字/尾字段」承载跳转的**字节**偏移)。 +/// Project(Lift(b)) == b、Lift(Project(x)) ≡ x 无条件下成立(布局不再依赖内容)。 +/// +/// 跳转偏移的双表示:**内存** = 相对指令下标(解释器按下标取指;前插平移不变); +/// **线格式** = 相对**字节**偏移(目标 = 指令起始 + 本指令字节数 + delta)。 +/// 换算只发生在本类: 一遍计算布局(定长 ⇒ 无定点迭代), +/// 借指令起始字节表反查目标下标。 +/// +public static class InstructionCodec +{ + /// 每条指令的字节数(4B 或 8B,由格式表唯一决定)。 + public static int SizeOf(EcsOpcode op) => EcsFormat.WordCount(op) * 4; + + public static int InstructionSize(in EcsInstruction ins) => SizeOf(ins.Op); + + // ---- 布局依赖的跳转(内存 Jump(指令下标)→ 线上字节偏移) ---- + + static bool HasLayoutDelta(in EcsInstruction ins) + => ins.IsJump || ins.Op is EcsOpcode.ForStep or EcsOpcode.CmpJ; + + static int DeltaTargetIndex(in EcsInstruction ins, int i) => i + 1 + ins.Jump; + + static int JumpByteLimit(EcsOpcode op) => op switch + { + EcsOpcode.Jmp => (1 << 23) - 1, // IsJ: s24 + EcsOpcode.Jpt or EcsOpcode.Jpf => short.MaxValue, // AsBx: s16 + _ => int.MaxValue, // IabcJ 数据字: s32 全宽 + }; + + static void CheckJumpRange(EcsOpcode op, int jumpBytes, int at) + { + int limit = JumpByteLimit(op); + if (jumpBytes < -limit - 1 || jumpBytes > limit) + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"{op} 跳转字节偏移越界: {jumpBytes}(格式容许 ±{limit})", null, at) }); + } + + static byte SlotByte(int v, EcsOpcode op, int at) + { + if (v < 0 || v > 254) + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"{op} 槽位越界: {v}(v3 定长编码槽位为 u8;寄存器范围 R0–R254)", null, at) }); + return (byte)v; + } + + static byte ByteOperand(int v, EcsOpcode op, string name, int at) + { + if (v < 0 || v > 255) + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"{op} 的 {name} 越界: {v}(v3 定长编码为 u8)", null, at) }); + return (byte)v; + } + + // ---- Project:解码形态 → 字节流 ---- + + /// 编码一个函数的指令序列为定长字节流(一遍布局;跳转范围越界响亮失败)。 + public static byte[] Project(IReadOnlyList instructions) + { + int n = instructions.Count; + var offsets = new int[n + 1]; + int off = 0; + for (int i = 0; i < n; i++) + { + offsets[i] = off; + off += InstructionSize(instructions[i]); + } + offsets[n] = off; + + var buf = new List(off); + for (int i = 0; i < n; i++) + { + var ins = instructions[i]; + int size = InstructionSize(ins); + int jumpBytes = 0; + if (HasLayoutDelta(ins)) + { + int targetIdx = DeltaTargetIndex(ins, i); + if (targetIdx < 0 || targetIdx > n) + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"跳转目标指令下标越界: {targetIdx}(共 {n} 条)", null, i) }); + jumpBytes = offsets[targetIdx] - (offsets[i] + size); + CheckJumpRange(ins.Op, jumpBytes, i); + } + + buf.Add((byte)ins.Op); + switch (EcsFormat.Get(ins.Op)) + { + case EcsInsFormat.Iabc: + buf.Add(SlotByte(ins.A, ins.Op, i)); + buf.Add(ByteOperand(ins.B, ins.Op, "B", i)); + buf.Add(ByteOperand(ins.C, ins.Op, "C", i)); + break; + + case EcsInsFormat.ABx: + buf.Add(SlotByte(ins.A, ins.Op, i)); + W16(buf, ins.B >= 0 && ins.B <= 0xFFFF ? ins.B + : throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"{ins.Op} 的 Bx 越界: {ins.B}(u16)", null, i) })); + break; + + case EcsInsFormat.AsBx: + buf.Add(SlotByte(ins.A, ins.Op, i)); + if (ins.IsJump) { CheckJumpRange(ins.Op, jumpBytes, i); S16(buf, jumpBytes); } + else S16(buf, ins.B >= short.MinValue && ins.B <= short.MaxValue ? ins.B + : throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"{ins.Op} 的 sBx 越界: {ins.B}(s16)", null, i) })); + break; + + case EcsInsFormat.IsJ: + CheckJumpRange(ins.Op, jumpBytes, i); + S24(buf, jumpBytes); + break; + + case EcsInsFormat.Ext: + buf.Add(SlotByte(ins.A, ins.Op, i)); + buf.Add(ByteOperand(ins.B, ins.Op, "B", i)); + buf.Add(ByteOperand(ins.C, ins.Op, "C", i)); + W32(buf, unchecked((int)ins.Ext)); + break; + + case EcsInsFormat.IabcJ: + buf.Add(SlotByte(ins.A, ins.Op, i)); + buf.Add(ByteOperand(ins.B, ins.Op, "B", i)); + buf.Add(ByteOperand(ins.C, ins.Op, "C", i)); + W32(buf, jumpBytes); + break; + + default: + throw new BytecodeException(new[] { new BytecodeDiagnostic( + $"{ins.Op} 未登记编码格式", null, i) }); + } + } + return buf.ToArray(); + } + + // ---- Lift:字节流 → 解码形态 ---- + + /// 解码单个函数的字节流区间 [start, end) 回指令序列。校验:操作码已登记、 + /// 指令完整(定长截断即拒)、跳转落点必须是某条指令的**起始字节**(验证网 N6)。 + public static List Lift(ReadOnlySpan stream, int start, int end, string? function = null) + => LiftWithStarts(stream, start, end, out _, function); + + /// 同 ,另回传每条指令的起始字节(SEC_LINES 的字节偏移 → 指令下标还原用)。 + public static List LiftWithStarts(ReadOnlySpan stream, int start, int end, + out List instructionStarts, string? function = null) + { + var result = new List(); + var startBytes = new List(); + var rawJumpBytes = new List(); // 与 result 平行:跳转指令的线格式字节偏移(非跳转 = 0) + int pos = start; + while (pos < end) + { + int insStart = pos; + var op = (EcsOpcode)stream[pos]; + if ((int)op >= System.Enum.GetValues().Length) + throw Fail($"未登记操作码 {(int)op}", function, insStart); + int size = SizeOf(op); + if (pos + size > end) + throw Fail("指令流截断(定长指令不完整)", function, insStart); + int a = stream[pos + 1]; + int b = stream[pos + 2]; + int c = stream[pos + 3]; + uint word = 0; + if (size == 8) + word = (uint)(stream[pos + 4] | stream[pos + 5] << 8 | stream[pos + 6] << 16 | stream[pos + 7] << 24); + pos += size; + + int rawJump = 0; + uint ext = 0; + bool isBranch = op is EcsOpcode.Jmp or EcsOpcode.Jpt or EcsOpcode.Jpf; + switch (EcsFormat.Get(op)) + { + case EcsInsFormat.Iabc: + break; + + case EcsInsFormat.ABx: + b = b | c << 8; + c = 0; + break; + + case EcsInsFormat.AsBx: + { + int sbx = (short)(b | c << 8); + if (isBranch) { b = c = 0; rawJump = sbx; } + else { b = sbx; c = 0; } + break; + } + + case EcsInsFormat.IsJ: + { + int sj = stream[insStart + 1] | stream[insStart + 2] << 8 | stream[insStart + 3] << 16; + sj = (sj << 8) >> 8; // s24 符号扩展 + a = b = c = 0; + rawJump = sj; + break; + } + + case EcsInsFormat.Ext: + ext = word; + break; + + case EcsInsFormat.IabcJ: + rawJump = (int)word; // 出口/跳转偏移(ForStep/CmpJ) + break; + } + + startBytes.Add(insStart); + rawJumpBytes.Add(rawJump); + result.Add(isBranch + ? new EcsInstruction(op, a, 0, 0, ext) + : new EcsInstruction(op, a, b, c, ext)); + } + + instructionStarts = startBytes; + // 跳转换算:字节偏移 → 相对指令下标(落点必须是指令起始字节,N6);ForStep/CmpJ 的偏移在数据字 + for (int i = 0; i < result.Count; i++) + { + bool layoutJump = HasLayoutDelta(result[i]); + if (!layoutJump) + continue; + int raw = rawJumpBytes[i]; + int targetByte = startBytes[i] + SizeOf(result[i].Op) + raw; + int targetIdx = startBytes.BinarySearch(targetByte); + if (targetIdx < 0) + throw Fail($"跳转落点 {targetByte} 不是指令起始字节", function, i); + int delta = targetIdx - (i + 1); + result[i] = result[i] with { Jump = delta }; + } + return result; + } + + // ---- 小端写入 ---- + + static void W16(List buf, int v) + { + buf.Add((byte)v); + buf.Add((byte)(v >> 8)); + } + + static void S16(List buf, int v) + { + ushort u = unchecked((ushort)v); + W16(buf, u); + } + + static void S24(List buf, int v) + { + uint u = unchecked((uint)v) & 0xFFFFFF; + buf.Add((byte)u); + buf.Add((byte)(u >> 8)); + buf.Add((byte)(u >> 16)); + } + + static void W32(List buf, int v) + { + uint u = unchecked((uint)v); + buf.Add((byte)u); + buf.Add((byte)(u >> 8)); + buf.Add((byte)(u >> 16)); + buf.Add((byte)(u >> 24)); + } + + static BytecodeException Fail(string message, string? function, int at) + => new(new[] { new BytecodeDiagnostic(message, function, at) }); +} \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/InstructionScanner.cs b/src/EasyCon.Script/Bytecode/InstructionScanner.cs index 3e166f05..755f688a 100644 --- a/src/EasyCon.Script/Bytecode/InstructionScanner.cs +++ b/src/EasyCon.Script/Bytecode/InstructionScanner.cs @@ -1,11 +1,10 @@ namespace EasyCon.Script.Bytecode; /// -/// ECX 代码线性扫描器(回调式,链接器各分析/重写 pass 共享)。步进由 -/// 驱动——EXT 后随字是「数据」,数值可能恰好等于某个 -/// 操作码,扫描器保证它永不被误读为指令(历史 F4 缺陷形态)。 -/// 新增分析/重写 pass 只写回调,不再复制指令遍历骨架;重写型 pass 经 -/// code[w] = … 原地修改(IList 视图)。 +/// 指令流线性扫描器(回调式,链接器各分析/重写 pass 共享)。 +/// 扫描对象是解码形态 ——单流化(docs/SingleStreamFormat.md §2)后 +/// 不再有「按 ExtWords 步进猜字长」的字流扫描:EXT 数据字是指令内字段,天然不被误读为指令 +///(历史 F4 缺陷形态随双流一起消失)。重写型 pass 经返回的新指令原地替换。 /// internal static class InstructionScanner { @@ -14,43 +13,46 @@ public sealed class Callbacks /// 置 true 后扫描在当前指令处理完即停止(早退:可达性命中等)。 public bool Stop; - /// 每条指令(含 EXT 首字)——ABx 类原地重写(LoadG/StoreG/NewSt/LoadK/Img)用。 - public Action, int, EcsOpcode>? OnInstruction; + /// 每条指令(含返回的替换指令;null = 保持原指令)。ABx 类重写(LoadG/StoreG/NewSt/LoadK/Img)用。 + public Func? OnInstruction; /// Call:ext = 目标 fid(链接前模块局部/导入标记,链接后镜像全局)。 - public Action, int, uint>? OnCall; - /// CallN:ext = 原生名表 nid。 - public Action, int, uint>? OnCallN; - /// LoadK/Img:Bx = 模块/镜像常量池索引(ABx,无 EXT 后随字)。 - public Action, int, int>? OnConstRef; + public Func? OnCall; + /// CallN:ext = 原生名表 nid / syscall 编号。 + public Func? OnCallN; + /// LoadK/Img:Bx = 模块/镜像常量池索引。 + public Action? OnConstRef; /// 其余 EXT 指令:ext 为数据字(语义见 EcsFormat.ExtKind)。 - public Action, int, EcsOpcode, uint>? OnExt; + public Action? OnExt; } - public static void Scan(IList code, Callbacks cb) + public static void Scan(List code, Callbacks cb) { - for (int w = 0; w < code.Count; w++) + for (int i = 0; i < code.Count; i++) { - var op = (EcsOpcode)(code[w] & 0xFF); - cb.OnInstruction?.Invoke(code, w, op); - switch (op) + var ins = code[i]; + var replaced = cb.OnInstruction?.Invoke(ins, ins.Op); + if (replaced != null) + code[i] = ins = replaced.Value; + switch (ins.Op) { case EcsOpcode.Call: - cb.OnCall?.Invoke(code, w, code[w + 1]); + if (cb.OnCall?.Invoke(ins.Ext) is { } callExt) + code[i] = ins = ins with { Ext = callExt }; break; case EcsOpcode.CallN: - cb.OnCallN?.Invoke(code, w, code[w + 1]); + if (cb.OnCallN?.Invoke(ins.Ext) is { } callNExt) + code[i] = ins = ins with { Ext = callNExt }; break; case EcsOpcode.LoadK or EcsOpcode.Img: - cb.OnConstRef?.Invoke(code, w, (int)((code[w] >> 16) & 0xFFFF)); + cb.OnConstRef?.Invoke(ins.B); break; default: - if (EcsFormat.ExtWords(op) > 0) - cb.OnExt?.Invoke(code, w, op, code[w + 1]); + if (ins.HasExt) + cb.OnExt?.Invoke(ins.Op, ins.Ext); break; } if (cb.Stop) return; - w += EcsFormat.ExtWords(op); // EXT 后随字是数据:步进跳过 } } } \ No newline at end of file diff --git a/src/EasyCon.Script/Bytecode/ModuleInterface.cs b/src/EasyCon.Script/Bytecode/ModuleInterface.cs index 23dbd14f..a403a566 100644 --- a/src/EasyCon.Script/Bytecode/ModuleInterface.cs +++ b/src/EasyCon.Script/Bytecode/ModuleInterface.cs @@ -223,34 +223,34 @@ static void AppendItem(StringBuilder sb, string s) } /// -/// 接口序列化(ECM v2 接口区,EcmFormat 内嵌;字节布局小端、UTF-8 长度前置字符串)。 +/// 接口序列化(ECM1 元数据尾块内嵌 blob;字节布局小端、UTF-8 长度前置字符串,内层格式自有版本演进)。 /// public static class ModuleInterfaceFormat { public static void Write(BinaryWriter w, ModuleInterface iface) { - EcmFormat.WriteUtf8(w, iface.Name); - EcmFormat.WriteUtf8(w, iface.CompilerVersion); - EcmFormat.WriteUtf8(w, iface.InterfaceHash); + EcsContainer.WriteUtf8(w, iface.Name); + EcsContainer.WriteUtf8(w, iface.CompilerVersion); + EcsContainer.WriteUtf8(w, iface.InterfaceHash); w.Write(iface.HasInit); w.Write(iface.Functions.Count); foreach (var f in iface.Functions) { - EcmFormat.WriteUtf8(w, f.Name); - EcmFormat.WriteUtf8(w, f.ReturnTypeName); + EcsContainer.WriteUtf8(w, f.Name); + EcsContainer.WriteUtf8(w, f.ReturnTypeName); w.Write(f.IsExtern); if (f.IsExtern) { - EcmFormat.WriteUtf8(w, f.ExternLibrary ?? ""); - EcmFormat.WriteUtf8(w, f.ExternalName ?? ""); + EcsContainer.WriteUtf8(w, f.ExternLibrary ?? ""); + EcsContainer.WriteUtf8(w, f.ExternalName ?? ""); } w.Write(f.DeclLine); w.Write(f.Params.Count); foreach (var p in f.Params) { - EcmFormat.WriteUtf8(w, p.Name); - EcmFormat.WriteUtf8(w, p.TypeName); + EcsContainer.WriteUtf8(w, p.Name); + EcsContainer.WriteUtf8(w, p.TypeName); w.Write((byte)p.Ordinal); w.Write(p.HasDefault); if (p.HasDefault) @@ -261,54 +261,54 @@ public static void Write(BinaryWriter w, ModuleInterface iface) w.Write(iface.Structs.Count); foreach (var s in iface.Structs) { - EcmFormat.WriteUtf8(w, s.Name); + EcsContainer.WriteUtf8(w, s.Name); w.Write(s.Fields.Count); foreach (var fld in s.Fields) { - EcmFormat.WriteUtf8(w, fld.Name); - EcmFormat.WriteUtf8(w, fld.TypeName); + EcsContainer.WriteUtf8(w, fld.Name); + EcsContainer.WriteUtf8(w, fld.TypeName); } } w.Write(iface.ILNames.Count); foreach (var il in iface.ILNames) - EcmFormat.WriteUtf8(w, il); + EcsContainer.WriteUtf8(w, il); w.Write(iface.Dependencies.Count); foreach (var d in iface.Dependencies) { - EcmFormat.WriteUtf8(w, d.Name); - EcmFormat.WriteUtf8(w, d.InterfaceHash); + EcsContainer.WriteUtf8(w, d.Name); + EcsContainer.WriteUtf8(w, d.InterfaceHash); } } public static ModuleInterface Read(BinaryReader r) { - var name = EcmFormat.ReadUtf8(r); - var version = EcmFormat.ReadUtf8(r); - var hash = EcmFormat.ReadUtf8(r); + var name = EcsContainer.ReadUtf8(r); + var version = EcsContainer.ReadUtf8(r); + var hash = EcsContainer.ReadUtf8(r); bool hasInit = r.ReadBoolean(); - int funcCount = EcmFormat.ReadCount(r); + int funcCount = EcsContainer.ReadCount(r); var funcs = new List(funcCount); for (int i = 0; i < funcCount; i++) { - var fname = EcmFormat.ReadUtf8(r); - var ret = EcmFormat.ReadUtf8(r); + var fname = EcsContainer.ReadUtf8(r); + var ret = EcsContainer.ReadUtf8(r); bool isExtern = r.ReadBoolean(); string? lib = null, extName = null; if (isExtern) { - lib = EcmFormat.ReadUtf8(r); - extName = EcmFormat.ReadUtf8(r); + lib = EcsContainer.ReadUtf8(r); + extName = EcsContainer.ReadUtf8(r); } int declLine = r.ReadInt32(); - int paramCount = EcmFormat.ReadCount(r); + int paramCount = EcsContainer.ReadCount(r); var ps = new List(paramCount); for (int p = 0; p < paramCount; p++) { - var pname = EcmFormat.ReadUtf8(r); - var ptype = EcmFormat.ReadUtf8(r); + var pname = EcsContainer.ReadUtf8(r); + var ptype = EcsContainer.ReadUtf8(r); int ordinal = r.ReadByte(); bool hasDefault = r.ReadBoolean(); object? def = hasDefault ? ReadDefault(r) : null; @@ -326,27 +326,27 @@ public static ModuleInterface Read(BinaryReader r) }); } - int structCount = EcmFormat.ReadCount(r); + int structCount = EcsContainer.ReadCount(r); var structs = new List(structCount); for (int i = 0; i < structCount; i++) { - var sname = EcmFormat.ReadUtf8(r); - int fieldCount = EcmFormat.ReadCount(r); + var sname = EcsContainer.ReadUtf8(r); + int fieldCount = EcsContainer.ReadCount(r); var fields = new List(fieldCount); for (int f = 0; f < fieldCount; f++) - fields.Add(new ExportedField { Name = EcmFormat.ReadUtf8(r), TypeName = EcmFormat.ReadUtf8(r) }); + fields.Add(new ExportedField { Name = EcsContainer.ReadUtf8(r), TypeName = EcsContainer.ReadUtf8(r) }); structs.Add(new ExportedStruct { Name = sname, Fields = fields }); } - int ilCount = EcmFormat.ReadCount(r); + int ilCount = EcsContainer.ReadCount(r); var ilNames = new List(ilCount); for (int i = 0; i < ilCount; i++) - ilNames.Add(EcmFormat.ReadUtf8(r)); + ilNames.Add(EcsContainer.ReadUtf8(r)); - int depCount = EcmFormat.ReadCount(r); + int depCount = EcsContainer.ReadCount(r); var deps = new List(depCount); for (int i = 0; i < depCount; i++) - deps.Add(new ModuleDependency { Name = EcmFormat.ReadUtf8(r), InterfaceHash = EcmFormat.ReadUtf8(r) }); + deps.Add(new ModuleDependency { Name = EcsContainer.ReadUtf8(r), InterfaceHash = EcsContainer.ReadUtf8(r) }); return new ModuleInterface { @@ -378,7 +378,7 @@ static void WriteDefault(BinaryWriter w, object? value) break; case string v: w.Write((byte)3); - EcmFormat.WriteUtf8(w, v); + EcsContainer.WriteUtf8(w, v); break; case bool v: w.Write((byte)4); @@ -396,7 +396,7 @@ static void WriteDefault(BinaryWriter w, object? value) 0 => null, 1 => r.ReadInt32(), 2 => r.ReadDouble(), - 3 => EcmFormat.ReadUtf8(r), + 3 => EcsContainer.ReadUtf8(r), 4 => r.ReadBoolean(), var t => throw new BytecodeException(new[] { new BytecodeDiagnostic($"默认值载荷标签非法 {t}", null, 0) }), }; diff --git a/src/EasyCon.Script/Bytecode/SlotAllocator.cs b/src/EasyCon.Script/Bytecode/SlotAllocator.cs index c6cc1d8e..9197d5d3 100644 --- a/src/EasyCon.Script/Bytecode/SlotAllocator.cs +++ b/src/EasyCon.Script/Bytecode/SlotAllocator.cs @@ -34,6 +34,320 @@ or SsaOp.StickAction or SsaOp.StickPress or SsaOp.Return _ => true, }; + /// P1b phi 臂二地址合并:跨块指令 X(唯一读取是某条 phi 臂)→ 与该 phi 共槽, + /// 边副本 dst==src 自消(EmitParallelCopy 跳过)。 + readonly Dictionary _coalesced = new(); + /// P2′ CmpJ 融合:已标记为「比较跳转融合」的 BranchCondition(不落槽、EmitInst 跳过)。 + readonly HashSet _fusedCmp = new(); + + /// CmpJ kind 码 = typeBlock*6 + op;typeBlock:0=i32,1=u32,2=f64,3=i64; + /// op:0=Eq,1=Neq,2=Lt,3=Le,4=Gt,5=Ge。非比较族返回 null。 + internal static int? CmpJKind(SsaOp op) => op switch + { + SsaOp.EqInt => 0, + SsaOp.NeqInt => 1, + SsaOp.LtInt => 2, + SsaOp.LeqInt => 3, + SsaOp.GtInt => 4, + SsaOp.GeqInt => 5, + SsaOp.EqUInt => 6, + SsaOp.NeqUInt => 7, + SsaOp.LtUInt => 8, + SsaOp.LeqUInt => 9, + SsaOp.GtUInt => 10, + SsaOp.GeqUInt => 11, + SsaOp.EqDouble => 12, + SsaOp.NeqDouble => 13, + SsaOp.LtDouble => 14, + SsaOp.LeqDouble => 15, + SsaOp.GtDouble => 16, + SsaOp.GeqDouble => 17, + SsaOp.EqUInt64 => 18, + SsaOp.NeqUInt64 => 19, + SsaOp.LtUInt64 => 20, + SsaOp.LeqUInt64 => 21, + SsaOp.GtUInt64 => 22, + SsaOp.GeqUInt64 => 23, + SsaOp.EqBool => 0, + SsaOp.NeqBool => 1, // Bool 槽载荷 = I32 0/1,按 i32 比较 + SsaOp.EqByte => 0, + SsaOp.NeqByte => 1, + SsaOp.LtByte => 2, + SsaOp.LeqByte => 3, + SsaOp.GtByte => 4, + SsaOp.GeqByte => 5, + SsaOp.EqPtr => 18, + SsaOp.NeqPtr => 19, + SsaOp.LtPtr => 20, + SsaOp.LeqPtr => 21, + SsaOp.GtPtr => 22, + SsaOp.GeqPtr => 23, // Ptr = I64 载荷 + _ => null, + }; + + /// CmpJ kind 极性反转(Eq↔Neq,Lt↔Ge,Le↔Gt;IEEE 下 != ≡ !(==),f64 亦精确)。 + internal static int InvertCmpJKind(int k) => (k / 6) * 6 + (k % 6) switch { 0 => 1, 1 => 0, 2 => 5, 3 => 4, 4 => 3, _ => 2 }; + + /// + /// P1b phi 臂二地址合并:跨块指令 X 的全函数唯一读取若是某条 phi 臂(X 定义在前驱 P、 + /// 臂属 P 的终结符副本),让 X 与该 φ 共槽——P 体内 X 直接把结果写入 φ 槽, + /// 边副本 dst==src 被 EmitParallelCopy 消除,每条循环携带值省 1 条 Move。 + /// 守卫(违一即放弃合并,保持原副本形态): + /// (a) X 非常量、非 ForStep(终结符独占槽)、未参与其他合并; + /// (b) P 内 X 之后无指令读 φ(Arg0/Arg1/ExtraArgs)——X 覆写后旧值不得再被读; + /// (c) P 的 BranchCondition 与 P 各后继 phi 的其他臂不引用 φ(终结符并行副本 + /// 按 Slot 读旧值,覆写后会拿到新值); + /// (d) φ 为活 φ(已定槽)。 + /// + void TryCoalescePhiArm(SsaValue phi, SsaBlock succ) + { + if (phi.ExtraArgs == null) + return; + bool zeroPhi = Environment.GetEnvironmentVariable("ECS_NO_ZERO_PHI") != "1"; + if (!zeroPhi) + { + // 关闭零 φ 扩展(完整旁路):恢复原「单臂 + 常量/ForStep 排除」保守形态(回归二分用) + for (int i = 0; i < phi.ExtraArgs.Count && i < succ.Predecessors.Count; i++) + { + var arm0 = phi.ExtraArgs[i]; + if (arm0 == null || arm0.IsConstant || arm0.Op == SsaOp.ForStep) + return; + } + } + for (int i = 0; i < phi.ExtraArgs.Count && i < succ.Predecessors.Count; i++) + { + var arm = phi.ExtraArgs[i]; + var pred = succ.Predecessors[i]; + if (arm == null || arm == phi || arm.IsConstant) + continue; + // (a') ForStep 臂允许合并:ForStep 的自增目标槽改为直写 φ 槽(C=φ 槽), + // 回边副本 dst==src 被 EmitParallelCopy 自消——Lua FORLOOP 式就地更新。 + // 守卫:forStep 的全函数数据读取恰为该臂 1 次(终结符形态不算数据读取)。 + if (arm.Op == SsaOp.ForStep) + { + if (_coalesced.ContainsKey(arm) || _slots.ContainsKey(arm)) + continue; + if (_totalReads.TryGetValue(arm, out var ft) && ft != 1) + continue; + if (pred.BranchCondition == arm) + { + _coalesced[arm] = phi; + _slots[arm] = _slots[phi]; + ScrubPooled(arm); + } + continue; + } + if (_coalesced.ContainsKey(arm) || _slots.ContainsKey(arm)) + continue; + if (arm.Block != pred) + continue; + if (_totalReads.TryGetValue(arm, out var t) && t != 1) + continue; + if (pred.BranchCondition == phi) + continue; // (c) 终结符条件读 φ 槽 + + // (b) P 内 X 之后读 φ → 放弃 + var instrs = pred.Instructions; + int xIdx = instrs.IndexOf(arm); + bool unsafeRead = false; + for (int k = xIdx + 1; k < instrs.Count && !unsafeRead; k++) + { + var inst = instrs[k]; + if (inst.Arg0 == phi || inst.Arg1 == phi) + unsafeRead = true; + else if (inst.ExtraArgs != null && inst.ExtraArgs.Contains(phi)) + unsafeRead = true; + } + if (unsafeRead) + continue; + + // (c) P 的各后继 phi 的其他臂引用 φ → 放弃 + foreach (var s2 in pred.GetSuccessors()) + { + foreach (var phi2 in s2.Phis) + { + if (phi2 == phi || phi2.ExtraArgs == null) + continue; + foreach (var a2 in phi2.ExtraArgs) + { + if (a2 == phi) + { + unsafeRead = true; + break; + } + } + if (unsafeRead) break; + } + if (unsafeRead) break; + } + if (unsafeRead) + continue; + + _coalesced[arm] = phi; + _slots[arm] = _slots[phi]; + ScrubPooled(arm); // 臂若已按池化分类(其块先于 φ 块处理),共享 φ 独占槽后须退出池化结算 + // 零 φ 拷贝:遍历全部臂(各臂守卫独立成立;同 φ 多臂在不同前驱边执行, + // 分时写同一槽即 φ 语义本身)。常量臂不合并——块首物化在 φ 槽上会覆写 + // 同迭代仍活跃的旧值。 + } + } + + /// 把已池化分类的值退出池化结算(合并共享 φ 槽后,其“末次读取归还” + /// 会把 φ 的独占槽放进空闲池——寄存器筛语料实证的槽位污染源)。 + void ScrubPooled(SsaValue v) + { + _pooled.Remove(v); + _releaseLeft.Remove(v); + } + + /// + /// 循环不变量常量外提(docs/VM2.md §4.3 块首惰性物化的循环修正): + /// 常量按「使用块块首」物化,落在循环头/体时每轮重发——FOR 尾融合后回边极短, + /// 2-3 条块首 LoadI 占每轮指令数的近半。本遍把「全部使用块都被某循环 preheader + /// 支配」的常量改挂到 preheader 块首物化一次,循环内读取零重物化。 + /// + /// 判定 = 回边(t→h 且 h 支配 t)→ 自然循环体(从 t 逆行不经 h)→ 唯一非循环 + /// 前驱即 preheader;支配性由 Dominators 树核对(preheader 支配使用块 ⟹ 运行期 + /// 物化必先于读取,与发射顺序无关)。 + /// + /// 槽位 = 可共享组:外提常量若各占专属槽会膨胀帧区(MCU 固定池敏感)。两常量 + /// 共享一槽的充要守卫:对每个有序对 (x,y),从 P_y 出发不经 P_x 块首不可达 c_x 的 + /// 任何使用块(BFS 判定)——否则 y 的物化覆写后 x 的读取拿到陈值。同挂点不共享。 + /// 已被外层循环外提的常量跳过(_hoisted 去重;外层 preheader 是更优挂点)。 + /// + void HoistLoopInvariantConsts() + { + if (_useInfo.Count == 0 || Environment.GetEnvironmentVariable("ECS_DISABLE_CONST_HOIST") == "1") + return; + var dom = Dominators.Compute(_fn); + + // 1. 收集外提对(const, preheader),快照各自使用块集合(供共享判定) + var pairs = new List<(SsaValue Const, SsaBlock Preheader)>(); + var usesOf = new Dictionary>(); + foreach (var block in _fn.Blocks) + { + foreach (var header in block.GetSuccessors()) + { + if (!dom.Dominates(header, block)) + continue; // 非回边 + + // 自然循环体:{header} ∪ 从 block 逆行不经 header 可达的块 + var body = new HashSet { header }; + var stack = new Stack(); + if (body.Add(block)) + stack.Push(block); + while (stack.Count > 0) + foreach (var p in stack.Pop().Predecessors) + if (body.Add(p)) + stack.Push(p); + + // preheader = header 的唯一非循环前驱(不唯一则放外提:挂点不唯一即不安全) + var outside = header.Predecessors.Where(p => !body.Contains(p)).ToList(); + if (outside.Count != 1) + continue; + var preheader = outside[0]; + + // 候选 = 循环体块登记的常量;只外提「脊柱」使用(header 支配使用块且 + // 使用块支配回边源 ⟹ 每轮必经)——稀有分支内的常量外提无每轮收益, + // 徒占共享槽;全部使用块都被 preheader 支配仍是大前提 + var tail = block; + foreach (var b in body) + { + var info = Info(b); + for (int i = info.Consts.Count - 1; i >= 0; i--) + { + var c = info.Consts[i]; + if (_hoisted.Contains(c)) + continue; // 已挂更外层 preheader(更优挂点) + var useBlocks = new HashSet(); + bool allDominated = true, allSpine = true; + foreach (var kv in _useInfo) + { + if (!kv.Value.Consts.Contains(c)) + continue; + useBlocks.Add(kv.Key); + if (!dom.Dominates(preheader, kv.Key)) + { + allDominated = false; + break; + } + if (body.Contains(kv.Key) + && !(dom.Dominates(header, kv.Key) && dom.Dominates(kv.Key, tail))) + allSpine = false; + } + if (!allDominated || !allSpine) + continue; + foreach (var kv in _useInfo) + kv.Value.Consts.Remove(c); + pairs.Add((c, preheader)); + usesOf[c] = useBlocks; + _hoisted.Add(c); + } + } + } + } + if (pairs.Count == 0) + return; + if (Environment.GetEnvironmentVariable("ECX_HOIST_TRACE") == "1") + foreach (var (c, p) in pairs) + Console.Error.WriteLine($"[hoist] v{c.Id} -> preheader b{p.Id} (fn={_fn.Symbol.Name})"); + + // 2. 可共享分组定槽(贪心:与既有组两两做双向可达性守卫) + var groupConsts = new List>(); + var groupPres = new List>(); + var groupSlots = new List(); + foreach (var (c, p) in pairs) + { + int g = 0; + for (; g < groupConsts.Count; g++) + { + bool safe = true; + for (int i = 0; i < groupConsts[g].Count && safe; i++) + safe = SafeToShare(groupPres[g][i], groupConsts[g][i], p, usesOf) + && SafeToShare(p, c, groupPres[g][i], usesOf); + if (safe) + break; + } + if (g == groupConsts.Count) + { + groupConsts.Add(new List()); + groupPres.Add(new List()); + groupSlots.Add(_next++); // 新组一个共享专属槽 + } + groupConsts[g].Add(c); + groupPres[g].Add(p); + _slots[c] = groupSlots[g]; // 加入既有组:取组槽(非最新组槽) + // 挂点登记:EmitBlocks 在 preheader 块首物化一次 + if (!_hoistedConsts.TryGetValue(p, out var list)) + _hoistedConsts[p] = list = new List(); + list.Add(c); + } + } + + /// 共享守卫:从 py 块首物化后(含 py 自身指令),不经 px 块首可达 cx 的 + /// 使用块 ⟹ py 的物化覆写共享槽后 cx 读到陈值 → 不可共享。 + static bool SafeToShare(SsaBlock px, SsaValue cx, SsaBlock py, + Dictionary> usesOf) + { + if (px == py || usesOf[cx].Contains(py)) + return false; // 同挂点顺序覆写 / py 自身读取 cx 在其物化之后 + var visited = new HashSet(); + var stack = new Stack(); + foreach (var s in py.GetSuccessors()) + if (s != px && visited.Add(s)) + stack.Push(s); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (usesOf[cx].Contains(cur)) + return false; + foreach (var s in cur.GetSuccessors()) + if (s != px && visited.Add(s)) + stack.Push(s); + } + return true; + } + /// /// 槽位分配(docs/VM2.md §4.3):存活局部压缩重编号 + 跨块/块内分类 + 块内槽池 + 常量块首惰性物化。 /// 优化删除的局部不占帧槽(步骤 0 重编号);phi 结果恒跨块(写入发生在前驱边的并行副本); @@ -50,10 +364,29 @@ or SsaOp.StickAction or SsaOp.StickPress or SsaOp.Return /// void AssignSlots() { + // 参数窗仅保留实际 NParams;出站窗口按本函数的最大调用/数组需求保留。 + // 所有出站值先在分配区求值,再搬入隔离窗口,嵌套调用和参数重排不会覆写存活值。 + _stagingBase = EcsRegisters.ArgBase + _symbol.Parameters.Length; + foreach (SsaBlock block in _fn.Blocks) + foreach (SsaValue inst in block.Instructions) + { + int count = inst.Op switch + { + SsaOp.Call or SsaOp.StaticCall => CallArgs(inst).Count, + SsaOp.ArrayInit => IsTemplateArrayInit(inst) + ? EcsRegisters.ArrayStagingCount : TemplateElements(inst).Count, + _ => 0, + }; + _stagingCount = Math.Max(_stagingCount, count); + } + _allocBase = _stagingBase + _stagingCount; + if (_allocBase > EcsRegisters.MaxRegisters) + throw Fail($"寄存器溢出:函数 '{_symbol.Name}' 的参数窗和出站窗口需求 {_allocBase} 超过帧上限 {EcsRegisters.MaxRegisters}"); + // 0. 存活局部重编号(编码期槽位回收):局部读值全走 SSA(构建期 mem2reg), // StoreLocal 仅参数 store 发射(EmitInst;TRE 回边写参数帧槽,entry 循环头重读)。 - // 帧槽读取者 = 参数入口 LoadLocal(Call ABI 播种);仅给仍被引用的符号保留槽位; - // 参数窗 [0, NParams) 恒等映射保留,其余(防御性:若未来出现非参数 LoadLocal)压缩到参数窗之后。 + // 帧槽读取者 = 参数入口 LoadLocal(Call ABI 播种);参数窗映射到 R1 起的 NParams 个寄存器 + // (Call 深拷贝播种,见 EcsRegisters);防御性非参数局部(若未来出现)从通用区起。 var liveLocalSlots = new SortedSet(); foreach (var block in _fn.Blocks) foreach (var inst in block.Instructions) @@ -63,15 +396,15 @@ void AssignSlots() else if (inst.Op == SsaOp.StoreLocal && inst.Aux is ParamSymbol ps) liveLocalSlots.Add(ps.Slot.Index); } - int compacted = _symbol.Parameters.Length; + int nextLocal = _allocBase; foreach (var oldIdx in liveLocalSlots) { if (oldIdx < _symbol.Parameters.Length) - _localSlotRemap[oldIdx] = oldIdx; + _localSlotRemap[oldIdx] = EcsRegisters.ArgBase + oldIdx; else - _localSlotRemap[oldIdx] = compacted++; + _localSlotRemap[oldIdx] = nextLocal++; } - _next = compacted; + _next = nextLocal; // 1. 使用点收集 + 结算计划推导(同源):操作数/分支条件记在使用块;phi 臂记在对应前驱 foreach (var block in _fn.Blocks) @@ -135,6 +468,15 @@ void AssignSlots() { RecordRead(block.BranchCondition, block); terminatorReleases.Add(block.BranchCondition); + // ForStep 终结符:src/limit 的读取随指令执行发生(EmitTerminatorShape), + // 归入终结符计划结算——否则池化 limit 常量的块内计数永不归零(槽泄漏) + if (block.BranchCondition.Op == SsaOp.ForStep) + { + if (block.BranchCondition.Arg0 != null) + terminatorReleases.Add(block.BranchCondition.Arg0); + if (block.BranchCondition.Arg1 != null) + terminatorReleases.Add(block.BranchCondition.Arg1); + } } foreach (var successor in SuccessorEdges(block)) { @@ -152,9 +494,44 @@ void AssignSlots() _terminatorReleases[block] = [.. terminatorReleases]; } - // 2. 分类:跨块值专用槽;块内值登记池化;常量已按块登记 + // 1.4 P2′ CmpJ 融合标记:BranchCondition 为比较族、定义在本块、唯一读取是终结符 + // → 比较不落槽(EmitInst 跳过、不占池),由 EmitTerminatorShape 发单条 CmpJ; + // 其操作数读取结算从指令计划移交终结符计划(CmpJ 执行时才读槽) + foreach (var block in _fn.Blocks) + { + if (block.BranchCondition is not { } bc || _fusedCmp.Contains(bc)) + continue; + if (bc.Block != block || bc.IsConstant || bc.Arg0 == null || bc.Arg1 == null) + continue; + if (CmpJKind(bc.Op) is null) + continue; + if (!_totalReads.TryGetValue(bc, out var tr) || tr != 1) + continue; + _fusedCmp.Add(bc); + if (_instReleases.Remove(bc, out var rel)) + { + var term = _terminatorReleases.TryGetValue(block, out var t) ? t.ToList() : []; + term.AddRange(rel); + _terminatorReleases[block] = [.. term]; + } + } + + // 1.5 循环不变量常量外提:消费步骤 1 的使用登记(_useInfo),改挂外提常量到 + // preheader 块首(从各使用块 Consts 移除)——须在分类/发射前完成 + HoistLoopInvariantConsts(); + + // 1.6 循环携带活性:值「定义块不在某自然循环内、却有读取落在该循环体内」时, + // 末次静态读取结算不安全——循环下一轮会重读该值,而槽位可能已让给别人 + // (线性扫描对回边活性的盲区,参考 regalloc 的 loop-nest 感知)。此类值 + // 退回独占槽(def ∈ L ⟹ 定义随循环体重执行,读取前的本轮定义先写回,池化仍安全)。 + var loopExclusive = CollectLoopCarriedValues(); + + // 2. 分类:φ 结果/ForStep 目标独占槽(跨迭代被边副本/分支原地写,池化会在循环 + // 存续期间把槽位让给他人);其余 needs-slot 值(块内与跨块统一)全部池化—— + // 生命周期由读取点结算驱动(参考线性扫描寄存器分配:值在定义发射时取槽、 + // 末次静态读取结算后归还;循环内的后续执行不会再遇到已发射完毕的读取点)。 + // 常量仍按使用块块首物化(每块独立槽 + 块内计数归还)。 bool hasPhi = false, hasNeq = false, hasTpl = false; - int maxArity = 1; foreach (var block in _fn.Blocks) { var info = Info(block); @@ -163,59 +540,71 @@ void AssignSlots() if (!_totalReads.ContainsKey(phi)) continue; // 死 φ(防线 1):全函数无读取——不占槽,前驱边不产生副本(EmitEdgeCopies 跳过) _slots[phi] = _next++; + _exclusiveSlots.Add(_slots[phi]); hasPhi = true; + TryCoalescePhiArm(phi, block); } foreach (var inst in block.Instructions) { + // P2′ 融合比较:无结果槽(CmpJ 内联消费),跳过分类 + if (_fusedCmp.Contains(inst)) + continue; + // P1b 合并指令:槽已随 phi 定槽(共享),跳过独立分类 + if (_coalesced.ContainsKey(inst)) + continue; + // ForStep:分支内嵌的自增目标槽必须独占(每迭代原地写),恒跨块专属槽; + // 零 φ 拷贝合并例外:目标槽 = φ 槽(C 直写 φ 槽,回边副本自消) + if (inst.Op == SsaOp.ForStep) + { + _slots[inst] = _coalesced.TryGetValue(inst, out var phiSlot) ? _slots[phiSlot] : _next++; + _exclusiveSlots.Add(_slots[inst]); + continue; + } if (inst.Op is SsaOp.NeqInt or SsaOp.NeqUInt or SsaOp.NeqDouble or SsaOp.NeqUInt64 or SsaOp.NeqBool or SsaOp.NeqByte or SsaOp.NeqString or SsaOp.NeqPtr) hasNeq = true; - // 常量模板降级:元素不占槽也不进 staging(maxArity 不计入),结果槽照常分类 - bool templateInit = IsTemplateArrayInit(inst); - if (templateInit) + // 常量模板降级:元素不占槽也不进 staging(结果槽照常池化) + if (IsTemplateArrayInit(inst)) hasTpl = true; - int arity = templateInit ? 0 : ArityOf(inst); - if (arity > maxArity) maxArity = arity; - if (inst.IsConstant) { _pooled.Add(inst); continue; } // 常量池化:Consts 已在步骤 1 登记 + if (inst.IsConstant) + { + if (!_hoisted.Contains(inst)) + _pooled.Add(inst); // 常量池化:Consts 已在步骤 1 登记(块首物化) + // 外提常量:共享组槽位已在 HoistLoopInvariantConsts 定槽,跳过 + continue; + } if (!NeedsSlot(inst.Op)) continue; - int readsHere = info.UseCount.TryGetValue(inst, out var c) ? c : 0; - int totalReads = _totalReads.TryGetValue(inst, out var t) ? t : 0; - if (totalReads > readsHere) - _slots[inst] = _next++; // 跨块:专用槽 - else + if (loopExclusive.Contains(inst)) { - _pooled.Add(inst); // 块内:定义时入池 + _slots[inst] = _next++; // 循环携带活性(见 1.6):独占 + _exclusiveSlots.Add(_slots[inst]); + continue; } + + // 全池化:不再区分块内/跨块——跨块槽的独占占用是 $eval 顶层脚本 + // 寄存器高水位的根因;末次读取结算(ReleaseDying 全函数计数)回收 + _pooled.Add(inst); + _releaseLeft[inst] = _totalReads.TryGetValue(inst, out var t) ? t : 0; } } - if (hasNeq) _neqTemp = _next++; - if (hasPhi) _scratch = _next++; - if (hasTpl) - { - _tplGuard = _next++; - _tplArr = _next++; - _tplChunk = _next++; - } - _stagingBase = _next; - var stagingCount = maxArity; + if (hasNeq) { _neqTemp = _next++; _exclusiveSlots.Add(_neqTemp); } + if (hasPhi) { _scratch = _next++; _exclusiveSlots.Add(_scratch); } if (hasTpl) { - // 常量模板分块构建:staging 区须容纳一个分块(NewArrV 的 C:8 上限 255 之内取满) - _tplChunkSize = Math.Min(TemplateChunkSize, 254 - _stagingBase); - if (_tplChunkSize <= 0) - throw Fail("常量模板构建 staging 槽不足(函数局部槽过多)"); - if (stagingCount < _tplChunkSize) - stagingCount = _tplChunkSize; + _tplGuard = _next++; _exclusiveSlots.Add(_tplGuard); + _tplArr = _next++; _exclusiveSlots.Add(_tplArr); + _tplChunk = _next++; _exclusiveSlots.Add(_tplChunk); } - _receiveSlot = _stagingBase + stagingCount; - _next += stagingCount + 1; - _maxArity = maxArity; - // 池区在所有保留槽之后生长 - _poolNext = _next; + // 出站窗口与数组分块共用;参数与分配区均不与它重叠。 + _tplChunkSize = Math.Min(TemplateChunkSize, _stagingCount); + + // 池区自独占区之后生长(_next 已含全部独占槽;≥ _allocBase);帧长(NSlots)= + // 发射结束后池区高水位(≤ MaxRegisters,Run 期校验) + _poolNext = Math.Max(_next, _allocBase); } /// @@ -242,7 +631,7 @@ SsaOp.KeyPress or SsaOp.Wait /// static bool IsDirectlyEmitted(SsaOp op) => op switch { - SsaOp.Phi or SsaOp.CondBranch or SsaOp.Branch or SsaOp.Return or SsaOp.Nop => false, + SsaOp.Phi or SsaOp.CondBranch or SsaOp.Branch or SsaOp.Return or SsaOp.Nop or SsaOp.ForStep => false, SsaOp.ConstBool or SsaOp.ConstByte or SsaOp.ConstInt or SsaOp.ConstUInt or SsaOp.ConstUInt64 or SsaOp.ConstDouble or SsaOp.ConstString or SsaOp.ConstPtr => false, _ => true, @@ -264,6 +653,59 @@ static IEnumerable SuccessorEdges(SsaBlock block) } } + /// 循环携带活性判定(步骤 1.6):收集「定义块不在其读取所在自然循环体内」的 + /// 非常量值。这类值的读取会在循环的后续迭代中再次执行,而池化结算已在末次静态读取 + /// 处把槽位让给别人——线性扫描对回边活性的盲区(参考 regalloc 的 loop-nest 感知)。 + /// 定义块 ∈ 循环体 ⟹ 定义随每轮迭代重执行、先于本轮读取写回,池化仍安全。 + HashSet CollectLoopCarriedValues() + { + var result = new HashSet(); + if (_fn.Blocks.Count == 0) + return result; + + var dom = Dominators.Compute(_fn); + + // 值 → 读取块集合(来自步骤 1 的使用登记;φ 臂读取登记在前驱块) + var readBlocks = new Dictionary>(); + foreach (var kv in _useInfo) + foreach (var v in kv.Value.UseCount.Keys) + { + if (v.IsConstant) + continue; + if (!readBlocks.TryGetValue(v, out var list)) + readBlocks[v] = list = new List(); + list.Add(kv.Key); + } + + foreach (var tail in _fn.Blocks) + { + foreach (var header in tail.GetSuccessors()) + { + if (!dom.Dominates(header, tail)) + continue; // 非回边 + + // 自然循环体:{header} ∪ 从 tail 逆行不经 header 可达的块 + var body = new HashSet { header }; + var stack = new Stack(); + if (body.Add(tail)) + stack.Push(tail); + while (stack.Count > 0) + foreach (var p in stack.Pop().Predecessors) + if (body.Add(p)) + stack.Push(p); + + foreach (var (v, blocks) in readBlocks) + { + if (result.Contains(v) || v.Block == null || body.Contains(v.Block)) + continue; + if (blocks.Any(body.Contains)) + result.Add(v); + } + } + } + return result; + } + void RecordRead(SsaValue v, SsaBlock reader) { _totalReads[v] = _totalReads.TryGetValue(v, out var t) ? t + 1 : 1; @@ -282,44 +724,105 @@ void RecordRead(SsaValue v, SsaBlock reader) int PoolAlloc() { - if (_poolFree.Count > 0) + // 取用守卫:非常量归还槽只在「归还块支配当前发射块」时复用——归还块执行 ⟹ + // 其末次读取先于本块的写入;支配不成立则槽位留待后续受支配块(常量归还槽 + // 无守卫:块首物化自愈,维持原行为)。 + if (_poolFree.Count > 0 && _freeSlotRelease.Count > 0) + _dom ??= Dominators.Compute(_fn); + for (int i = _poolFree.Count - 1; i >= 0; i--) { - var s = _poolFree[_poolFree.Count - 1]; - _poolFree.RemoveAt(_poolFree.Count - 1); + var s = _poolFree[i]; + if (_freeSlotRelease.TryGetValue(s, out var release) && !_dom!.Dominates(release, _emittingBlock)) + continue; + _poolFree.RemoveAt(i); + _freeSlotRelease.Remove(s); +#if DEBUG + Debug.Assert(!_exclusiveSlots.Contains(s), $"池化分配撞上独占槽 {s}(合并臂结算污染)"); +#endif return s; } - return _poolNext++; + var fresh = _poolNext++; + if (fresh >= EcsRegisters.MaxRegisters) + throw Fail($"寄存器溢出:函数 '{_symbol.Name}' 的寄存器需求超过帧上限 {EcsRegisters.MaxRegisters}(拆分函数或简化深表达式)"); + return fresh; } - /// 读取点结算:池化值在读取块内的剩余计数减一,归零即归还槽位。 - /// 计数按块分段(UseCount)——跨块常量在各使用块有独立槽与独立计数, - /// 若用全函数单计数器,首块槽位永远等不到归零(泄漏)。 + /// 读取点结算:池化值在末次静态读取发射后归还槽位。 + /// 常量 = 块内计数按块分段(跨块常量在各使用块有独立槽与独立计数,若用全函数 + /// 单计数器,首块槽位永远等不到归零——泄漏);非常量值 = 全函数读取计数 + /// (跨块值不再独占槽位:读取点只有发射序列中登记过的那些,末次结算后 + /// 循环再执行也不会遇到已发射完毕的读取点,归还即安全)。 /// 只由结算计划回放调用:计划与登记同点推导,立即数消费天然无键, /// 不会出现「无读取的消费」抢走同块真实读取登记的失衡。 void ReleaseDying(SsaBlock block, SsaValue? v) { if (v == null || !_pooled.Contains(v)) return; - var info = Info(block); - if (!info.UseCount.TryGetValue(v, out var left)) + if (v.IsConstant) + { + var info = Info(block); + if (!info.UseCount.TryGetValue(v, out var left)) + return; + left--; + info.UseCount[v] = left; + if (left == 0) + _poolFree.Add(_slots[v]); return; - left--; - info.UseCount[v] = left; - Debug.Assert(left >= 0, "读取结算次数超过登记次数"); - if (left == 0) - _poolFree.Add(_slots[v]); + } + if (!_releaseLeft.TryGetValue(v, out var gleft)) + return; // 合并臂等共享槽位(φ 槽)不参与结算 + gleft--; + _releaseLeft[v] = gleft; + Debug.Assert(gleft >= 0, "读取结算次数超过登记次数"); + if (gleft == 0 && _slots.TryGetValue(v, out var slot)) + { + Debug.Assert(!_exclusiveSlots.Contains(slot), $"池化值归还独占槽 {slot}(合并臂未 scrub)"); + if (ReleaseSafeAt(block, v)) + { + _poolFree.Add(slot); + _freeSlotRelease[slot] = block; + } + // 不安全:末次读取块不后支配其余读取块——发射序上更晚的取用者可能 + // 先于本次读取执行(CFG 序 ≠ 发射序),槽位保守保留至函数结束 + } } - static int ArityOf(SsaValue inst) + /// 归还安全性(CFG 活性守卫):末次读取块须后支配该值全部读取块—— + /// 从任一其他读取块出发,不经末次读取块不可达出口。线性扫描的「末次静态读取 + /// 归还」以发射序为执行序,循环体内块重排/回边可倒置二者:复用者先覆写归还槽, + /// 末次读取随即拿到陈值(LoopCarriedLocalRegressionTests 实证)。 + bool ReleaseSafeAt(SsaBlock release, SsaValue v) { - int n = inst.Arg0 != null ? 1 : 0; - if (inst.ExtraArgs != null) n += inst.ExtraArgs.Count; - return inst.Op switch + foreach (var kv in _useInfo) { - SsaOp.Call or SsaOp.StaticCall or SsaOp.ArrayInit => n, - SsaOp.Capture or SsaOp.Ocr or SsaOp.Roi or SsaOp.OcrInit => n, - _ => 0, - }; + if (kv.Key == release || !kv.Value.UseCount.ContainsKey(v)) + continue; + if (!AllPathsPassThrough(kv.Key, release)) + return false; + } + return true; + } + + /// 从 from 出发、不经 via,能否抵达出口(Return/未终结块)。能 → via 不后支配 from。 + bool AllPathsPassThrough(SsaBlock from, SsaBlock via) + { + var visited = new HashSet(); + var stack = new Stack(); + visited.Add(from); + stack.Push(from); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur.IsReturn || !cur.IsTerminated) + return false; + foreach (var s in cur.GetSuccessors()) + { + if (s == via || !visited.Add(s)) + continue; + stack.Push(s); + } + } + return true; } // ---- 常量物化(块首惰性,替代函数级 prologue)---- diff --git a/src/EasyCon.Script/Compilation.cs b/src/EasyCon.Script/Compilation.cs index eb001606..ad5424f0 100644 --- a/src/EasyCon.Script/Compilation.cs +++ b/src/EasyCon.Script/Compilation.cs @@ -11,9 +11,20 @@ namespace EasyCon.Script; -public class ScriptException(string message, int address = 0) : Exception(message) +public class ScriptException : Exception { - public int Address { get; private set; } = address; + public ScriptException(string message, int address = 0) : base(message) + { + Address = address; + } + + public ScriptException(string message, Exception innerException, int address = 0) + : base(message, innerException) + { + Address = address; + } + + public int Address { get; private set; } } /// @@ -71,8 +82,8 @@ public sealed class CompileOptions { /// 外部变量名集合(识图标签等)。 public ImmutableHashSet? ExtVars { get; set; } - /// 启用 obj/ 磁盘缓存(默认 true:compile/MCU 分发路径收益)。 - /// 桌面解释器路径应显式置 false 每次现编(与 v1 等价、无回归,ModuleSystem.md §7)。 + /// 启用 obj/ 磁盘缓存(默认 true:compile/MCU 分发路径收益;现编宿主可置 false, + /// 由进程级 兜底,ModuleSystem.md §7)。 public bool UseDiskCache { get; set; } = true; /// false 时跳过 SSA 优化(DumpIr --raw 用)。 public bool Optimize { get; set; } = true; @@ -88,9 +99,6 @@ public sealed class CompileOptions /// ModuleProjectResult.ProcessCacheHits/Misses。 /// public bool UseProcessCache { get; set; } = true; - /// 旧版语法兼容(v1 PRINT/IF= 语义)。默认 true 保持行为;影响产物 → 进缓存键 - /// (见 )。 - public bool LegacySyntax { get; set; } = true; /// /// 影响编译产物的选项指纹(缓存键成分,docs/ModuleSystem.md §7.2)。 @@ -99,7 +107,12 @@ public sealed class CompileOptions /// public string ProductFingerprint() { - return $"O={(Optimize ? '1' : '0')}|L={(LegacySyntax ? '1' : '0')}|E={Modules.ModuleCacheKeys.ExtVarsKey(ExtVars)}|B={Bytecode.EcsSyscall.AbiRevision}"; + // M = 产物代数:凡「同源码会编出不同产物/语义」的编译器变更(作用域/路径/指令集等) + // 必须 +1,使全部旧缓存按未命中重编,不写迁移。2026-10-06 自 7 重置为 1 + // (无旧版缓存、仅开发测试;旧履历 M4–M7 删除)。M2:参数/出站窗口按函数需求分配。 + // M5:声音接口改为 AUDIO_LOAD/AUDIO_SCORE/AUDIO_UNLOAD。M6:AUDIO_SCORE 返回 INT。 + // M7:合入 SSA 全局提升修复,使旧音频分支的编译缓存失效。 + return $"O={(Optimize ? '1' : '0')}|E={Modules.ModuleCacheKeys.ExtVarsKey(ExtVars)}|B={Bytecode.EcsSyscall.AbiRevision}|M=7"; } } @@ -119,6 +132,18 @@ public static CompileResult CompileSource(string code, CompileOptions? options = return FromProject(project); } + /// 统一链路编译内存源码并保留源文件路径与同目录 lib/ 上下文。 + public static CompileResult CompileSource(string code, string fileName, CompileOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + options ??= new CompileOptions(); + string fullPath = Path.GetFullPath(fileName); + SyntaxTree mainTree = SyntaxTree.Parse(SourceText.From(code, fullPath)); + string scriptDir = Path.GetDirectoryName(fullPath)!; + var project = ProjectCompiler.CompileProject(mainTree, scriptDir, options); + return FromProject(project); + } + /// /// 统一链路编译(脚本文件):Parser → ProjectCompiler(模块图 + lib/ 自动加载) /// → 逐模块独立编译(obj/ 缓存按 options.UseDiskCache)→ EcxPipeline.Link → EcxImage。 @@ -148,6 +173,7 @@ static CompileResult FromProject(ModuleProjectResult project) // MD_AMBIGUOUS_EXPORT 等项目级警告并入诊断流(v1 诊断数组本就含警告) var noLocation = new TextLocation(SourceText.From("", ""), new SourceSpan(0, 0)); diagnostics.AddRange(project.Warnings.Select(w => Diagnostic.Warning(noLocation, w))); + diagnostics = diagnostics.DistinctStable().ToList(); var image = project.Image; return new CompileResult( diff --git a/src/EasyCon.Script/Diagnostic.cs b/src/EasyCon.Script/Diagnostic.cs index 47cb5222..8b01f089 100644 --- a/src/EasyCon.Script/Diagnostic.cs +++ b/src/EasyCon.Script/Diagnostic.cs @@ -3,32 +3,66 @@ namespace EasyCon.Script; +/// 诊断严重级(统一替代历史 IsError/IsWarning 双 bool;诊断结构化 M1,SCRIPT_MODERNIZATION_PLAN.md)。 +public enum DiagnosticSeverity +{ + Info, + Warning, + Error, +} + +/// 诊断附注(相关位置链:主诊断的「另见」/级联原因)。 +public sealed record DiagnosticNote(TextLocation Location, string Message); + public sealed class Diagnostic { - private Diagnostic(bool isError, TextLocation location, string message) + private Diagnostic(DiagnosticSeverity severity, TextLocation location, string message, + string? code, ImmutableArray notes) { - IsError = isError; + Severity = severity; Location = location; Message = message; - IsWarning = !IsError; + Code = code; + Notes = notes; } - public bool IsError { get; } - public bool IsWarning { get; } - public readonly TextLocation Location; - public readonly string Message; + + public DiagnosticSeverity Severity { get; } + /// 错误码(DiagnosticCodes 分配表;null = 未分配——历史散点诊断)。 + public string? Code { get; } + public TextLocation Location { get; } + public string Message { get; } + public ImmutableArray Notes { get; } + + // ---- 历史兼容视图(消费方众多,逐步迁移到 Severity)---- + public bool IsError => Severity == DiagnosticSeverity.Error; + public bool IsWarning => Severity == DiagnosticSeverity.Warning; public string FileName => Location.FileName; + /// 诊断所属编译阶段,供统一去重键和宿主展示使用。 + public string Phase => Code switch + { + not null when Code.StartsWith("ECX00", StringComparison.Ordinal) => "parse", + not null when Code.StartsWith("ECX01", StringComparison.Ordinal) + || Code.StartsWith("ECX02", StringComparison.Ordinal) => "bind", + DiagnosticCodes.BytecodeEmit => "emit", + DiagnosticCodes.ContainerImage => "container", + DiagnosticCodes.Link => "link", + not null when Code.StartsWith("ECX04", StringComparison.Ordinal) => "module", + _ => "compile", + }; + public override string ToString() => Message; - public static Diagnostic Error(TextLocation location, string message) - { - return new Diagnostic(isError: true, location, message); - } - public static Diagnostic Warning(TextLocation location, string message) - { - return new Diagnostic(isError: false, location, message); - } + public static Diagnostic Error(TextLocation location, string message, string? code = null) + => new(DiagnosticSeverity.Error, location, message, code, []); + + public static Diagnostic Warning(TextLocation location, string message, string? code = null) + => new(DiagnosticSeverity.Warning, location, message, code, []); + + /// 附带相关位置链(不可变:返回携带 notes 的新实例)。 + public Diagnostic WithNotes(params ReadOnlySpan notes) + => new(Severity, Location, Message, Code, [.. notes]); } public static class DiagnosticExtensions @@ -42,4 +76,21 @@ public static bool HasErrors(this IEnumerable diagnostics) { return diagnostics.Any(d => d.IsError); } + + /// + /// 按阶段、错误码、文件、范围和消息稳定去重。 + /// 该去重在编译器结果层完成,CLI、GUI、LSP 和测试共享同一诊断集合。 + /// + public static IEnumerable DistinctStable(this IEnumerable diagnostics) + { + HashSet seen = new(StringComparer.Ordinal); + foreach (Diagnostic diagnostic in diagnostics) + { + string key = string.Join("|", diagnostic.Phase, diagnostic.Code ?? "", + diagnostic.FileName, diagnostic.Location.StartLine, diagnostic.Location.StartCharacter, + diagnostic.Location.EndLine, diagnostic.Location.EndCharacter, diagnostic.Message); + if (seen.Add(key)) + yield return diagnostic; + } + } } \ No newline at end of file diff --git a/src/EasyCon.Script/DiagnosticBag.cs b/src/EasyCon.Script/DiagnosticBag.cs index 53ce7aed..0d143629 100644 --- a/src/EasyCon.Script/DiagnosticBag.cs +++ b/src/EasyCon.Script/DiagnosticBag.cs @@ -15,7 +15,8 @@ internal sealed class DiagnosticBag : IEnumerable /// 无源码上下文时为零跨度 + 模块文件名)。两处消费方必须复用本方法,保证重放/包装文本形态一致。 /// public static Diagnostic FromMessage(SyntaxTree? tree, string moduleName, string text) - => Diagnostic.Error(Modules.ModuleLocations.Default(tree), $"[{moduleName}] {text}"); + => Diagnostic.Error(Modules.ModuleLocations.Default(tree), $"[{moduleName}] {text}", + DiagnosticCodes.ModuleCompileFailed); public IEnumerator GetEnumerator() => _diagnostics.GetEnumerator(); @@ -26,234 +27,238 @@ public void AddRange(IEnumerable diagnostics) _diagnostics.AddRange(diagnostics); } - private void ReportWarnning(TextLocation location, string message) + public void Add(Diagnostic diagnostic) { - var diagnostic = Diagnostic.Warning(location, message); _diagnostics.Add(diagnostic); } - private void ReportError(TextLocation location, string message) + private void ReportError(TextLocation location, string message, string code) { - var diagnostic = Diagnostic.Error(location, message); - _diagnostics.Add(diagnostic); + _diagnostics.Add(Diagnostic.Error(location, message, code)); } public void ReportInvalidNumber(TextLocation location, string text) { - var message = $"数字格式不正确: {text}"; - ReportError(location, message); + ReportError(location, $"数字格式不正确: {text}", DiagnosticCodes.InvalidNumber); } public void ReportBadCharacter(TextLocation location, char character) { - var message = $"不识别的字符: '{character}'"; - ReportError(location, message); + ReportError(location, $"不识别的字符: '{character}'", DiagnosticCodes.BadCharacter); } public void ReportUnterminatedString(TextLocation location) { - var message = "字符串没有结束引号"; - ReportError(location, message); + ReportError(location, "字符串没有结束引号", DiagnosticCodes.UnterminatedString); + } + + public void ReportKeywordMustBeLowercase(TextLocation location, string word) + { + ReportError(location, $"关键字 '{word}' 必须全小写", DiagnosticCodes.KeywordMustBeLowercase); + } + + public void ReportArrayConstantRequired(TextLocation location, Token actual) + { + ReportError(location, $"数组初始化只允许常量元素,意外的 <{actual.Value}>", DiagnosticCodes.ArrayConstantRequired); } public void ReportUnexpectedToken(TextLocation location, Token actual, TokenType expectedKind) { - var message = $"需要<{expectedKind}>但是意外的<{actual.Value}>"; - ReportError(location, message); + ReportError(location, $"需要<{expectedKind}>但是意外的<{actual.Value}>", DiagnosticCodes.UnexpectedToken); } public void ReportInvalidImport(TextLocation location, Token modToken) { - var message = $"导入库不存在 :{modToken.Value}"; - ReportError(location, message); + ReportError(location, $"导入库不存在 :{modToken.Value}", DiagnosticCodes.InvalidImport); } public void ReportInvalidExpressionStatement(TextLocation location) { - var message = "无效的表达式语句"; - ReportError(location, message); + ReportError(location, "无效的表达式语句", DiagnosticCodes.InvalidExpressionStatement); } public void ReportInvalidKeyActionStatement(TextLocation location, Token actual) { - var message = $"按键语法不正确 :{actual}"; - ReportError(location, message); + ReportError(location, $"按键语法不正确 :{actual}", DiagnosticCodes.InvalidKeyActionStatement); } + public void ReportInvalidEOF(TextLocation location, Token actual) { - var message = $"期望结束但多余的 :{actual.Value}"; - ReportError(location, message); + ReportError(location, $"期望结束但多余的 :{actual.Value}", DiagnosticCodes.InvalidEOF); } public void ReportTooMuchLoop(TextLocation location) { - var message = "循环层数过多,请优化脚本"; - ReportWarnning(location, message); - ReportError(location, message); + _diagnostics.Add(Diagnostic.Warning(location, "循环层数过多,请优化脚本")); + ReportError(location, "循环层数过多,请优化脚本", DiagnosticCodes.TooMuchLoop); } public void ReportInvalidBreakOrContinue(TextLocation location, Token keyword) { - var message = $"跳出语句 {keyword.Value} 循环层数不足"; - ReportError(location, message); + ReportError(location, $"跳出语句 {keyword.Value} 循环层数不足", DiagnosticCodes.InvalidBreakOrContinue); } public void ReportBadStruct(TextLocation location, string message) { - ReportError(location, message); + ReportError(location, message, DiagnosticCodes.BadStruct); } public void ReportAllPathsMustReturn(TextLocation location) { - ReportError(location, "函数所有路径必须有返回值"); + ReportError(location, "函数所有路径必须有返回值", DiagnosticCodes.AllPathsMustReturn); } public void ReportParameterAlreadyDeclared(TextLocation location, string paramName) { - ReportError(location, $"重复定义的参数名: {paramName}"); + ReportError(location, $"重复定义的参数名: {paramName}", DiagnosticCodes.ParameterAlreadyDeclared); } public void ReportCannotConvert(TextLocation location, ScriptType fromType, ScriptType toType) { - var message = $"类型不匹配:无法将 {fromType} 转换成 {toType}"; - ReportError(location, message); + ReportError(location, $"类型不匹配:无法将 {fromType} 转换成 {toType}", DiagnosticCodes.CannotConvert); } public void ReportVoidFunctionCannotReturn(TextLocation location, FunctionSymbol function, ScriptType type) { - ReportError(location, $"函数 '{function.Name}' 返回类型为 void,不应有返回值"); + ReportError(location, $"函数 '{function.Name}' 返回类型为 void,不应有返回值", DiagnosticCodes.VoidFunctionCannotReturn); } public void ReportFunctionMustReturnValue(TextLocation location, FunctionSymbol function, ScriptType returnType) { - ReportError(location, $"函数 '{function.Name}' 必须返回类型为 {returnType} 的值"); + ReportError(location, $"函数 '{function.Name}' 必须返回类型为 {returnType} 的值", DiagnosticCodes.FunctionMustReturnValue); } public void ReportInvalidConstantExpression(TextLocation location) { - ReportError(location, "常量表达式不正确"); + ReportError(location, "常量表达式不正确", DiagnosticCodes.InvalidConstantExpression); } public void ReportConstantAlreadyDefined(Token constantToken) { - ReportError(constantToken.Location, $"重复定义的常量 '{constantToken.Value}'"); + ReportError(constantToken.Location, $"重复定义的常量 '{constantToken.Value}'", DiagnosticCodes.ConstantAlreadyDefined); } public void ReportCannotAssignToSpecialConstant(Token constantToken) { - ReportError(constantToken.Location, $"特殊常量 '{constantToken.Value}' 不能被赋值"); + ReportError(constantToken.Location, $"特殊常量 '{constantToken.Value}' 不能被赋值", DiagnosticCodes.CannotAssignToSpecialConstant); } public void ReportUnsupportedBinaryOperator(TextLocation location, Token opToken, ScriptType leftType, ScriptType rightType) { - ReportError(location, $"不支持的运算符:{opToken.Value}对于类型 <{leftType}>和<{rightType}>"); + ReportError(location, $"不支持的运算符:{opToken.Value}对于类型 <{leftType}>和<{rightType}>", DiagnosticCodes.UnsupportedBinaryOperator); } public void ReportUnsupportedUnaryOperator(TextLocation location, Token opToken, ScriptType operandType) { - ReportError(location, $"不支持的运算符:{opToken.Value}对于类型 <{operandType}>"); + ReportError(location, $"不支持的运算符:{opToken.Value}对于类型 <{operandType}>", DiagnosticCodes.UnsupportedUnaryOperator); } public void ReportVoidExpressionCannotAssign(TextLocation location) { - ReportError(location, "空值表达式无法赋值"); + ReportError(location, "空值表达式无法赋值", DiagnosticCodes.VoidExpressionCannotAssign); } public void ReportReadOnlyVariable(Token variableToken) { - ReportError(variableToken.Location, $"只读变量无法修改:{variableToken.Value}"); + ReportError(variableToken.Location, $"只读变量无法修改:{variableToken.Value}", DiagnosticCodes.ReadOnlyVariable); } public void ReportUnknownType(TextLocation location, Token typeToken) { - ReportError(location, $"未知类型:{typeToken.Value}"); + ReportError(location, $"未知类型:{typeToken.Value}", DiagnosticCodes.UnknownType); } public void ReportFunctionNotFound(TextLocation location, string functionName) { - ReportError(location, $"找不到调用函数 {functionName}"); + ReportError(location, $"找不到调用函数 {functionName}", DiagnosticCodes.FunctionNotFound); } public void ReportUnknownExpressionType(TextLocation location) { - ReportError(location, "未知的表达式类型"); + ReportError(location, "未知的表达式类型", DiagnosticCodes.UnknownExpressionType); } public void ReportImageLabelNotFound(TextLocation location, string labelName) { - ReportError(location, $"找不到识图标签\"@{labelName}\""); + ReportError(location, $"找不到识图标签\"@{labelName}\"", DiagnosticCodes.ImageLabelNotFound); } public void ReportArrayElementTypeMismatch(TextLocation location) { - ReportError(location, "数组成员类型必须一致"); + ReportError(location, "数组成员类型必须一致", DiagnosticCodes.ArrayElementTypeMismatch); } public void ReportTypeDoesNotSupportIndexAccess(TextLocation location, ScriptType type) { - ReportError(location, $"类型 <{type}> 不支持索引访问"); + ReportError(location, $"类型 <{type}> 不支持索引访问", DiagnosticCodes.TypeDoesNotSupportIndexAccess); } public void ReportTypeDoesNotSupportSlice(TextLocation location, ScriptType type) { - ReportError(location, $"类型 <{type}> 不支持切片操作"); + ReportError(location, $"类型 <{type}> 不支持切片操作", DiagnosticCodes.TypeDoesNotSupportSlice); } public void ReportVariableNotFound(TextLocation location, string varName) { - ReportError(location, $"找不到变量 {varName}"); + ReportError(location, $"找不到变量 {varName}", DiagnosticCodes.VariableNotFound); } public void ReportFunctionArgumentCountMismatch(TextLocation location, FunctionSymbol fn) { - ReportError(location, $"函数 {fn.Name} 参数数量不匹配"); + ReportError(location, $"函数 {fn.Name} 参数数量不匹配", DiagnosticCodes.FunctionArgumentCountMismatch); + } + + public void ReportParamWindowOverflow(TextLocation location, string functionName, int count) + { + ReportError(location, $"函数 '{functionName}' 的参数个数 {count} 超过字节码参数上限 {Bytecode.EcsRegisters.ArgCount}", + DiagnosticCodes.ParamWindowOverflow); } public void ReportFunctionAlreadyDeclared(TextLocation location, string functionName) { - ReportError(location, $"重复定义的函数: {functionName}"); + ReportError(location, $"重复定义的函数: {functionName}", DiagnosticCodes.FunctionAlreadyDeclared); } public void ReportAmbiguousCall(TextLocation location, string functionName, FunctionSymbol[] candidates) { var sigs = string.Join(", ", candidates.Select(c => $"{c.Name}({string.Join(", ", c.Parameters.Select(p => p.Type.Name))})")); - ReportError(location, $"函数调用 '{functionName}' 存在歧义,匹配的重载: {sigs}"); + ReportError(location, $"函数调用 '{functionName}' 存在歧义,匹配的重载: {sigs}", DiagnosticCodes.AmbiguousCall); } public void ReportNoMatchingOverload(TextLocation location, string functionName, ScriptType[] argTypes) { var args = string.Join(", ", argTypes.Select(t => t.Name)); - ReportError(location, $"找不到匹配的函数 '{functionName}',参数类型: ({args})"); + ReportError(location, $"找不到匹配的函数 '{functionName}',参数类型: ({args})", DiagnosticCodes.NoMatchingOverload); } public void ReportNamespaceNotFound(TextLocation location, string name) { - ReportError(location, $"命名空间 '{name}' 不存在"); + ReportError(location, $"命名空间 '{name}' 不存在", DiagnosticCodes.NamespaceNotFound); } public void ReportFunctionNotFoundInNamespace(TextLocation location, string funcName, string nsName) { - ReportError(location, $"命名空间 '{nsName}' 中不存在函数 '{funcName}'"); + ReportError(location, $"命名空间 '{nsName}' 中不存在函数 '{funcName}'", DiagnosticCodes.FunctionNotFoundInNamespace); } public void ReportNamespaceConflictsWithFunction(TextLocation location, string name) { - ReportError(location, $"命名空间 '{name}' 与已声明的函数名冲突"); + ReportError(location, $"命名空间 '{name}' 与已声明的函数名冲突", DiagnosticCodes.NamespaceConflictsWithFunction); } public void ReportCircularImport(TextLocation location, string path) { - ReportError(location, $"循环导入: {Path.GetFileName(path)}"); + ReportError(location, $"循环导入: {Path.GetFileName(path)}", DiagnosticCodes.CircularImport); } public void ReportImportFileNotFound(TextLocation location, string path) { - ReportError(location, $"导入文件不存在: {path}"); + ReportError(location, $"导入文件不存在: {path}", DiagnosticCodes.ImportFileNotFound); } public void ReportModuleNameConflict(TextLocation location, string moduleName, string path) { - ReportError(location, $"模块名冲突: {moduleName}({path})"); + ReportError(location, $"模块名冲突: {moduleName}({path})", DiagnosticCodes.ModuleNameConflict); } } \ No newline at end of file diff --git a/src/EasyCon.Script/DiagnosticCodes.cs b/src/EasyCon.Script/DiagnosticCodes.cs new file mode 100644 index 00000000..a2f7d9c0 --- /dev/null +++ b/src/EasyCon.Script/DiagnosticCodes.cs @@ -0,0 +1,69 @@ +namespace EasyCon.Script; + +/// +/// 诊断错误码分配表(M1 诊断结构化,SCRIPT_MODERNIZATION_PLAN.md;单一事实源)。 +/// 分段:0001–0099 词法/语法,0101–0199 声明与绑定,0201–0299 类型, +/// 0301–0399 字节码/容器,0401–0499 模块系统。新增诊断在对应段尾追加,不复用已撤码。 +/// +public static class DiagnosticCodes +{ + // ---- 词法/语法(DiagnosticBag.Report* ← Lexer/Parser)---- + public const string BadCharacter = "ECX0001"; + public const string InvalidNumber = "ECX0002"; + public const string UnterminatedString = "ECX0003"; + public const string UnexpectedToken = "ECX0004"; + public const string InvalidImport = "ECX0005"; + public const string InvalidExpressionStatement = "ECX0006"; + public const string InvalidKeyActionStatement = "ECX0007"; + public const string InvalidEOF = "ECX0008"; + public const string TooMuchLoop = "ECX0009"; + public const string InvalidBreakOrContinue = "ECX0010"; + public const string BadStruct = "ECX0011"; + public const string KeywordMustBeLowercase = "ECX0012"; // true/false 字面量仅接受全小写 + public const string ArrayConstantRequired = "ECX0013"; // 数组初始化元素仅允许编译期常量 + + // ---- 声明与绑定 ---- + public const string VariableNotFound = "ECX0101"; + public const string FunctionNotFound = "ECX0102"; + public const string FunctionAlreadyDeclared = "ECX0103"; + public const string ParameterAlreadyDeclared = "ECX0104"; + public const string ConstantAlreadyDefined = "ECX0105"; + public const string CannotAssignToSpecialConstant = "ECX0106"; + public const string ReadOnlyVariable = "ECX0107"; + public const string UnknownType = "ECX0108"; + public const string NamespaceNotFound = "ECX0109"; + public const string FunctionNotFoundInNamespace = "ECX0110"; + public const string NamespaceConflictsWithFunction = "ECX0111"; + public const string CircularImport = "ECX0112"; + public const string ImportFileNotFound = "ECX0113"; + public const string ModuleNameConflict = "ECX0114"; + public const string ImageLabelNotFound = "ECX0115"; + public const string InvalidConstantExpression = "ECX0116"; + public const string ParamWindowOverflow = "ECX0117"; // 参数个数超过字节码参数上限 + + // ---- 类型 ---- + public const string CannotConvert = "ECX0201"; + public const string UnsupportedBinaryOperator = "ECX0202"; + public const string UnsupportedUnaryOperator = "ECX0203"; + public const string VoidExpressionCannotAssign = "ECX0204"; + public const string AllPathsMustReturn = "ECX0205"; + public const string VoidFunctionCannotReturn = "ECX0206"; + public const string FunctionMustReturnValue = "ECX0207"; + public const string ArrayElementTypeMismatch = "ECX0208"; + public const string TypeDoesNotSupportIndexAccess = "ECX0209"; + public const string TypeDoesNotSupportSlice = "ECX0210"; + public const string UnknownExpressionType = "ECX0211"; + public const string AmbiguousCall = "ECX0212"; + public const string NoMatchingOverload = "ECX0213"; + public const string FunctionArgumentCountMismatch = "ECX0214"; + + // ---- 字节码/容器(异常包装)---- + public const string BytecodeEmit = "ECX0301"; + public const string ContainerImage = "ECX0302"; + public const string Link = "ECX0303"; + + // ---- 模块系统 ---- + public const string ModuleCompileFailed = "ECX0401"; + public const string DependencyFailed = "ECX0402"; // M2 级联:依赖模块编译失败 + public const string ErrorCacheReplay = "ECX0403"; // 错误缓存重放(§7.3) +} \ No newline at end of file diff --git a/src/EasyCon.Script/DiagnosticFormat.cs b/src/EasyCon.Script/DiagnosticFormat.cs new file mode 100644 index 00000000..85f0aa32 --- /dev/null +++ b/src/EasyCon.Script/DiagnosticFormat.cs @@ -0,0 +1,59 @@ +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace EasyCon.Script; + +/// +/// 诊断 JSON 输出(M1 诊断结构化;CLI --diagnostic-format=json 与 CI 消费)。 +/// 字段:code(可 null)/ severity / message / fileName / line / character / endLine / endCharacter / notes。 +/// +public static class DiagnosticFormat +{ + public static string ToJson(IEnumerable diagnostics) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, + new JsonWriterOptions { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + writer.WriteStartArray(); + foreach (var d in diagnostics) + WriteOne(writer, d); + writer.WriteEndArray(); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + static void WriteOne(Utf8JsonWriter writer, Diagnostic d) + { + writer.WriteStartObject(); + if (d.Code != null) + writer.WriteString("code", d.Code); + writer.WriteString("severity", d.Severity switch + { + DiagnosticSeverity.Error => "error", + DiagnosticSeverity.Warning => "warning", + _ => "info", + }); + writer.WriteString("message", d.Message); + writer.WriteString("fileName", d.FileName); + writer.WriteNumber("line", d.Location.StartLine); + writer.WriteNumber("character", d.Location.StartCharacter); + writer.WriteNumber("endLine", d.Location.EndLine); + writer.WriteNumber("endCharacter", d.Location.EndCharacter); + if (d.Notes.Length > 0) + { + writer.WriteStartArray("notes"); + foreach (var n in d.Notes) + { + writer.WriteStartObject(); + writer.WriteString("message", n.Message); + writer.WriteString("fileName", n.Location.FileName); + writer.WriteNumber("line", n.Location.StartLine); + writer.WriteNumber("character", n.Location.StartCharacter); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/EasyCon.Script/EasyCon.Script.csproj b/src/EasyCon.Script/EasyCon.Script.csproj index e4b0079b..78c1fcf2 100644 --- a/src/EasyCon.Script/EasyCon.Script.csproj +++ b/src/EasyCon.Script/EasyCon.Script.csproj @@ -1,4 +1,4 @@ - + ecs脚本引擎实现库 true @@ -7,7 +7,6 @@ - diff --git a/src/EasyCon.Script/Assembly/HexWriter.cs b/src/EasyCon.Script/IO/HexWriter.cs similarity index 82% rename from src/EasyCon.Script/Assembly/HexWriter.cs rename to src/EasyCon.Script/IO/HexWriter.cs index e2675ca2..7956a383 100644 --- a/src/EasyCon.Script/Assembly/HexWriter.cs +++ b/src/EasyCon.Script/IO/HexWriter.cs @@ -1,3 +1,5 @@ +// 固件 + 脚本合并写回 Intel HEX(GUI 固件合成用;自 v1 Assembly 目录迁出, +// 仅保留 HEX 改写工具,v1 汇编器不随迁)。 using System.Text; namespace EasyCon.Script.Asm; @@ -8,7 +10,7 @@ public static class HexWriter public static string WriteHex(string hexStr, byte[] asmBytes, int size, int version) { if (asmBytes.Length > size) - throw new AssembleException("烧录失败!字节超出限制"); + throw new InvalidOperationException("烧录失败!字节超出限制"); var list = new List(asmBytes); // load hex file var lines = hexStr.Split('\r', '\n'); @@ -21,9 +23,9 @@ public static string WriteHex(string hexStr, byte[] asmBytes, int size, int vers if (hex != null) hexFile.Add(hex); } - catch (AssembleException ex) + catch (InvalidOperationException ex) { - throw new AssembleException("固件读取失败!" + ex.Message); + throw new InvalidOperationException("固件读取失败!" + ex.Message); } } // find data position @@ -37,10 +39,10 @@ public static string WriteHex(string hexStr, byte[] asmBytes, int size, int vers return true; }); if (index == -1) - throw new AssembleException("固件读取失败!未找到数据结构"); + throw new InvalidOperationException("固件读取失败!未找到数据结构"); int ver = hexFile[index].Data[2]; if (ver < version) - throw new AssembleException("固件版本不符,请使用最新版的固件"); + throw new InvalidOperationException("固件版本不符,请使用最新版的固件"); // write data from asmBytes for (int i = 0; i < list.Count;) { @@ -75,7 +77,7 @@ class IntelHex return null; line = line.Trim(); if (line[0] != ':') - throw new AssembleException("需要以“:”作为起始"); + throw new InvalidOperationException("需要以“:”作为起始"); var bytes = new List(); for (int i = 1; i < line.Length; i += 2) bytes.Add(Convert.ToByte(line.Substring(i, 2), 16)); @@ -89,12 +91,12 @@ class IntelHex index += hex.DataSize; hex.Checksum = bytes[index++]; if (index != bytes.Count) - throw new AssembleException("数据过多"); + throw new InvalidOperationException("数据过多"); return hex; } catch (IndexOutOfRangeException) { - throw new AssembleException("数据过少"); + throw new InvalidOperationException("数据过少"); } } diff --git a/src/EasyCon.Script/Modules/ModuleCache.cs b/src/EasyCon.Script/Modules/ModuleCache.cs index 7e8d7315..dc9abbee 100644 --- a/src/EasyCon.Script/Modules/ModuleCache.cs +++ b/src/EasyCon.Script/Modules/ModuleCache.cs @@ -9,8 +9,9 @@ namespace EasyCon.Script.Modules; /// /// 模块缓存键与 obj/ 磁盘缓存(docs/ModuleSystem.md §7)。 /// -/// cacheKey = SHA256(源码内容 ⊕ Σ直接依赖接口哈希 ⊕ 编译器版本 ⊕ 影响产物的编译选项)(§7.2)。 -/// 进键的选项集中在 CompileOptions.ProductFingerprint()(当前:Optimize/LegacySyntax/ExtVars); +/// cacheKey = SHA256(源码内容 ⊕ 源文件路径上下文 ⊕ Σ直接依赖接口哈希 ⊕ 编译器版本 ⊕ 影响产物的编译选项)(§7.2)。 +/// 进键的选项集中在 CompileOptions.ProductFingerprint()(当前:Optimize/ExtVars +/// + syscall ABI 修订 + 产物代数 M); /// KeepSsa 不影响序列化产物、UseDiskCache/ObjDir/GcMaxAge 与产物内容无关,均不进键。 /// 文件名 = <模块名>-<cacheKey 前 8 位>.ecm;实现体改动 → 源码变 → 新键新文件, /// 接口未变时下游 cacheKey 不变 → 下游缓存全命中(Merkle 失效模型,§2.3)。 @@ -18,6 +19,17 @@ namespace EasyCon.Script.Modules; /// internal static class ModuleCacheKeys { + /// + /// 文件模块的规范化路径上下文。EXTERN 相对库路径以声明文件目录为基准, + /// 所以路径参与缓存键;内嵌模块与内存伪文件名保持空上下文以继续共享缓存。 + /// + public static string SourceContext(string sourceFileName) + { + if (string.IsNullOrWhiteSpace(sourceFileName) || sourceFileName.StartsWith('<')) + return ""; + return Path.GetFullPath(sourceFileName); + } + /// 规范化排序后的外部变量集(ExtVars 集合序不定,进键前排序保证键稳定)。 public static string ExtVarsKey(ImmutableHashSet? extVars) { @@ -27,10 +39,11 @@ public static string ExtVarsKey(ImmutableHashSet? extVars) } public static string Compute(string source, IReadOnlyList depInterfaceHashes, string compilerVersion, - string productFingerprint = "") + string productFingerprint = "", string sourceContext = "") { var sb = new StringBuilder(); AppendItem(sb, source); + AppendItem(sb, sourceContext); foreach (var h in depInterfaceHashes) AppendItem(sb, h); AppendItem(sb, compilerVersion); @@ -43,11 +56,24 @@ public static string Compute(string source, IReadOnlyList depInterfaceHa } public static string FileName(string moduleName, string cacheKey) - => $"{moduleName}-{cacheKey[..8]}.ecm"; + // 层级模块名("net/http")的 '/' 落盘转义(N2);不同模块转义后撞名的概率 + // 与目录分隔符语义冲突在实际项目中可忽略(键含完整 cacheKey 哈希) + => $"{Sanitize(moduleName)}-{cacheKey[..8]}.ecm"; + + internal static string Sanitize(string moduleName) => moduleName.Replace('/', '_'); /// 错误缓存 sidecar 文件名(§7.3 错误重放):同 cacheKey 的编译失败记录。 public static string ErrorFileName(string moduleName, string cacheKey) - => $"{moduleName}-{cacheKey[..8]}.err"; + => $"{Sanitize(moduleName)}-{cacheKey[..8]}.err"; + + /// 根级库的预声明接口缓存键;源码变更或编译语义变更都会失效。 + public static string InterfaceKey(string moduleName, string source, string productFingerprint, + string sourceContext = "") + => Compute(source, [], ModuleInterface.CurrentCompilerVersion, + $"interface|{moduleName}|{productFingerprint}", sourceContext); + + public static string InterfaceFileName(string moduleName, string cacheKey) + => $"{Sanitize(moduleName)}-{cacheKey[..8]}.eci"; /// 长度前缀规范项(防拼接歧义)。 static void AppendItem(StringBuilder sb, string s) @@ -57,37 +83,40 @@ static void AppendItem(StringBuilder sb, string s) /// /// 进程级产物缓存(统一链路阶段 B):仅作用于 UseDiskCache=false 的桌面现编路径—— /// 同源码 ⊕ 同依赖接口 ⊕ 同选项的模块免重编(std/vision 等大模块二次编译近零开销)。 -/// 存 EcmFormat 序列化字节,命中即反序列化出新实例——杜绝调用方对产物的就地修改串味 +/// 存 ECM1 平铺序列化字节,命中即反序列化出新实例——杜绝调用方对产物的就地修改串味 /// (ModuleInterfaceTests 曾就地改产物字段,实例绝不共享)。与磁盘缓存正交(不读写 obj/)。 /// internal static class ProcessModuleCache { static readonly ConcurrentDictionary Entries = new(); - public static int Hits { get; private set; } - public static int Misses { get; private set; } + static int _hits, _misses; // M3:并行编译 → Interlocked 计数 + public static int Hits => _hits; + public static int Misses => _misses; + public static void CountHit() => Interlocked.Increment(ref _hits); + public static void CountMiss() => Interlocked.Increment(ref _misses); public static ModuleArtifact? TryLoad(string moduleName, string cacheKey) { if (!Entries.TryGetValue(ModuleCacheKeys.FileName(moduleName, cacheKey), out var bytes)) { - Misses++; + CountMiss(); return null; } try { - var artifact = EcmFormat.Read(bytes); + var artifact = EcsContainer.ReadModule(bytes); if (!artifact.IntegrityCheck()) { - Misses++; // 损坏:按未命中重编 + CountMiss(); // 损坏:按未命中重编 return null; } - Hits++; + CountHit(); return artifact; } catch (BytecodeException) { - Misses++; + CountMiss(); return null; } } @@ -96,7 +125,7 @@ public static void Store(ModuleArtifact artifact, string cacheKey) { try { - Entries[ModuleCacheKeys.FileName(artifact.Name, cacheKey)] = EcmFormat.Write(artifact); + Entries[ModuleCacheKeys.FileName(artifact.Name, cacheKey)] = EcsContainer.WriteModule(artifact); } catch (BytecodeException) { @@ -108,8 +137,8 @@ public static void Store(ModuleArtifact artifact, string cacheKey) public static void Clear() { Entries.Clear(); - Hits = 0; - Misses = 0; + _hits = 0; + _misses = 0; } } @@ -127,10 +156,13 @@ public ModuleCache(string objDir) Directory.CreateDirectory(objDir); } - public int Hits { get; private set; } - public int Misses { get; private set; } - /// 错误缓存命中次数(编译失败记录重放)。 - public int ErrorHits { get; private set; } + int _hits, _misses, _errorHits; // M3:波内并行编译 → Interlocked 计数 + public int Hits => _hits; + public int Misses => _misses; + public int ErrorHits => _errorHits; + void CountHit() => Interlocked.Increment(ref _hits); + void CountMiss() => Interlocked.Increment(ref _misses); + void CountErrorHit() => Interlocked.Increment(ref _errorHits); /// 按 cacheKey 定位并加载模块产物;命中后做完整性校验(接口哈希自洽)。 public ModuleArtifact? TryLoad(string moduleName, string cacheKey) @@ -138,27 +170,67 @@ public ModuleCache(string objDir) var path = Path.Combine(_objDir, ModuleCacheKeys.FileName(moduleName, cacheKey)); if (!File.Exists(path)) { - Misses++; + CountMiss(); return null; } try { - var artifact = EcmFormat.Read(File.ReadAllBytes(path)); + var artifact = EcsContainer.ReadModule(File.ReadAllBytes(path)); if (!artifact.IntegrityCheck()) { - Misses++; // 损坏/被篡改:按未命中重编 + CountMiss(); // 损坏/被篡改:按未命中重编 return null; } - Hits++; + CountHit(); return artifact; } catch (BytecodeException) { - Misses++; + CountMiss(); + return null; + } + } + + /// 加载可用作根级库预声明的接口缓存;无效/损坏条目按未命中处理。 + public ModuleInterface? TryLoadInterface(string moduleName, string source, string productFingerprint, + string sourceContext = "") + { + var cacheKey = ModuleCacheKeys.InterfaceKey(moduleName, source, productFingerprint, sourceContext); + var path = Path.Combine(_objDir, ModuleCacheKeys.InterfaceFileName(moduleName, cacheKey)); + if (!File.Exists(path)) + return null; + + try + { + using var stream = File.OpenRead(path); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + var iface = ModuleInterfaceFormat.Read(reader); + if (stream.Position != stream.Length + || iface.Name != moduleName + || iface.CompilerVersion != ModuleInterface.CurrentCompilerVersion + || iface.InterfaceHash != InterfaceHasher.Compute(iface)) + return null; + return iface; + } + catch (Exception ex) when (ex is IOException or BytecodeException or ArgumentException) + { return null; } } + /// 原子写入根级库预声明接口。 + public void StoreInterface(ModuleInterface iface, string source, string productFingerprint, + string sourceContext = "") + { + var cacheKey = ModuleCacheKeys.InterfaceKey(iface.Name, source, productFingerprint, sourceContext); + var finalPath = Path.Combine(_objDir, ModuleCacheKeys.InterfaceFileName(iface.Name, cacheKey)); + var tempPath = Path.Combine(_objDir, $".{ModuleCacheKeys.Sanitize(iface.Name)}-{Guid.NewGuid():N}.tmp"); + using (var stream = File.Create(tempPath)) + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: false)) + ModuleInterfaceFormat.Write(writer, iface); + MoveWithRetry(tempPath, finalPath); + } + /// 错误缓存重放(§7.3):同 cacheKey 的历史编译失败诊断,命中即快速失败。 public List? TryLoadErrors(string moduleName, string cacheKey) { @@ -168,7 +240,7 @@ public ModuleCache(string objDir) var lines = File.ReadAllLines(path).Where(l => l.Length > 0).ToList(); if (lines.Count == 0) return null; - ErrorHits++; + CountErrorHit(); return lines; } @@ -182,17 +254,36 @@ public void StoreErrors(string moduleName, string cacheKey, IReadOnlyList原子写入产物;同键的历史错误 sidecar 一并清除。 public void Store(ModuleArtifact artifact, string cacheKey) { - var bytes = EcmFormat.Write(artifact); + var bytes = EcsContainer.WriteModule(artifact); var finalPath = Path.Combine(_objDir, ModuleCacheKeys.FileName(artifact.Name, cacheKey)); - var tempPath = Path.Combine(_objDir, $".{artifact.Name}-{Guid.NewGuid():N}.tmp"); + var tempPath = Path.Combine(_objDir, $".{ModuleCacheKeys.Sanitize(artifact.Name)}-{Guid.NewGuid():N}.tmp"); File.WriteAllBytes(tempPath, bytes); - File.Move(tempPath, finalPath, overwrite: true); + MoveWithRetry(tempPath, finalPath); var errPath = Path.Combine(_objDir, ModuleCacheKeys.ErrorFileName(artifact.Name, cacheKey)); if (File.Exists(errPath)) File.Delete(errPath); } + static void MoveWithRetry(string tempPath, string finalPath) + { + // 多进程/多任务共享同一 obj/ 目录时,另一端可能正打开同一 .ecm 读取; + // Windows 上覆盖被占用目标会抛 IOException,短暂重试即可让渡读方 + const int MaxAttempts = 3; + for (int attempt = 1; ; attempt++) + { + try + { + File.Move(tempPath, finalPath, overwrite: true); + return; + } + catch (IOException) when (attempt < MaxAttempts) + { + System.Threading.Thread.Sleep(20 * attempt); + } + } + } + /// /// 缓存 GC(§7.3):删除引用闭包之外且 mtime 早于 <maxAge> 的产物/错误文件。 /// 返回删除的文件数。当前运行仍在引用的文件(keep)永不删除。 @@ -204,7 +295,7 @@ public int GarbageCollect(IReadOnlySet keepFileNames, TimeSpan maxAge) foreach (var file in Directory.EnumerateFiles(_objDir)) { var ext = Path.GetExtension(file); - if (ext is not (".ecm" or ".err")) + if (ext is not (".ecm" or ".err" or ".eci")) continue; if (keepFileNames.Contains(Path.GetFileName(file))) continue; diff --git a/src/EasyCon.Script/Modules/ModuleCompilePipeline.cs b/src/EasyCon.Script/Modules/ModuleCompilePipeline.cs index ac274f8b..18562cab 100644 --- a/src/EasyCon.Script/Modules/ModuleCompilePipeline.cs +++ b/src/EasyCon.Script/Modules/ModuleCompilePipeline.cs @@ -5,6 +5,7 @@ using EasyCon.Script.Symbols; using EasyCon.Script.Syntax; using EasyCon.Script.Text; +using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; @@ -45,82 +46,200 @@ public ModuleCompilePipeline(Dictionary nodes, ModuleCache? /// 全项目 extern 符号并集(FFI 原生按名分发的类型来源)。 public HashSet NativeSymbols => _nativeSymbols; - /// 按拓扑序编译全部模块;false = 已失败(诊断与统计已写入 result,调用方直接返回)。 + /// 编译全部模块:先做接口先行预提取(同目录互见产生接口级依赖环, + /// 编译序不再保证依赖接口就绪——docs/ModuleImportRules.md §2),再按确定性序逐模块编译。 + /// false = 已失败(诊断与统计已写入 result)。 + /// 线程模型:顺序编译(环内失败级联的确定性优先于波内并行;std/vision 大模块由 + /// 进程/磁盘缓存兜底)。 public bool CompileAll(List compileOrder) { - var timing = _result.Timing; + // ---- 接口先行预提取:每个用户模块的接口在编译前就绪 ---- + // 命中 .eci 接口缓存则零 parse(保持「源码不变永不解析」);未命中才 parse 提取并回写缓存。 + // 提取失败(语法错误)→ 预标失败 + 诊断并入,依赖者确定性级联,不进入绑定。 + var failedModules = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); foreach (var node in compileOrder) { - var depIfaces = node.Dependencies - .Where(_nodes.ContainsKey) - .Select(d => _nodes[d].Interface) - .ToList(); - var cacheKey = ModuleCacheKeys.Compute(node.Source, - depIfaces.Select(i => i.InterfaceHash).ToList(), ModuleInterface.CurrentCompilerVersion, - _options.ProductFingerprint()); - _cacheKeys[node.Name] = cacheKey; - - ModuleArtifact? artifact = null; - if (_cache != null) + // main 同样预提取:同目录互见是 mutual 的,兄弟模块的自动依赖含 main, + // 其接口(导出/HasInit)必须就绪(main 树已由调用方加载,零额外 parse) + var sourceContext = ModuleCacheKeys.SourceContext(node.Path); + var iface = _cache?.TryLoadInterface(node.Name, node.Source, _options.ProductFingerprint(), sourceContext); + if (iface == null && node.Tree == null) { - artifact = _cache.TryLoad(node.Name, cacheKey); - if (artifact != null && artifact.Interface!.Dependencies.Count != node.Dependencies.Count) - artifact = null; // 依赖集合变化(防御;cacheKey 已覆盖,双保险) + // node.Tree 已存在(main 由调用方加载)则复用——重 parse 会以 "main.ecs" + // 兜底名替换内存树的空 FileName,污染诊断位置格式 + var swParse = Stopwatch.StartNew(); + var tree = SyntaxTree.Parse(SourceText.From(node.Source, node.Path.Length > 0 ? node.Path : node.Name + ".ecs"), + node.ImportBase); + lock (_stateLock) + _result.Timing.LexingAndParsing += swParse.Elapsed; + if (tree.Diagnostics.Where(d => d.IsError).ToList() is { Count: > 0 } treeErrors) + { + var sink = new DiagnosticBag(); + sink.AddRange(treeErrors); + MergeDiagnostics(sink); + failedModules.TryAdd(node.Name, 0); + continue; + } + try + { + iface = ModuleInterfaceBuilder.FromSyntaxTree(tree, node.Name); + } + catch (InvalidOperationException) + { + // 接口收集失败 = 源码含语义错误(重复声明等):留空接口, + // 真实诊断由绑定阶段报告;兄弟模块经 ImportedList 的 null 过滤跳过 + } + if (iface != null) + _cache?.StoreInterface(iface, node.Source, _options.ProductFingerprint(), sourceContext); + node.Tree = tree; } - else if (_options.UseProcessCache) + else if (iface == null) { - // 进程级产物缓存:仅现编路径(UseDiskCache=false);命中反序列化出新实例,无别名共享 - artifact = ProcessModuleCache.TryLoad(node.Name, cacheKey); - if (artifact != null && artifact.Interface!.Dependencies.Count != node.Dependencies.Count) - artifact = null; + // 树已存在但接口缺失(main 首编译):从现有树提取,失败同上容错 + try + { + var ifaceFromTree = ModuleInterfaceBuilder.FromSyntaxTree(node.Tree!, node.Name); + node.Interface = ifaceFromTree; + _cache?.StoreInterface(ifaceFromTree, node.Source, _options.ProductFingerprint(), sourceContext); + } + catch (InvalidOperationException) + { + // 真实诊断由绑定阶段报告 + } + continue; } + node.Interface = iface; + } + + foreach (var node in compileOrder) + CompileOne(node, failedModules); + + if (!failedModules.IsEmpty) + return Fail(); + FillCacheStats(); + return true; + } + + /// 编译单个模块(缓存键 → 三路缓存查找 → 错误重放/parse → 编码 → 落缓存)。 + /// 返回 false = 本模块失败(诊断进 ,调用方波末并入全局; + /// 失败标记进 供依赖者级联拦截)。 + bool CompileOne(ModuleNode node, ConcurrentDictionary failedModules) + { + // 接口预提取阶段已失败(语法错误):诊断已并入,跳过避免重复报告 + if (failedModules.ContainsKey(node.Name)) + return false; + // M2 级联容错:依赖模块编译失败 → 级联诊断拦截(不进绑定,避免符号错误风暴), + // 其余无关模块照常编译——一次编译收集全部模块的诊断(SCRIPT_MODERNIZATION_PLAN.md)。 + var failedDeps = node.Dependencies.Where(failedModules.ContainsKey).ToList(); + if (failedDeps.Count > 0) + { + var cascade = new DiagnosticBag(); + cascade.Add(Diagnostic.Error( + Modules.ModuleLocations.Default(node.Tree), $"[{node.Name}] 依赖模块编译失败,已跳过: {string.Join(", ", failedDeps)}", + DiagnosticCodes.DependencyFailed)); + MergeDiagnostics(cascade); + failedModules.TryAdd(node.Name, 0); + return false; + } + var sink = new DiagnosticBag(); + + string sourceContext = ModuleCacheKeys.SourceContext(node.Path); + var depIfaces = node.Dependencies + .Where(_nodes.ContainsKey) + .Select(d => _nodes[d].Interface) + .Where(i => i != null) + .ToList(); + var cacheKey = ModuleCacheKeys.Compute(node.Source, + depIfaces.Select(i => i.InterfaceHash).ToList(), ModuleInterface.CurrentCompilerVersion, + _options.ProductFingerprint(), sourceContext); + lock (_stateLock) + { + _cacheKeys[node.Name] = cacheKey; + } - // The ECM artifact records native names but not FunctionSymbol signatures. - // An FFI cache hit would leave NativeSymbols empty, so the VM cannot - // dispatch a subsequent run. Recompile only modules that call external - // native functions until the artifact format carries those signatures. - if (artifact != null && artifact.Natives.Any(n => n.Name.Contains('!'))) + ModuleArtifact? artifact = null; + if (_cache != null) + { + artifact = _cache.TryLoad(node.Name, cacheKey); + if (artifact != null && artifact.Interface!.Dependencies.Count != node.Dependencies.Count) + artifact = null; // 依赖集合变化(防御;cacheKey 已覆盖,双保险) + } + else if (_options.UseProcessCache) + { + // 进程级产物缓存:仅现编路径(UseDiskCache=false);命中反序列化出新实例,无别名共享 + artifact = ProcessModuleCache.TryLoad(node.Name, cacheKey); + if (artifact != null && artifact.Interface!.Dependencies.Count != node.Dependencies.Count) artifact = null; + } - if (artifact == null) + // FFI 缓存限制(挂账):.ecm 只记录原生名不带 FunctionSymbol 签名——缓存命中会使 + // NativeSymbols 缺失、运行期无法分发。检测面 = Natives 表 '!' 名(L3 名表 FFI) + // + 接口区 ExternLibrary(EXTERN FROM,经接口传递的 FFI——M3 前仅前者,遮蔽场景间歇暴露)。 + // 根修 = 产物格式携带签名,另批。 + if (artifact != null && (artifact.Natives.Any(n => n.Name.Contains('!')) + || artifact.Interface!.Functions.Any(f => !string.IsNullOrEmpty(f.ExternLibrary)))) + artifact = null; + + if (artifact == null) + { + // 错误缓存重放(§7.3):同 cacheKey 的历史编译失败 → 不绑定/编码也不 parse + if (_cache != null) { - // 错误缓存重放(§7.3):同 cacheKey 的历史编译失败 → 快速失败,不绑定/编码也不 parse - if (_cache != null) + var replay = _cache.TryLoadErrors(node.Name, cacheKey); + if (replay != null) { - var replay = _cache.TryLoadErrors(node.Name, cacheKey); - if (replay != null) - { - _result.Diagnostics.AddRange(replay.Select(line => - DiagnosticBag.FromMessage(node.Tree, node.Name, line))); - return Fail(); - } + sink.AddRange(replay.Select(line => + DiagnosticBag.FromMessage(node.Tree, node.Name, line))); + MergeDiagnostics(sink); + failedModules.TryAdd(node.Name, 0); + return false; } + } - // 缓存未命中才全量 parse(轻量导入扫描只建立图;此处拿到绑定所需语法树) - if (node.Tree == null && !EnsureParsed(node)) - { - StoreErrors(node.Name, cacheKey); - return Fail(); - } + // 缓存未命中才全量 parse(轻量导入扫描只建立图;此处拿到绑定所需语法树) + if (node.Tree == null && !EnsureParsed(node, sink)) + { + StoreErrors(sink, node.Name, cacheKey); + MergeDiagnostics(sink); + failedModules.TryAdd(node.Name, 0); + return false; + } - var imports = ImportedList(node); - artifact = CompileModuleNode(node, imports, isMain: node.Name == ProjectCompiler.MainModule); - if (artifact == null) - { - StoreErrors(node.Name, cacheKey); - return Fail(); - } - _cache?.Store(artifact, cacheKey); - if (_cache == null && _options.UseProcessCache) - ProcessModuleCache.Store(artifact, cacheKey); + var imports = ImportedList(node); + artifact = CompileModuleNode(node, imports, sink, isMain: node.Name == ProjectCompiler.MainModule); + if (artifact == null) + { + StoreErrors(sink, node.Name, cacheKey); + MergeDiagnostics(sink); + failedModules.TryAdd(node.Name, 0); + return false; } - node.Artifact = artifact; - node.Interface = artifact.Interface!; + _cache?.Store(artifact, cacheKey); + if (_cache == null && _options.UseProcessCache) + ProcessModuleCache.Store(artifact, cacheKey); } - FillCacheStats(); + node.Artifact = artifact; + node.Interface = artifact.Interface!; + MergeDiagnostics(sink); return true; } + /// 并行聚合锁:诊断 / 计时 / 缓存键 / extern 并集(波内多模块竞争点)。 + readonly object _stateLock = new(); + + void MergeDiagnostics(DiagnosticBag sink) + { + lock (_stateLock) + _result.Diagnostics.AddRange(sink); + } + + void StoreErrors(DiagnosticBag sink, string name, string cacheKey) + { + if (_cache != null) + _cache.StoreErrors(name, cacheKey, sink + .Where(d => d.IsError) + .Select(d => d.Message).ToList()); + } /// 统计聚合(成功与失败路径共用;Cache 统计直接读缓存对象,Process 统计按差值)。 void FillCacheStats() { @@ -149,13 +268,15 @@ void StoreErrors(string name, string cacheKey) /// 依赖接口列表(别名随 IMPORT 语句;已 parse 走 AST,未 parse 补 parse 提取语句)。 List ImportedList(ModuleNode node) { - var tree = node.Tree ?? SyntaxTree.Parse(SourceText.From(node.Source, node.Path), _options.LegacySyntax); + var tree = node.Tree ?? SyntaxTree.Parse(SourceText.From(node.Source, node.Path), node.ImportBase); var importStmts = tree.Root.Members.OfType() .ToDictionary(i => Path.GetFullPath(i.FullFileName), i => i); var list = new List(); foreach (var depName in node.Dependencies) { var dep = _nodes[depName]; + if (dep.Interface == null) + continue; // 接口提取失败(源码语义错误):绑定阶段该依赖自身会失败并级联 importStmts.TryGetValue(dep.Path, out var stmt); list.Add(new ImportedInterface(dep.Interface, stmt?.Alias?.Value)); } @@ -163,16 +284,17 @@ List ImportedList(ModuleNode node) } /// 缓存未命中后的全量 parse;语法错误写入 result 并返回 false(模块失败)。 - bool EnsureParsed(ModuleNode node) + bool EnsureParsed(ModuleNode node, DiagnosticBag sink) { // parse 耗时计入 LexingAndParsing(缓存命中路径恒为 0,是「零 parse」的可观测证据) var swParse = Stopwatch.StartNew(); - var tree = SyntaxTree.Parse(SourceText.From(node.Source, node.Path), _options.LegacySyntax); - _result.Timing.LexingAndParsing += swParse.Elapsed; + var tree = SyntaxTree.Parse(SourceText.From(node.Source, node.Path), node.ImportBase); + lock (_stateLock) + _result.Timing.LexingAndParsing += swParse.Elapsed; node.Tree = tree; if (tree.Diagnostics.Where(d => d.IsError).ToList() is { Count: > 0 } treeErrors) { - _result.Diagnostics.AddRange(treeErrors); + sink.AddRange(treeErrors); return false; } return true; @@ -181,10 +303,10 @@ bool EnsureParsed(ModuleNode node) /// 编译单个模块(M5 + 统一链路):接口合成 → 急切绑定 → SSA → 优化(导出为根)→ 编码。 /// 失败时结构化诊断写入 result,且不写产物缓存(错误缓存由调用方存字符串 sidecar)。 ModuleArtifact? CompileModuleNode(ModuleNode node, List importedIfaces, - bool isMain) + DiagnosticBag sink, bool isMain) { var timing = _result.Timing; - var moduleTree = node.Tree ?? SyntaxTree.Parse(SourceText.From(node.Source, node.Path), _options.LegacySyntax); + var moduleTree = node.Tree ?? SyntaxTree.Parse(SourceText.From(node.Source, node.Path), node.ImportBase); node.Tree = moduleTree; try { @@ -194,31 +316,36 @@ bool EnsureParsed(ModuleNode node) var (resolution, externalFunctions) = InterfaceScopeSynthesizer.Synthesize( moduleTree, importedIfaces.ToImmutableArray(), includeCaptureHoles: !isMain); - var bound = Binder.BindProgram(resolution, _options.ExtVars, eagerBindMainFunctions: true, - legacySyntax: _options.LegacySyntax); - timing.Binding += sw.Elapsed; + var bound = Binder.BindProgram(resolution, _options.ExtVars, eagerBindMainFunctions: true); + lock (_stateLock) + timing.Binding += sw.Elapsed; if (bound.Diagnostics.HasErrors()) { - _result.Diagnostics.AddRange(bound.Diagnostics); + sink.AddRange(bound.Diagnostics); return null; } - foreach (var ext in bound.ExternFunctions) - _nativeSymbols.Add(ext); + lock (_stateLock) + foreach (var ext in bound.ExternFunctions) + _nativeSymbols.Add(ext); sw.Restart(); var ssa = SsaProgramBuilder.Build(bound); - timing.SsaBuild += sw.Elapsed; + lock (_stateLock) + timing.SsaBuild += sw.Elapsed; sw.Restart(); if (_options.Optimize) SsaOptimizer.Optimize(ssa, moduleRoots: ssa.Functions.Keys); - timing.SsaOptimize += sw.Elapsed; + lock (_stateLock) + timing.SsaOptimize += sw.Elapsed; + + SsaUseDefValidator.ValidateProgram(ssa); if (isMain) _result.Program = ssa; - bool hasInit = isMain || ModuleInterfaceBuilder.FromSyntaxTree(moduleTree, node.Name).HasInit; + bool hasInit = isMain || (node.Interface?.HasInit ?? false); var artifact = EcxModuleEncoder.CompileWholeProgramAsModule(ssa, node.Name, externalFunctions, hasInit: hasInit, isMain: isMain); artifact.Ssa = _options.KeepSsa ? ssa : null; @@ -234,7 +361,7 @@ bool EnsureParsed(ModuleNode node) } catch (Exception ex) { - _result.Diagnostics.Add(DiagnosticBag.FromMessage(node.Tree, node.Name, ex.Message)); + sink.Add(DiagnosticBag.FromMessage(node.Tree, node.Name, ex.Message)); return null; } } diff --git a/src/EasyCon.Script/Modules/ModuleGraphBuilder.cs b/src/EasyCon.Script/Modules/ModuleGraphBuilder.cs index 89df08f4..3bafee61 100644 --- a/src/EasyCon.Script/Modules/ModuleGraphBuilder.cs +++ b/src/EasyCon.Script/Modules/ModuleGraphBuilder.cs @@ -6,17 +6,22 @@ namespace EasyCon.Script.Modules; -/// 模块图节点(图构建与编译管线的内部载体;docs/ModuleSystem.md §3.3)。 +/// 模块图节点(图构建与编译管线的内部载体;docs/ModuleImportRules.md)。 internal sealed class ModuleNode { public required string Name; public required string Source; - /// 语法树(惰性):仅缓存未命中/主模块才 parse——缓存命中路径只读 .ecm 接口区 - /// (图构建经 Lexer 令牌扫描 IMPORT,不对依赖全量 parse)。 + /// 语法树(惰性):仅缓存未命中/主模块才 parse——缓存命中路径只读 .eci/.ecm + /// 接口区(图构建经 Lexer 令牌扫描 IMPORT,不对依赖全量 parse;接口预提取走 .eci)。 public SyntaxTree? Tree; public required string Path; - /// 依赖节点名(图边;std/vision 隐式依赖注入在前)。 + /// IMPORT 解析基准 = 本文件所在目录(R2;内嵌模块为 null)。 + public string? ImportBase; + /// 显式 IMPORT 依赖(环检测作用域;编译层级/别名解析的唯一来源)。 public readonly List Dependencies = new(); + /// 自动依赖:同目录互见 + main 的 lib/ 自动导入(R1/R3); + /// 不参与环检测——同目录互见天然双向,接口先行预提取保证循环可见可编译。 + public readonly List AutoDependencies = new(); public ModuleArtifact Artifact = null!; public ModuleInterface Interface = null!; } @@ -35,27 +40,29 @@ public static TextLocation FirstImport(SyntaxTree tree) } /// -/// 模块依赖图构建(docs/ModuleSystem.md §3.3/§5.3 的图构建半区): -/// main 树注册 + IMPORT 递归展开 + 环检测(DFS 当前路径栈)+ lib/ 自动加载(v1 -/// ImportResolver 顺序语义:显式 import 之后、main 之前)→ 隐式 std/vision 依赖注入 → -/// 拓扑序编译序列(被依赖者在前,main 最后)。依赖模块只做 Lexer 令牌级导入扫描 -/// (禁止裸正则),全量 parse 推迟到缓存未命中之后(编译管线的 EnsureParsed)。 -/// 致命错误经 上报(HasErrors 由调用方判定);非致命提示透传。 +/// 模块依赖图构建(docs/ModuleImportRules.md): +/// R1 同目录互见(目录即包,mutual 自动边);R7 入口所在目录豁免(并列主脚本互不打扰); +/// R2 显式 IMPORT 基准 = 导入文件所在目录; +/// R3 main 隐式导入 lib/*.ecs(不传递);R4 跨目录必须显式导入;R5 模块名 = 相对主脚本目录的 +/// 规范化路径(POSIX 分隔符、去扩展名)。显式 IMPORT 闭包 DFS + 环检测;自动边定点收敛 +/// (新注册节点再扫目录与其显式导入,直至闭包稳定)。自动/显式边分列表维护: +/// 合成器注册序 = std → vision → 自动(名序,同包遮蔽导入)→ 显式(语句序)。 +/// 依赖模块只做 Lexer 令牌级导入扫描(禁止裸正则);全量 parse 推迟到管线接口预提取/缓存未命中。 +/// 致命错误经 上报;非致命提示透传。 /// internal static class ModuleGraphBuilder { internal sealed class Graph { public required Dictionary Nodes; - /// std → vision → 拓扑序用户模块 → main;存在致命图错误时仍为可编译前缀(调用方先查诊断)。 + /// std → vision → 用户模块(显式边层级 + 同层名序)→ main;存在致命图错误时仍为可编译前缀(调用方先查诊断)。 public required List CompileOrder; } - public static Graph Build(SyntaxTree mainTree, string? scriptDir, bool allowLibAutoLoad, - CompilationTiming timing, DiagnosticBag diagnostics, bool legacySyntax = true) + public static Graph Build(SyntaxTree mainTree, string? scriptDir, + CompilationTiming timing, DiagnosticBag diagnostics) { var sw = Stopwatch.StartNew(); - var mainSource = mainTree.Text.ToString(); var mainPath = mainTree.Text.FileName; // ---- 节点注册:std/vision 内嵌源码 + main ---- @@ -76,136 +83,222 @@ ModuleNode AddEmbedded(string name, string source) var mainNode = new ModuleNode { Name = ProjectCompiler.MainModule, - Source = mainSource, + Source = mainTree.Text.ToString(), Tree = mainTree, Path = mainPath, + ImportBase = scriptDir, }; nodes[ProjectCompiler.MainModule] = mainNode; - byPath[mainPath] = ProjectCompiler.MainModule; + if (mainPath.Length > 0) + byPath[mainPath] = ProjectCompiler.MainModule; - // ---- IMPORT 递归展开(DFS 后序 = 拓扑序)---- - var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); - var stack = new List(); - var order = new List(); + // ---- 显式 IMPORT 收集:保留边的源码位置,闭包收敛后再做 DFS 环检测 ---- + Dictionary<(string Importer, string Dependency), TextLocation> importLocations = new(); + var expandQueue = new Queue(); + expandQueue.Enqueue(mainNode); - void Collect(ModuleNode node) + void CollectExplicit(ModuleNode node) { - if (!visiting.Add(node.Name)) - { - diagnostics.ReportCircularImport(NodeLocation(node), node.Path); - return; - } - stack.Add(node.Name); - foreach (var (importPath, importLocation) in ScannedImports(node, legacySyntax)) + foreach (var (importPath, importLocation) in ScannedImports(node)) { if (!File.Exists(importPath)) { diagnostics.ReportImportFileNotFound(importLocation, importPath); continue; } - if (byPath.TryGetValue(importPath, out var depName)) + if (!byPath.TryGetValue(importPath, out string? depName)) { - if (visiting.Contains(depName)) - diagnostics.ReportCircularImport(importLocation, importPath); - else if (!node.Dependencies.Contains(depName)) - node.Dependencies.Add(depName); - continue; + ModuleNode? depNode = RegisterFileNode(importPath, scriptDir, nodes, byPath, importLocation, diagnostics); + if (depNode == null) + continue; + expandQueue.Enqueue(depNode); + depName = depNode.Name; } - var moduleName = Path.GetFileNameWithoutExtension(importPath); - if (nodes.ContainsKey(moduleName)) + if (!node.Dependencies.Contains(depName)) { - diagnostics.ReportModuleNameConflict(importLocation, moduleName, importPath); - continue; + node.Dependencies.Add(depName); + importLocations.Add((node.Name, depName), importLocation); } - var swRead = Stopwatch.StartNew(); - var depSource = File.ReadAllText(importPath); - var depNode = new ModuleNode - { - Name = moduleName, - Source = depSource, - Tree = null, - Path = importPath, - }; - nodes[moduleName] = depNode; - byPath[importPath] = moduleName; - timing.FileLoad += swRead.Elapsed; - Collect(depNode); - if (!node.Dependencies.Contains(depNode.Name)) - node.Dependencies.Add(depNode.Name); } - stack.RemoveAt(stack.Count - 1); - visiting.Remove(node.Name); - order.Add(node.Name); } - sw.Restart(); - Collect(mainNode); - timing.ImportResolve += sw.Elapsed; + // ---- 定点收敛:显式闭包 + 目录自动注册(同目录互见 / lib/ 自动导入)相互触发, + // 直到不再产生新节点。自动边双向追加、不做环检测(接口先行预提取保证可编译)。 + // 入口脚本所在目录豁免同目录互见(R7):项目根并列多个主脚本互不打扰, + // 根目录的显式导入模块同样不与根目录其他脚本互见。---- + var dirScanned = new HashSet(StringComparer.OrdinalIgnoreCase); + var mainDirectory = mainNode.Path.Length > 0 ? Path.GetDirectoryName(mainNode.Path) : null; + while (expandQueue.Count > 0) + { + var node = expandQueue.Dequeue(); + if (node.Path.Length > 0 && !dirScanned.Add(node.Path)) + continue; + CollectExplicit(node); + + // R3:main 隐式导入 lib/*.ecs(不传递;lib 内互见由同目录规则自然成立) + if (node == mainNode && scriptDir != null) + RegisterDirAutos(Path.Combine(scriptDir, "lib"), scriptDir, nodes, byPath, + mainNode, mainNode.AutoDependencies, expandQueue, diagnostics); - // ---- lib/ 目录自动加载(v1 ImportResolver 顺序语义:显式 import 之后、main 之前)---- - if (allowLibAutoLoad && scriptDir != null) + // R1:同目录互见(目录即包);R7:入口所在目录豁免 + if (node.Path.Length > 0) + { + var dir = Path.GetDirectoryName(node.Path); + if (dir != null && dir != mainDirectory) + RegisterDirAutos(dir, scriptDir, nodes, byPath, + node, node.AutoDependencies, expandQueue, diagnostics); + } + } + + // 自动发现的节点也可能含显式环;必须在自动边合入 Dependencies 前检查所有节点。 + HashSet visited = new(StringComparer.OrdinalIgnoreCase); + HashSet visiting = new(StringComparer.OrdinalIgnoreCase); + void CheckExplicitCycles(ModuleNode node) { - sw.Restart(); - var libDir = Path.Combine(scriptDir, "lib"); - if (Directory.Exists(libDir)) + if (!visited.Add(node.Name)) + return; + visiting.Add(node.Name); + foreach (string dependency in node.Dependencies) { - foreach (var file in Directory.GetFiles(libDir, "*.ecs").OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) - { - var fullPath = Path.GetFullPath(file); - if (byPath.ContainsKey(fullPath)) - continue; // 已由显式 import 加载(自动加载去重,v1 同语义) - var moduleName = Path.GetFileNameWithoutExtension(fullPath); - if (nodes.ContainsKey(moduleName)) - { - diagnostics.ReportModuleNameConflict(ModuleLocations.FirstImport(mainTree), moduleName, fullPath); - continue; - } - var swRead = Stopwatch.StartNew(); - var libNode = new ModuleNode - { - Name = moduleName, - Source = File.ReadAllText(fullPath), - Tree = null, - Path = fullPath, - }; - nodes[moduleName] = libNode; - byPath[fullPath] = moduleName; - timing.FileLoad += swRead.Elapsed; - Collect(libNode); // lib 可再 import 其他模块(嵌套导入) - if (!mainNode.Dependencies.Contains(moduleName)) - mainNode.Dependencies.Add(moduleName); - } + if (visiting.Contains(dependency)) + diagnostics.ReportCircularImport(importLocations[(node.Name, dependency)], nodes[dependency].Path); + else + CheckExplicitCycles(nodes[dependency]); + } + visiting.Remove(node.Name); + } + foreach (ModuleNode node in nodes.Values) + CheckExplicitCycles(node); + + // ---- 合并依赖与编译序:std/vision 头插 → 自动(名序,同包遮蔽显式导入)→ 显式(发现序)。 + // 层级只由显式边约束(Kahn;自动边不约束——接口先行已保证就绪): + // 跨目录显式链的 <init> 拓扑序得以保持(依赖者后于被依赖者), + // 同目录互见环(自动边)由同层名序破环(docs/ModuleImportRules.md §2)。---- + var userNames = nodes.Keys + .Where(n => n != ProjectCompiler.MainModule + && n != ProjectCompiler.StdModule && n != ProjectCompiler.VisionModule) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + var levelMemo = new Dictionary(StringComparer.OrdinalIgnoreCase); + int LevelOf(string name) + { + if (levelMemo.TryGetValue(name, out var level)) + return level; + levelMemo[name] = 0; // 环防御:显式环已报错,此处按第 0 层截断 + var node = nodes[name]; + level = 0; + foreach (var dep in node.Dependencies) + { + if (dep == ProjectCompiler.StdModule || dep == ProjectCompiler.VisionModule) + continue; + if (nodes.ContainsKey(dep)) + level = Math.Max(level, LevelOf(dep) + 1); } - timing.AutoLoadLib += sw.Elapsed; + levelMemo[name] = level; + return level; } + foreach (var name in userNames) + _ = LevelOf(name); - // ---- 隐式依赖:std/vision 先于一切用户模块;main 恒最后 ---- var compileOrder = new List { nodes[ProjectCompiler.StdModule], nodes[ProjectCompiler.VisionModule], }; - foreach (var name in order) + foreach (var name in userNames.OrderBy(n => levelMemo[n]).ThenBy(n => n, StringComparer.OrdinalIgnoreCase)) { - if (name == ProjectCompiler.MainModule) - continue; - compileOrder.Add(nodes[name]); - var deps = nodes[name].Dependencies; - deps.Insert(0, ProjectCompiler.VisionModule); - deps.Insert(0, ProjectCompiler.StdModule); + var node = nodes[name]; + node.Dependencies.Insert(0, ProjectCompiler.VisionModule); + node.Dependencies.Insert(0, ProjectCompiler.StdModule); + node.Dependencies.InsertRange(2, SortedAutos(node)); + compileOrder.Add(node); } - compileOrder.Add(mainNode); mainNode.Dependencies.Insert(0, ProjectCompiler.VisionModule); mainNode.Dependencies.Insert(0, ProjectCompiler.StdModule); + mainNode.Dependencies.InsertRange(2, SortedAutos(mainNode)); + compileOrder.Add(mainNode); return new Graph { Nodes = nodes, CompileOrder = compileOrder }; } + /// 自动依赖排序视图;已同时出现在显式依赖里的项去重(显式别名优先)。 + static List SortedAutos(ModuleNode node) + => node.AutoDependencies + .Where(d => !node.Dependencies.Contains(d)) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// 注册文件模块节点;主目录不可用时模块名退化为文件名(内存单文件场景无自动注册)。 + static ModuleNode? RegisterFileNode(string importPath, string? scriptDir, + Dictionary nodes, Dictionary byPath, + TextLocation location, DiagnosticBag diagnostics) + { + var moduleName = ModuleNameFor(importPath, scriptDir); + if (nodes.ContainsKey(moduleName)) + { + diagnostics.ReportModuleNameConflict(location, moduleName, importPath); + return null; + } + var depSource = File.ReadAllText(importPath); + var depNode = new ModuleNode + { + Name = moduleName, + Source = depSource, + Tree = null, + Path = importPath, + ImportBase = Path.GetDirectoryName(importPath), + }; + nodes[moduleName] = depNode; + byPath[importPath] = moduleName; + return depNode; + } + + /// 目录自动注册(R1/R3):扫描 dir 下全部 .ecs,未注册者注册为新节点并加自动边 + /// autoOwner → 新节点;已注册者补齐反向自动边(同目录互见的 mutual 语义)。 + static void RegisterDirAutos(string dir, string? scriptDir, + Dictionary nodes, Dictionary byPath, + ModuleNode autoOwner, List autoDeps, Queue expandQueue, + DiagnosticBag diagnostics) + { + if (!Directory.Exists(dir)) + return; + foreach (var file in Directory.EnumerateFiles(dir, "*.ecs").OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) + { + var full = Path.GetFullPath(file); + if (byPath.TryGetValue(full, out var existing)) + { + if (existing != autoOwner.Name + && !autoDeps.Contains(existing) + && !autoOwner.Dependencies.Contains(existing)) // 显式依赖已有:去重(别名优先) + autoDeps.Add(existing); // 反向边:目录内互见 + continue; + } + var node = RegisterFileNode(full, scriptDir, nodes, byPath, + ModuleLocations.Default(null), diagnostics); + if (node == null) + continue; + expandQueue.Enqueue(node); + if (!autoDeps.Contains(node.Name)) + autoDeps.Add(node.Name); + } + } + + /// R5:模块名 = 相对主脚本目录的规范化路径(POSIX 分隔符、去扩展名; + /// lib/net/http.ecs → "lib/net/http")。主目录不可用时退化为文件名。 + static string ModuleNameFor(string importPath, string? scriptDir) + { + if (scriptDir == null) + return Path.GetFileNameWithoutExtension(importPath); + var rel = Path.GetRelativePath(scriptDir, importPath); + return Path.ChangeExtension(rel, null)?.Replace('\\', '/') ?? ""; + } + // ---- 轻量导入扫描 ---- /// 节点的导入引用列表:已 parse 走 AST;未 parse 走 Lexer 令牌扫描 - /// (IMPORT + STRING 令牌对,跳过换行/空白/注释 trivia——杜绝裸正则对注释/字符串的误判)。 - static List<(string FullPath, TextLocation Location)> ScannedImports(ModuleNode node, bool legacySyntax) + /// (IMPORT + STRING 令牌对,跳过换行/空白/注释 trivia——杜绝裸正则对注释/字符串的误判)。 + /// 解析基准 = 节点 ImportBase(R2 = 导入文件所在目录)。 + static List<(string FullPath, TextLocation Location)> ScannedImports(ModuleNode node) { if (node.Tree is { } tree) return tree.Root.Members.OfType() @@ -213,9 +306,8 @@ void Collect(ModuleNode node) .ToList(); var sourceText = SourceText.From(node.Source, node.Path.Length > 0 ? node.Path : node.Name + ".ecs"); - var tokens = SyntaxTree.ParseTokens(sourceText, legacySyntax); - // 导入路径解析与 Parser.ParseImport 一致:InitPath = <文件目录>/lib/ - var initPath = Path.Combine(Path.GetDirectoryName(node.Path) ?? "", "lib"); + var tokens = SyntaxTree.ParseTokens(sourceText, node.ImportBase); + var initPath = node.ImportBase; var result = new List<(string, TextLocation)>(); for (int i = 0; i < tokens.Length; i++) { @@ -227,14 +319,11 @@ void Collect(ModuleNode node) if (j >= tokens.Length || tokens[j].Type != TokenType.STRING) continue; // 残缺 import:全量 parse 时由 Parser 报错(缓存命中路径此前也必然合法) var lib = tokens[j].STRTrimQ(); - result.Add((Path.GetFullPath(Path.Combine(initPath, lib)), tokens[j].Location)); + var fullPath = initPath != null + ? Path.GetFullPath(Path.Combine(initPath, lib)) + : lib; + result.Add((fullPath, tokens[j].Location)); } return result; } - - /// 无树节点的诊断落点(环检测防御分支):模块文件名 + 零跨度。 - static TextLocation NodeLocation(ModuleNode node) - => node.Tree is { } tree - ? ModuleLocations.FirstImport(tree) - : new TextLocation(SourceText.From("", node.Path), new SourceSpan(0, 0)); } \ No newline at end of file diff --git a/src/EasyCon.Script/Modules/ProjectCompiler.cs b/src/EasyCon.Script/Modules/ProjectCompiler.cs index 8d100f8b..8a2d4512 100644 --- a/src/EasyCon.Script/Modules/ProjectCompiler.cs +++ b/src/EasyCon.Script/Modules/ProjectCompiler.cs @@ -40,16 +40,16 @@ public sealed class ModuleProjectResult /// /// 项目级独立编译编排(docs/ModuleSystem.md §3.3/§5 + 统一链路 docs/Pipeline.md): /// -/// main.ecs ─► ModuleGraphBuilder(IMPORT 递归展开 + 环检测 + lib/ 自动加载)──► 拓扑序 +/// main.ecs ─► ModuleGraphBuilder(显式 IMPORT 闭包 + 同目录互见/lib/ 自动导入)──► 确定性序 /// │ -/// ModuleCompilePipeline:obj/ 缓存命中 → 读 .ecm(接口区 + 代码区)(M4) +/// ModuleCompilePipeline:接口先行预提取(.eci 缓存)→ obj/ 缓存命中 → 读 .ecm /// 未命中 → Synthesize(依赖接口) → 急切绑定 → SSA → 优化 → -/// 编码(外部调用 = 导入标记 / CallN 原生)→ 原子写回 obj/(M5) +/// 编码(外部调用 = 导入标记 / CallN 原生)→ 原子写回 obj/ /// │ /// Link:类型表合并 + 导入解析 + <main>/<init> 合成(M6)──► EcxImage /// /// std/vision 为隐式依赖(内嵌源码、模块名固定),先于一切用户模块编译; -/// lib/ 自动加载对齐 v1 ImportResolver 顺序语义(显式 import 之后、main 之前,全局可见无 alias)。 +/// main 隐式导入 lib/*.ecs(R3),同目录互见见 R1(docs/ModuleImportRules.md)。 /// 桌面执行(EcxInterpreter)与 MCU 分发(.ecx)共用同一 EcxImage 产物。 /// public static class ProjectCompiler @@ -58,7 +58,7 @@ public static class ProjectCompiler public const string VisionModule = "vision"; public const string MainModule = "main"; - /// 从脚本文件编译(统一链路主入口;lib/ 自动加载 + obj/ 缓存按 options)。 + /// 从脚本文件编译(统一链路主入口;docs/ModuleImportRules.md)。 public static ModuleProjectResult CompileProject(string mainPath, CompileOptions? options = null) { options ??= new CompileOptions(); @@ -71,22 +71,33 @@ public static ModuleProjectResult CompileProject(string mainPath, CompileOptions result.Timing.FileLoad += sw.Elapsed; return CompileCore(mainTree, scriptDir, - options.ObjDir ?? Path.Combine(scriptDir, "obj"), options, result, - allowLibAutoLoad: true); + options.ObjDir ?? Path.Combine(scriptDir, "obj"), options, result); } - /// 从内存源码编译(单文件场景:无 lib/ 自动加载;磁盘缓存仅显式提供 objDir 时启用)。 + /// 从内存源码编译(单文件场景:磁盘缓存仅显式提供 objDir 时启用)。 public static ModuleProjectResult CompileProject(SyntaxTree mainTree, CompileOptions? options = null) { options ??= new CompileOptions(); var result = new ModuleProjectResult(); return CompileCore(mainTree, scriptDir: null, - objDir: options.ObjDir ?? "", options, result, - allowLibAutoLoad: false); + objDir: options.ObjDir ?? "", options, result); + } + + /// 从带源文件路径的内存源码编译(保留编辑器未保存内容,IMPORT 解析含同目录 lib/)。 + public static ModuleProjectResult CompileProject(SyntaxTree mainTree, string scriptDir, + CompileOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scriptDir); + options ??= new CompileOptions(); + var result = new ModuleProjectResult(); + scriptDir = Path.GetFullPath(scriptDir); + return CompileCore(mainTree, scriptDir, + options.ObjDir ?? (options.UseDiskCache ? Path.Combine(scriptDir, "obj") : ""), + options, result); } static ModuleProjectResult CompileCore(SyntaxTree mainTree, string? scriptDir, string objDir, - CompileOptions options, ModuleProjectResult result, bool allowLibAutoLoad) + CompileOptions options, ModuleProjectResult result) { ModuleCache? cache = null; if (options.UseDiskCache && objDir.Length > 0) @@ -96,16 +107,15 @@ static ModuleProjectResult CompileCore(SyntaxTree mainTree, string? scriptDir, s if (mainTree.Diagnostics.Where(d => d.IsError).ToList() is { Count: > 0 } mainTreeErrors) { result.Diagnostics.AddRange(mainTreeErrors); - return result; + return FinalizeResult(result); } var diagnostics = new DiagnosticBag(); - var graph = ModuleGraphBuilder.Build(mainTree, scriptDir, allowLibAutoLoad, result.Timing, diagnostics, - options.LegacySyntax); + var graph = ModuleGraphBuilder.Build(mainTree, scriptDir, result.Timing, diagnostics); if (diagnostics.HasErrors()) { result.Diagnostics.AddRange(diagnostics); - return result; + return FinalizeResult(result); } result.Diagnostics.AddRange(diagnostics.Where(d => !d.IsError)); // 非致命提示透传 @@ -113,7 +123,7 @@ static ModuleProjectResult CompileCore(SyntaxTree mainTree, string? scriptDir, s if (!pipeline.CompileAll(graph.CompileOrder)) { result.MainTree = mainTree; - return result; + return FinalizeResult(result); } foreach (var node in graph.CompileOrder) @@ -145,11 +155,45 @@ static ModuleProjectResult CompileCore(SyntaxTree mainTree, string? scriptDir, s } result.NativeSymbols = pipeline.NativeSymbols.ToImmutableArray(); - result.Image = EcxPipeline.Link(result.Artifacts, - result.Artifacts.Any(a => a.KeyAction), - result.Artifacts.Any(a => a.NeedIL)); - result.Success = true; result.MainTree = mainTree; + if (result.Diagnostics.HasErrors()) + return FinalizeResult(result); + + try + { + result.Image = EcxPipeline.Link(result.Artifacts, + result.Artifacts.Any(a => a.KeyAction), + result.Artifacts.Any(a => a.NeedIL)); + result.Success = true; + } + catch (BytecodeException ex) + { + string message = string.Join(";", ex.Diagnostics + .Select(d => d.ToString()) + .Distinct(StringComparer.Ordinal)); + result.Diagnostics.Add(Diagnostic.Error( + ModuleLocations.Default(mainTree), message, DiagnosticCodes.Link)); + result.Image = null; + result.Success = false; + } + catch (Exception ex) + { + result.Diagnostics.Add(Diagnostic.Error( + ModuleLocations.Default(mainTree), ex.Message, DiagnosticCodes.Link)); + result.Image = null; + result.Success = false; + } + return FinalizeResult(result); + } + + static ModuleProjectResult FinalizeResult(ModuleProjectResult result) + { + result.Diagnostics = result.Diagnostics.DistinctStable().ToList(); + if (result.Diagnostics.Any(d => d.IsError)) + { + result.Image = null; + result.Success = false; + } return result; } } \ No newline at end of file diff --git a/src/EasyCon.Script/README.md b/src/EasyCon.Script/README.md index 2b36aff2..a4a7bc0a 100644 --- a/src/EasyCon.Script/README.md +++ b/src/EasyCon.Script/README.md @@ -144,7 +144,7 @@ EasyCon.Script/ - 无外部 NuGet 依赖 - 上层通过 `Compilation` 类和委托接口使用 -- `InternalsVisibleTo` 暴露给 `EasyCon.Lsp`、`EasyCon.Lsp.Tests`、`EasyCon.Tests`、`BenchTest` +- `InternalsVisibleTo` 暴露给 `EasyCon.Lsp`、`EasyCon.Lsp.Tests`、`EasyCon.Tests` --- diff --git a/src/EasyCon.Script/Resolution/DeclarationCollector.cs b/src/EasyCon.Script/Resolution/DeclarationCollector.cs index dee20fcb..bb98230f 100644 --- a/src/EasyCon.Script/Resolution/DeclarationCollector.cs +++ b/src/EasyCon.Script/Resolution/DeclarationCollector.cs @@ -134,7 +134,7 @@ private void CollectExternDeclaration(ExternFuncStmt syntax, BoundScope scope) var returnType = ResolveType(syntax.ReturnType, syntax, scope) ?? ScriptType.Void; var function = new FunctionSymbol(syntax.Name, parameters.ToImmutable(), returnType, - libraryName: syntax.Library, + libraryName: ExternLibraryPath.Resolve(syntax.Library, syntax.Location.FileName), externalName: syntax.ExportName != syntax.Name ? syntax.ExportName : null); function.LocalSlotCount = parameters.Count; diff --git a/src/EasyCon.Script/Resolution/ExternLibraryPath.cs b/src/EasyCon.Script/Resolution/ExternLibraryPath.cs new file mode 100644 index 00000000..d5f48dc4 --- /dev/null +++ b/src/EasyCon.Script/Resolution/ExternLibraryPath.cs @@ -0,0 +1,29 @@ +namespace EasyCon.Script.Resolution; + +/// +/// Resolves path-like EXTERN libraries relative to the ECS file that declares them. +/// Bare library names retain the operating system's normal loader search behavior. +/// +internal static class ExternLibraryPath +{ + public static string Resolve(string libraryName, string sourceFileName) + { + if (string.IsNullOrWhiteSpace(libraryName) + || Path.IsPathFullyQualified(libraryName) + || !LooksLikeRelativePath(libraryName) + || string.IsNullOrWhiteSpace(sourceFileName) + || sourceFileName.StartsWith('<')) + { + return libraryName; + } + + string sourcePath = Path.GetFullPath(sourceFileName); + string? sourceDirectory = Path.GetDirectoryName(sourcePath); + return string.IsNullOrEmpty(sourceDirectory) + ? libraryName + : Path.GetFullPath(Path.Combine(sourceDirectory, libraryName)); + } + + private static bool LooksLikeRelativePath(string libraryName) + => libraryName.Contains('/') || libraryName.Contains('\\'); +} \ No newline at end of file diff --git a/src/EasyCon.Script/Resolution/InterfaceScopeSynthesizer.cs b/src/EasyCon.Script/Resolution/InterfaceScopeSynthesizer.cs index abe70a41..aa114efa 100644 --- a/src/EasyCon.Script/Resolution/InterfaceScopeSynthesizer.cs +++ b/src/EasyCon.Script/Resolution/InterfaceScopeSynthesizer.cs @@ -33,7 +33,7 @@ public static (ResolutionResult Resolution, IReadOnlySet Externa // lib 模块可见采集卡洞函数(对齐 v1 libOnlyScope);main 模块与 v1 一致不注入 if (includeCaptureHoles) - foreach (var hole in Binding.BuiltinFunctions.GetCaptureHoles()) + foreach (var hole in Binding.BuiltinFunctions.GetLibOnlyHoles()) globalScope.TryDeclareFunction(hole); // ---- 结构体:两遍注册(字段类型可引用其他接口结构体),布局消费端重算 ---- @@ -107,12 +107,25 @@ public static (ResolutionResult Resolution, IReadOnlySet Externa // ---- 函数:非 alias → GlobalScope;alias → 独立 ModuleScope ---- var moduleScopes = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); var externalFunctions = new HashSet(); + var localFunctions = mainTree.Root.Members + .Select(member => member switch + { + FuncDeclBlock function => (Name: function.Declare.Name.ToUpperInvariant(), Count: function.Declare.Paramters.Length), + ExternFuncStmt external => (Name: external.Name.ToUpperInvariant(), Count: external.Parameters.Length), + _ => (Name: "", Count: -1), + }) + .Where(function => function.Count >= 0) + .ToHashSet(); foreach (var (iface, alias) in imports) { var target = alias is null ? globalScope : new BoundScope(globalScope); foreach (var f in iface.Functions) { - var symbol = DeclareExport(target, f, diagnostics); + // 本模块自己的同名/同参数数声明优先;把 peer 的重复导出塞进同一 + // GlobalScope 会让 Binder 把本地函数误当成冲突。AS 导入仍保留独立作用域。 + if (alias is null && localFunctions.Contains((f.Name.ToUpperInvariant(), f.Params.Count))) + continue; + var symbol = DeclareExport(target, f, diagnostics, iface.Name); if (symbol is not null) externalFunctions.Add(symbol); } @@ -130,7 +143,8 @@ public static (ResolutionResult Resolution, IReadOnlySet Externa externalFunctions); } - static FunctionSymbol? DeclareExport(BoundScope scope, ExportedFunction f, DiagnosticBag diagnostics) + static FunctionSymbol? DeclareExport(BoundScope scope, ExportedFunction f, DiagnosticBag diagnostics, + string sourceModule) { var parameters = ImmutableArray.CreateBuilder(); foreach (var p in f.Params) @@ -151,7 +165,8 @@ public static (ResolutionResult Resolution, IReadOnlySet Externa ? new FunctionSymbol(f.Name, parameters.ToImmutable(), returnType, libraryName: f.ExternLibrary ?? "internal", externalName: f.ExternalName) - : new FunctionSymbol(f.Name, parameters.ToImmutable(), returnType); + : new FunctionSymbol(f.Name, parameters.ToImmutable(), returnType, + originModule: sourceModule); symbol.LocalSlotCount = parameters.Count; // Declaration 保持 null:EnsureFunctionBodyBound 跳过绑体(Binder.cs:252 既有机制) diff --git a/src/EasyCon.Script/Runtime/SimpleStringStore.cs b/src/EasyCon.Script/Runtime/SimpleStringStore.cs index dcd11901..f1342943 100644 --- a/src/EasyCon.Script/Runtime/SimpleStringStore.cs +++ b/src/EasyCon.Script/Runtime/SimpleStringStore.cs @@ -6,7 +6,7 @@ namespace EasyCon.Script.Runtime; /// 简单的 IStringHandleStore 实现:用于单元测试和不需要 RuntimeHeap 的场景。 /// 提供 intern 能力但不做内存回收(Free 为空操作)。 /// -public sealed class SimpleStringStore : IStringHandleStore +internal sealed class SimpleStringStore : IStringHandleStore { private readonly List _strings = new() { "" }; // index 0 = null sentinel private readonly Dictionary _intern = new(StringComparer.Ordinal); diff --git a/src/EasyCon.Script/Ssa/CaptureAnalyzer.cs b/src/EasyCon.Script/Ssa/CaptureAnalyzer.cs index 45c5f276..c6e612d6 100644 --- a/src/EasyCon.Script/Ssa/CaptureAnalyzer.cs +++ b/src/EasyCon.Script/Ssa/CaptureAnalyzer.cs @@ -7,7 +7,7 @@ namespace EasyCon.Script.Ssa; /// /// 基于 SSA 调用图的采集卡需求分析。 /// -/// BoundProgram.NeedIL 是"乐观假设":只要 lib 里出现了 CaptureHole/OcrHole/RoiHole +/// BoundProgram.NeedIL 是"乐观假设":只要 lib 里出现了 CaptureHole/TesseractHole/RoiHole /// 的调用(标准库 VisionSource 一定如此),就标记为需要采集卡。这对纯算法脚本 /// 会导致 CLI 无谓地打开采集卡设备。 /// @@ -56,18 +56,13 @@ private static bool ReachesCaptureHole(SsaProgram program) { foreach (var inst in block.Instructions) { - // Case 1: 内联到 SSA 的 vision 洞指令(FRAME/OCR/ROI 经 EmitIntrinsic 处理后) - // 这些指令直接代表一次采集卡操作,无需再向下展开 - if (inst.Op is SsaOp.Capture or SsaOp.Ocr or SsaOp.Roi) - return true; - - // Case 2: 常规函数调用,检查 callee + // 采集洞已全部走通用名表调用(StaticCall + Aux=洞符号),统一按 callee 判定 if (inst.Op is not (SsaOp.Call or SsaOp.StaticCall)) continue; if (inst.Aux is not FunctionSymbol callee) continue; - // 命中未内联的 vision 洞函数 → 一定需要采集卡 + // 命中采集洞函数(RequiresCapture 清单)→ 一定需要采集卡 if (BuiltinFunctions.RequiresCapture(callee)) return true; diff --git a/src/EasyCon.Script/Ssa/SsaBuilder.Expressions.cs b/src/EasyCon.Script/Ssa/SsaBuilder.Expressions.cs index e80f47a1..988df9e0 100644 --- a/src/EasyCon.Script/Ssa/SsaBuilder.Expressions.cs +++ b/src/EasyCon.Script/Ssa/SsaBuilder.Expressions.cs @@ -161,19 +161,18 @@ private SsaValue EmitUnary(BoundUnaryExpression un) private SsaValue EmitNegate(ScriptType type, SsaValue operand) { - var zeroOp = type.Equals(ScriptType.UInt) ? SsaOp.ConstUInt - : type.Equals(ScriptType.UInt64) ? SsaOp.ConstUInt64 - : type.Equals(ScriptType.Byte) ? SsaOp.ConstByte - : SsaOp.ConstInt; - var zero = NewValue(zeroOp, type); - AddInst(zero); // 常量必须加入指令列表 - var op = type switch + if (type.Equals(ScriptType.Double)) + return EmitAndAdd(SsaOp.NegDouble, type, operand); + if (type.Equals(ScriptType.UInt) || type.Equals(ScriptType.UInt64)) { - _ when type.Equals(ScriptType.UInt) => SsaOp.SubUInt, - _ when type.Equals(ScriptType.UInt64) => SsaOp.SubUInt64, - _ => SsaOp.SubInt - }; - return EmitAndAdd(op, type, zero, operand); + // 无专用取负:0 − x(保持既有语义) + var zeroOp = type.Equals(ScriptType.UInt) ? SsaOp.ConstUInt : SsaOp.ConstUInt64; + var zero = NewValue(zeroOp, type); + AddInst(zero); + var subOp = type.Equals(ScriptType.UInt) ? SsaOp.SubUInt : SsaOp.SubUInt64; + return EmitAndAdd(subOp, type, zero, operand); + } + return EmitAndAdd(SsaOp.NegInt, type, operand); } private SsaValue EmitConversion(BoundConversionExpression conv) diff --git a/src/EasyCon.Script/Ssa/SsaBuilder.Intrinsics.cs b/src/EasyCon.Script/Ssa/SsaBuilder.Intrinsics.cs index aaca49fe..1606a948 100644 --- a/src/EasyCon.Script/Ssa/SsaBuilder.Intrinsics.cs +++ b/src/EasyCon.Script/Ssa/SsaBuilder.Intrinsics.cs @@ -48,56 +48,6 @@ private SsaValue EmitIntrinsic(BoundCallExpression call) var max = EmitExpression(call.Arguments[0]); return EmitAndAdd(SsaOp.Rand, ScriptType.Int, max); } - if (fn == BuiltinFunctions.CaptureHole) - { - var x = EmitExpression(call.Arguments[0]); - var y = EmitExpression(call.Arguments[1]); - var w = EmitExpression(call.Arguments[2]); - var h = EmitExpression(call.Arguments[3]); - var v = NewValue(SsaOp.Capture, ScriptType.String, x, y); - v.ExtraArgs = new List { w, h }; - AddExtraUses(v.ExtraArgs); - AddInst(v); - return v; - } - if (fn == BuiltinFunctions.OcrHole) - { - var x = EmitExpression(call.Arguments[0]); - var y = EmitExpression(call.Arguments[1]); - var w = EmitExpression(call.Arguments[2]); - var h = EmitExpression(call.Arguments[3]); - var lang = EmitExpression(call.Arguments[4]); - var v = NewValue(SsaOp.Ocr, ScriptType.String, x, y); - v.ExtraArgs = new List { w, h, lang }; - AddExtraUses(v.ExtraArgs); - AddInst(v); - return v; - } - if (fn == BuiltinFunctions.RoiHole) - { - var image = EmitExpression(call.Arguments[0]); - var x = EmitExpression(call.Arguments[1]); - var y = EmitExpression(call.Arguments[2]); - var w = EmitExpression(call.Arguments[3]); - var h = EmitExpression(call.Arguments[4]); - var v = NewValue(SsaOp.Roi, ScriptType.String, image, x); - v.ExtraArgs = new List { y, w, h }; - AddExtraUses(v.ExtraArgs); - AddInst(v); - return v; - } - if (fn == BuiltinFunctions.OcrInitHole) - { - var lang = EmitExpression(call.Arguments[0]); - var dataPath = EmitExpression(call.Arguments[1]); - var engineMode = EmitExpression(call.Arguments[2]); - var psmode = EmitExpression(call.Arguments[3]); - var v = NewValue(SsaOp.OcrInit, ScriptType.Bool, lang, dataPath); - v.ExtraArgs = new List { engineMode, psmode }; - AddExtraUses(v.ExtraArgs); - AddInst(v); - return v; - } throw new InvalidOperationException($"未知内联函数: {fn.Name}"); } @@ -179,6 +129,10 @@ BoundBinaryOperatorKind.Equals when op.LeftType.Equals(ScriptType.UInt64) => Ssa BoundBinaryOperatorKind.Equals when op.LeftType.Equals(ScriptType.Bool) => SsaOp.EqBool, BoundBinaryOperatorKind.Equals when op.LeftType.Equals(ScriptType.String) => SsaOp.EqString, BoundBinaryOperatorKind.Equals when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.EqPtr, + BoundBinaryOperatorKind.Less when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.LtPtr, + BoundBinaryOperatorKind.LessOrEquals when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.LeqPtr, + BoundBinaryOperatorKind.Greater when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.GtPtr, + BoundBinaryOperatorKind.GreaterOrEquals when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.GeqPtr, BoundBinaryOperatorKind.Equals when op.LeftType.Equals(ScriptType.Byte) => SsaOp.EqByte, BoundBinaryOperatorKind.NotEquals when op.LeftType.Equals(ScriptType.Int) => SsaOp.NeqInt, @@ -188,6 +142,10 @@ BoundBinaryOperatorKind.NotEquals when op.LeftType.Equals(ScriptType.UInt64) => BoundBinaryOperatorKind.NotEquals when op.LeftType.Equals(ScriptType.Bool) => SsaOp.NeqBool, BoundBinaryOperatorKind.NotEquals when op.LeftType.Equals(ScriptType.String) => SsaOp.NeqString, BoundBinaryOperatorKind.NotEquals when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.NeqPtr, + BoundBinaryOperatorKind.Less when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.LtPtr, + BoundBinaryOperatorKind.LessOrEquals when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.LeqPtr, + BoundBinaryOperatorKind.Greater when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.GtPtr, + BoundBinaryOperatorKind.GreaterOrEquals when op.LeftType.Equals(ScriptType.Ptr) => SsaOp.GeqPtr, BoundBinaryOperatorKind.NotEquals when op.LeftType.Equals(ScriptType.Byte) => SsaOp.NeqByte, BoundBinaryOperatorKind.Less when op.LeftType.Equals(ScriptType.Int) => SsaOp.LtInt, diff --git a/src/EasyCon.Script/Ssa/SsaBuilder.Statements.cs b/src/EasyCon.Script/Ssa/SsaBuilder.Statements.cs index 834bf0d7..4add5ff6 100644 --- a/src/EasyCon.Script/Ssa/SsaBuilder.Statements.cs +++ b/src/EasyCon.Script/Ssa/SsaBuilder.Statements.cs @@ -244,6 +244,13 @@ private void EmitFor(BoundForStatement forStmt) { if (_inDeadSink) return; + // ForStep 快速路径(尾部融合,15 条 → 3 条/轮):局部变量且 body 不重写循环变量 + if (forStmt.Variable is LocalVariableSymbol localVar && !BodyWritesVariable(forStmt.Body, localVar)) + { + EmitForFastPath(forStmt, localVar); + return; + } + var headerBlock = CreateBlock(); var endBlock = CreateBlock(); @@ -379,6 +386,97 @@ private void EmitFor(BoundForStatement forStmt) SwitchToBlock(endBlock); } + /// + /// FOR 快速路径(尾部 ForStep 融合):布局 P → body(循环头, phi) → tail(ForStep) → end。 + /// ForStep 语义:i==upper → 落入 end(保持 i==upper 出循环);否则 i+=1 跳回 body。 + /// phi 自引用臂(tail 侧 = phi 本身)+ ForStep 原地写 phi 槽 → 回边零副本。 + /// CONTINUE → tail(先递增再进 body,语义正确);BREAK → end。 + /// + private void EmitForFastPath(BoundForStatement forStmt, LocalVariableSymbol variable) + { + var bodyBlock = CreateBlock(); // 循环头:i 的 phi + 用户代码 + var tailBlock = CreateBlock(); // ForStep + var endBlock = CreateBlock(); + _labelBlocks[forStmt.BreakLabel] = endBlock; + _labelBlocks[forStmt.ContinueLabel] = tailBlock; + + // 初始化 + 上限(进循环前求值一次) + var initVal = EmitExpression(forStmt.LowerBound!); + var store = NewValue(SsaOp.StoreLocal, ScriptType.Void, initVal, aux: variable); + AddInst(store); + _vars.WriteVariable(variable, initVal, _currentBlock); + var upperVal = EmitExpression(forStmt.UpperBound!); + + // 入口守卫:a > upper → 零迭代 + var guard = NewValue(SsaOp.GtInt, ScriptType.Bool, initVal, upperVal); + AddInst(guard); + _currentBlock.BranchCondition = guard; + guard.Uses++; + _currentBlock.TrueSuccessor = endBlock; + _currentBlock.FalseSuccessor = bodyBlock; + endBlock.AddPredecessor(_currentBlock); + bodyBlock.AddPredecessor(_currentBlock); + + // body:循环头。不立即封闭——回边来自 tail,读取处的占位 phi 在 SealBlock 时回填 + SwitchToBlock(bodyBlock, fromConditionalBranch: true); + EmitStatements(forStmt.Body.Statements); + + if (NeedsTerminator(_currentBlock) && !_inDeadSink) + { + _currentBlock.JumpTarget = tailBlock; + tailBlock.AddPredecessor(_currentBlock); + + // tail:ForStep = 真实指令(SSA 可见定义 → phi 臂非自引用,不被平凡替换): + // tmp = i + 1; dst = tmp; tmp > upper → 跳 end(保持 $i == upper 出循环);否则落入 arm 副本 + 回边 Jmp + SwitchToBlock(tailBlock); + _vars.SealBlock(tailBlock); + var varVal = _vars.ReadVariable(variable, ScriptType.Int, tailBlock); + var step = NewValue(SsaOp.ForStep, ScriptType.Int, varVal, upperVal); + AddInst(step); + _vars.WriteVariable(variable, step, tailBlock); + _currentBlock.BranchCondition = step; + step.Uses++; + _currentBlock.TrueSuccessor = bodyBlock; // i循环体内是否重写循环变量(重写 → 退回通用 lowering:ForStep 的原地写只对 phi 槽安全)。 + private static bool BodyWritesVariable(BoundStmt stmt, VariableSymbol variable) + { + switch (stmt) + { + case BoundBlockStatement b: + return b.Statements.Any(s => BodyWritesVariable(s, variable)); + case BoundVariableDeclaration vd: + return vd.Variable == variable; + case BoundIfStatement i: + return BodyWritesVariable(i.Body, variable) + || i.ElseIfs.Any(e => BodyWritesVariable(e.Body, variable)) + || (i.ElseBody != null && BodyWritesVariable(i.ElseBody, variable)); + case BoundWhileStatement w: + return BodyWritesVariable(w.Body, variable); + case BoundUntilStatement u: + return BodyWritesVariable(u.Body, variable); + case BoundForStatement f: + return f.Variable == variable || BodyWritesVariable(f.Body, variable); + default: + return false; + } + } + private void EmitUntil(BoundUntilStatement untilStmt) { if (_inDeadSink) return; diff --git a/src/EasyCon.Script/Ssa/SsaCfgSimplification.cs b/src/EasyCon.Script/Ssa/SsaCfgSimplification.cs index 50e0165d..37ff6939 100644 --- a/src/EasyCon.Script/Ssa/SsaCfgSimplification.cs +++ b/src/EasyCon.Script/Ssa/SsaCfgSimplification.cs @@ -110,6 +110,80 @@ private static void MergeBlockIntoPredecessor(SsaFunction func, SsaBlock pred, S func.Blocks.RemoveAt(blockIndex); } + // ============ 空 trampoline 折叠 ============ + + /// + /// 空 trampoline 块折叠:无指令、无 phi、纯 JumpTarget 的中转块 T(T→S), + /// 前驱重定向到 S 并按 T 的臂值对齐 S 的 phi——每处省一条 Jmp(常驻循环周边路径, + /// FOR 嵌套场景每轮 1-2 条)。MergeBlocks 不覆盖此形态:T 的前驱是条件分支块 + /// (两个后继)时无法合并入前驱,T 作为「合并汇」幸存到编码期,只贡献一条跳转。 + /// 语义依据:T 无 phi 无指令 ⟹ 经 T 到达 S 的所有路径携带同一臂值(T 的原臂), + /// 重定向后按前驱逐边补臂即可(Predecessors↔ExtraArgs 对齐由增删配对维护)。 + /// + internal static bool FoldEmptyTrampolines(SsaFunction func) + { + bool changed = false; + for (int i = func.Blocks.Count - 1; i >= 0; i--) + { + var t = func.Blocks[i]; + if (t == func.Entry) + continue; + if (t.Instructions.Count > 0 || t.Phis.Count > 0) + continue; + if (t.BranchCondition != null || t.IsReturn) + continue; + var s = t.JumpTarget; + if (s == null || s == t) + continue; + int tIdx = s.Predecessors.IndexOf(t); + if (tIdx < 0) + continue; // 无前驱:死块,RemoveUnreachableBlocks 处理 + var preds = t.Predecessors.ToList(); + if (preds.Contains(s)) + continue; // S→T→S 自环形态:重定向即自跳,保守跳过 + if (preds.Any(p => p.TrueSuccessor == s || p.FalseSuccessor == s || p.JumpTarget == s)) + continue; // 重定向将产生重复边(P→S 既有直连又有经 T):SCCP 的 executableEdges + // 是 (from,to) 集合,φ 双臂被同一条边标记门控、meet 误并两臂值 + //(素数筛 9999 实证)——此形态保留 trampoline 不折 + + // T 贡献给 S 的臂值(删除前捕获) + var arms = new List(); + foreach (var phi in s.Phis) + arms.Add(phi.ExtraArgs != null && tIdx < phi.ExtraArgs.Count ? phi.ExtraArgs[tIdx] : null); + + // 前驱重定向到 S + foreach (var p in preds) + { + if (p.TrueSuccessor == t) p.TrueSuccessor = s; + if (p.FalseSuccessor == t) p.FalseSuccessor = s; + if (p.JumpTarget == t) p.JumpTarget = s; + } + + // S:摘除 T(臂随删),再按新前驱逐边补臂(值 = T 的原臂) + s.RemovePredecessorAt(tIdx); + foreach (var p in preds) + { + s.Predecessors.Add(p); + for (int k = 0; k < s.Phis.Count; k++) + { + var phi = s.Phis[k]; + phi.ExtraArgs ??= new List(); + var arm = arms[k]; + if (arm == null) + continue; // T 摘除前臂缺失(防御):占位自引用由 FillPhiArms 语义兜底 + phi.ExtraArgs.Add(arm); + arm.Uses++; + } + } + + func.Blocks.RemoveAt(i); + changed = true; + if (Environment.GetEnvironmentVariable("ECX_PASS_TRACE") == "1") + Console.Error.WriteLine($"[tramp] 折叠空中转块 -> b{s.Id}(fn={func.Symbol.Name})"); + } + return changed; + } + // ============ 不可达块删除 ============ internal static bool RemoveUnreachableBlocks(SsaFunction func) diff --git a/src/EasyCon.Script/Ssa/SsaConstantPropagation.cs b/src/EasyCon.Script/Ssa/SsaConstantPropagation.cs index e645d243..435b6ae0 100644 --- a/src/EasyCon.Script/Ssa/SsaConstantPropagation.cs +++ b/src/EasyCon.Script/Ssa/SsaConstantPropagation.cs @@ -171,7 +171,6 @@ or SsaOp.StickAction or SsaOp.StickPress or SsaOp.Wait or SsaOp.StoreLocal or SsaOp.StoreGlobal or SsaOp.StoreField or SsaOp.StoreIndex or SsaOp.StoreFieldIndex or SsaOp.Return or SsaOp.Nop - or SsaOp.Capture or SsaOp.Ocr or SsaOp.Roi or SsaOp.Rand or SsaOp.RuntimeValue or SsaOp.ImageLabel or SsaOp.LoadIndex or SsaOp.LoadField or SsaOp.LoadFieldIndex @@ -197,6 +196,15 @@ or SsaOp.Slice or SsaOp.Contains if (inst.Op == SsaOp.Phi) return; + // ForStep:分支内嵌自增(SSA 值 = 新循环变量),其值运行期才知道 → 恒 unknown(Bottom)。 + // 绝不能按二元操作折叠:两侧常量时会被折成常量,循环变量 phi 随之折叠成常量, + // ForStep 的自增写入孤立槽位 → 死循环 + if (inst.Op == SsaOp.ForStep) + { + UpdateLattice(inst, LatticeValue.Bottom(), lattice, ssaWorklist, useMap); + return; + } + // 二元操作 if (inst.Arg0 != null && inst.Arg1 != null) { @@ -287,6 +295,15 @@ private static void ProcessTerminator( { if (block.BranchCondition != null) { + // ForStep(FOR 快速路径)是终结符专用值:lattice 恒为 Top,且语义上两条出边都可能执行 + //(运行期由指令内比较决定走向)→ 必须标记双边,否则循环体被误判不可达而删除 + if (block.BranchCondition.Op == SsaOp.ForStep) + { + MarkEdge(block, block.TrueSuccessor!, reachableBlocks, executableEdges, blockWorklist); + MarkEdge(block, block.FalseSuccessor!, reachableBlocks, executableEdges, blockWorklist); + return; + } + var condLattice = GetLatticeOrBottom(block.BranchCondition, lattice); if (condLattice.Tag == LatticeTag.Const && condLattice.ConstKind == SsaOp.ConstBool) @@ -984,6 +1001,8 @@ private static bool FoldUnaryInPlace(SsaValue inst) { case SsaOp.LogicNot: SsaOptimizer.SetBoolResult(inst, !operand.Const.GetBool()); return true; case SsaOp.NotInt: SsaOptimizer.SetIntResult(inst, ~operand.Const.GetInt()); return true; + case SsaOp.NegInt: SsaOptimizer.SetIntResult(inst, -operand.Const.GetInt()); return true; + case SsaOp.NegDouble: SsaOptimizer.SetDoubleResult(inst, -operand.Const.GetDouble()); return true; case SsaOp.ConvBoolToInt: SsaOptimizer.SetIntResult(inst, operand.Const.GetBool() ? 1 : 0); return true; case SsaOp.ConvByteToInt: SsaOptimizer.SetIntResult(inst, operand.Const.GetInt()); return true; case SsaOp.ConvIntToDouble: SsaOptimizer.SetDoubleResult(inst, operand.Const.GetInt()); return true; diff --git a/src/EasyCon.Script/Ssa/SsaGlobalPromotion.cs b/src/EasyCon.Script/Ssa/SsaGlobalPromotion.cs index bc1f39d6..9321c7c4 100644 --- a/src/EasyCon.Script/Ssa/SsaGlobalPromotion.cs +++ b/src/EasyCon.Script/Ssa/SsaGlobalPromotion.cs @@ -19,7 +19,6 @@ namespace EasyCon.Script.Ssa; ///
internal static class SsaGlobalPromotion { - private static int _nextId; /// 全局提升上下文:一个全局在提升期间的私有状态。 sealed class GlobalInfo @@ -30,14 +29,19 @@ sealed class GlobalInfo public List LoadInsts = new(); } + // M3:模块并行 Optimize → 计数器 = 进程级单调(Interlocked),**不重置**—— + // 重置会让并行模块互相清零、模块内 ID 撞号 + private static int _nextId; + internal static void Run(SsaProgram program) { - _nextId = 0; + // 打底:原子加 programMax + 1,保证本程序新值 ID > 既有最大(单调不减,跨模块共享序列) + int programMax = 0; foreach (var fn in AllFunctions(program)) foreach (var b in fn.Blocks) foreach (var v in b.Instructions.Concat(b.Phis)) - if (v.Id > _nextId) _nextId = v.Id; - _nextId++; + if (v.Id > programMax) programMax = v.Id; + Interlocked.Add(ref _nextId, programMax + 1 - _nextId); var fns = AllFunctions(program).ToList(); var stores = new Dictionary>(); @@ -117,7 +121,8 @@ static void DumpFn(SsaFunction fn, string tag) var bc = b.BranchCondition != null ? $" bc=v{b.BranchCondition.Id}:{b.BranchCondition.Op}(u{b.BranchCondition.Uses})" : ""; var phis = b.Phis.Count > 0 ? " phis=[" + string.Join(",", b.Phis.Select(p => $"v{p.Id}<-[{string.Join(",", p.ExtraArgs?.Select(a => $"v{a.Id}:{a.Op}") ?? [])}]")) + "]" : ""; - Console.Error.WriteLine($"[gp {tag}] fn={fn.Symbol.Name} b{b.Id} preds=[{string.Join(",", b.Predecessors.Select(p => p.Id))}]{bc}{phis} insts=[{string.Join(", ", b.Instructions.Select(i => $"v{i.Id}:{i.Op}(u{i.Uses})"))}]"); + static string AuxOf(SsaValue i) => i.Aux is Symbols.GlobalVariableSymbol gs ? $":{gs.Name}" : ""; + Console.Error.WriteLine($"[gp {tag}] fn={fn.Symbol.Name} b{b.Id} preds=[{string.Join(",", b.Predecessors.Select(p => p.Id))}]{bc}{phis} insts=[{string.Join(", ", b.Instructions.Select(i => $"v{i.Id}:{i.Op}{AuxOf(i)}(u{i.Uses})"))}]"); } } @@ -150,7 +155,7 @@ static void PromoteConst(List<(SsaFunction Fn, SsaValue Inst)> loadSites, SsaVal { if (!perFn.TryGetValue(fn, out var cv)) { - cv = new SsaValue(_nextId++, constVal.Op, constVal.Type) + cv = new SsaValue(Interlocked.Increment(ref _nextId), constVal.Op, constVal.Type) { Const = constVal.Const, ConstString = constVal.ConstString, @@ -209,12 +214,16 @@ static void PromoteFunctionLocals( return; } - var exitDef = new Dictionary<(SsaBlock, GlobalVariableSymbol), SsaValue>(); // 块出口定值 + // 块出口定值:存 store 指令本身,Arg0 惰性解引用—— + // 后续 LoadGlobal→提升值替换会就地改写 store.Arg0,预快照值会变悬挂引用 + //(实证:$i = $N 生成的 StoreGlobal($i, LoadGlobal($N)),$N 的 load 被提升删除后 + // 合流 φ 臂仍引用已删除的 LoadGlobal → "phi input references missing") + var exitDef = new Dictionary<(SsaBlock, GlobalVariableSymbol), SsaValue>(); // 块出口 store 指令 var entryDef = new Dictionary<(SsaBlock, GlobalVariableSymbol), SsaValue>(); // 块入口到达值(φ 断环缓存) foreach (var (g, inst) in stores) { var block = FindBlock(fn, inst)!; - exitDef[(block, g)] = inst.Arg0!; + exitDef[(block, g)] = inst; } // 块入口到达值:多前驱 → 占位 φ 先登记断环(回边自引用臂由此产生),再填臂; @@ -225,7 +234,7 @@ SsaValue EntryValue(GlobalVariableSymbol g, SsaBlock block) return def; if (block.Predecessors.Count == 0) { - var zero = NewZero(g.Type, block, _nextId++); + var zero = NewZero(g.Type, block, Interlocked.Increment(ref _nextId)); block.Instructions.Insert(0, zero); entryDef[(block, g)] = zero; return zero; @@ -236,7 +245,7 @@ SsaValue EntryValue(GlobalVariableSymbol g, SsaBlock block) entryDef[(block, g)] = v; return v; } - var phi = new SsaValue(_nextId++, SsaOp.Phi, g.Type) { Block = block }; + var phi = new SsaValue(Interlocked.Increment(ref _nextId), SsaOp.Phi, g.Type) { Block = block }; phi.ExtraArgs = new List(); entryDef[(block, g)] = phi; foreach (var pred in block.Predecessors) @@ -252,8 +261,8 @@ SsaValue EntryValue(GlobalVariableSymbol g, SsaBlock block) // 块出口值:块内最后一次 store 的值;块内无 store → 入口值 SsaValue ExitValue(GlobalVariableSymbol g, SsaBlock block) { - if (exitDef.TryGetValue((block, g), out var def)) - return def; + if (exitDef.TryGetValue((block, g), out var storeInst)) + return storeInst.Arg0!; // 惰性解引用,取替换后的最新值 return EntryValue(g, block); } diff --git a/src/EasyCon.Script/Ssa/SsaInterprocedural.cs b/src/EasyCon.Script/Ssa/SsaInterprocedural.cs index 486bb3f1..6a48de3b 100644 --- a/src/EasyCon.Script/Ssa/SsaInterprocedural.cs +++ b/src/EasyCon.Script/Ssa/SsaInterprocedural.cs @@ -125,12 +125,13 @@ private static void CollectUsedStructs(ScriptType type, HashSet us /// /// 全局 ID 计数器,用于内联时分配新的 SsaValue ID,避免与 caller 的 ID 冲突。 - /// 在 InlineTrivialFunctions 入口处从程序最大 ID 初始化。 + /// M3 模块并行:进程级单调(Interlocked),ResetIdCounter 只提升下限(CAS)不重置—— + /// 重置会让并行模块互相清零、模块内内联 ID 撞号。 /// private static int _globalInlineIdCounter; /// - /// 每次 Optimize 入口调用:把内联 ID 计数器强制重置为程序最大 ID + 1。 + /// 每次 Optimize 入口调用:把内联 ID 计数器下限提升为程序最大 ID + 1(CAS,单调不减)。 /// 修复进程内残留计数器与新程序主序列撞号的 bug(SSA ID 冲突)。 /// internal static void ResetIdCounter(SsaProgram program) @@ -144,7 +145,13 @@ internal static void ResetIdCounter(SsaProgram program) foreach (var block in program.MainFunction.Blocks) foreach (var val in block.Instructions.Concat(block.Phis)) if (val.Id > maxId) maxId = val.Id; - _globalInlineIdCounter = maxId + 1; + int cur = _globalInlineIdCounter; + while (cur < maxId + 1) + { + int prev = System.Threading.Interlocked.CompareExchange(ref _globalInlineIdCounter, maxId + 1, cur); + if (prev == cur) break; + cur = prev; + } } /// @@ -355,9 +362,7 @@ private static SsaValue CloneWithFreshId(SsaValue original, Dictionary IntrinsicMap = new() { ["TIME"] = (SsaOp.RuntimeValue, ScriptType.Int, []), - ["FRAME"] = (SsaOp.Capture, ScriptType.String, []), // 0 参数和 4 参数共用,通过参数数区分 - ["OCR"] = (SsaOp.Ocr, ScriptType.String, []), // 4 参数版本("chi_sim" 由包装函数内部提供) - ["ROI"] = (SsaOp.Roi, ScriptType.String, []), + // FRAME/OCR/ROI 已迁通用名表(__CAPTURE__/__TESSERACT__/__ROI__ 洞调用),不再内联 }; /// diff --git a/src/EasyCon.Script/Ssa/SsaLattice.cs b/src/EasyCon.Script/Ssa/SsaLattice.cs index 73846bd3..a9a00fdb 100644 --- a/src/EasyCon.Script/Ssa/SsaLattice.cs +++ b/src/EasyCon.Script/Ssa/SsaLattice.cs @@ -288,6 +288,8 @@ private static LatticeValue FoldUnary(SsaOp op, ref LatticeValue operand) { case SsaOp.LogicNot: return ConstBool(!operand.Value.GetBool()); case SsaOp.NotInt: return ConstInt(~operand.Value.GetInt()); + case SsaOp.NegInt: return ConstInt(-operand.Value.GetInt()); + case SsaOp.NegDouble: return ConstDouble(-operand.Value.GetDouble()); case SsaOp.ConvBoolToInt: return ConstInt(operand.Value.GetBool() ? 1 : 0); case SsaOp.ConvByteToInt: return ConstInt(operand.Value.GetInt()); case SsaOp.ConvIntToDouble: return ConstDouble(operand.Value.GetInt()); diff --git a/src/EasyCon.Script/Ssa/SsaOp.cs b/src/EasyCon.Script/Ssa/SsaOp.cs index 586d487e..a74c2792 100644 --- a/src/EasyCon.Script/Ssa/SsaOp.cs +++ b/src/EasyCon.Script/Ssa/SsaOp.cs @@ -42,12 +42,15 @@ public enum SsaOp : byte EqUInt64, NeqUInt64, LtUInt64, LeqUInt64, GtUInt64, GeqUInt64, // ---- 比较(bool/string/ptr/byte) ---- EqBool, NeqBool, EqString, NeqString, - EqPtr, NeqPtr, + EqPtr, LtPtr, LeqPtr, GtPtr, GeqPtr, NeqPtr, EqByte, NeqByte, LtByte, LeqByte, GtByte, GeqByte, // ---- 逻辑 ---- LogicNot, + // ---- 一元取负(对应字节码 NegI/NegD)---- + NegInt, NegDouble, + // ---- 类型转换 ---- ConvBoolToInt, ConvByteToInt, ConvIntToUInt, ConvIntToUInt64, ConvIntToDouble, ConvIntToByte, @@ -92,17 +95,16 @@ public enum SsaOp : byte Wait, // Arg0=duration Rand, // Arg0=max, 返回 [0, max) 随机整数 - // ---- OCR 引擎初始化 ---- - OcrInit, // Arg0=lang, Arg1=dataPath, ExtraArgs=[engineMode, psmode], 结果=0/1(bool) + // ---- OCR 引擎初始化 ----, // Arg0=lang, Arg1=dataPath, ExtraArgs=[engineMode, psmode], 结果=0/1(bool) - // ---- 采集卡打洞函数 ---- - Capture, // Arg0=x, ExtraArgs=[y, w, h] - Ocr, // Arg0=x, Arg1=y, ExtraArgs=[w, h, lang] - Roi, // Arg0=image, Arg1=x, ExtraArgs=[y, w, h] + // ---- 采集卡打洞函数 ----, // Arg0=x, ExtraArgs=[y, w, h], // Arg0=x, Arg1=y, ExtraArgs=[w, h, lang], // Arg0=image, Arg1=x, ExtraArgs=[y, w, h] // ---- 运行时 ---- RuntimeValue, // AuxSymbol 存 name string(特殊用法) ImageLabel, // AuxSymbol 存 label name + // ---- FOR 循环融合(ECS opcode ForStep 的 SSA 形态;只作 BranchCondition,不进 Instructions) ---- + ForStep, // Arg0=循环变量(槽内自增), Arg1=上限;==上限 → 跳出(FalseSuccessor),否则 i+=1 落入循环体 + Nop, } \ No newline at end of file diff --git a/src/EasyCon.Script/Ssa/SsaOptimizer.cs b/src/EasyCon.Script/Ssa/SsaOptimizer.cs index ea8f98d4..d6b977ee 100644 --- a/src/EasyCon.Script/Ssa/SsaOptimizer.cs +++ b/src/EasyCon.Script/Ssa/SsaOptimizer.cs @@ -54,17 +54,18 @@ public static void Optimize(SsaProgram program, SsaOptimizeTiming? optTiming = n globalMaxId = MaxId(program.MainFunction); foreach (var f in program.Functions.Values) globalMaxId = Math.Max(globalMaxId, MaxId(f)); - SsaTailRecursionElimination.GlobalNextId = globalMaxId + 1; + var treNextId = new System.Runtime.CompilerServices.StrongBox(globalMaxId + 1); + // TRE 新值 ID 计数器按 program 共享(盒跨函数递增;模块并行安全) if (program.MainFunction != null) - OptimizeFunction(program.MainFunction); + OptimizeFunction(program.MainFunction, treNextId); // MainFunction 已在 Functions 字典中且已直接优化过,排除之: // 否则会被并行循环再优化一遍(重复优化非幂等 pass 会破坏 IR,转储也会互相覆盖) var funcList = program.Functions.Values .Where(f => !ReferenceEquals(f, program.MainFunction)) .ToList(); if (funcList.Count > 0) - Parallel.ForEach(funcList, OptimizeFunction); + Parallel.ForEach(funcList, f => OptimizeFunction(f, treNextId)); if (optTiming != null) optTiming.IntraFunctionOpt = sw.Elapsed; // 内联 stdlib 包装函数(需要跨函数查表,放在函数内优化之后) @@ -99,35 +100,42 @@ private static void Dump(SsaFunction func, string tag) } } - internal static void OptimizeFunction(SsaFunction func) + /// 函数内 pass(M4 声明化):名字 + 执行委托。顺序 = 语义契约,禁止重排 + /// (不动点收敛依赖该序——如 DCE 前须先 PropagateCopies/CSE 清引用);ECX_PASS_TRACE + /// 经统一 instrumentation 输出。TRE 项经闭包共享 program 级 ID 盒(模块并行安全)。 + private sealed record IntraPass(string Name, Func Run); + + internal static void OptimizeFunction(SsaFunction func, System.Runtime.CompilerServices.StrongBox treNextId) { if (IsTooComplex(func)) return; + // 函数内 pass 流水线(同 pass 列表类 doc:序即契约) + var passes = new IntraPass[] + { + new("SCCP", SsaConstantPropagation.Run), + new("Algebraic", SsaConstantPropagation.AlgebraicSimplify), + new("TRE", f => SsaTailRecursionElimination.Eliminate(f, ref treNextId.Value)), + new("PropCopies", SsaRedundancyElimination.PropagateCopies), + new("CSE", SsaRedundancyElimination.EliminateCommonSubexpressions), + new("DCE", SsaDeadCodeElimination.EliminateDeadCode), + new("Merge", SsaCfgSimplification.MergeBlocks), + new("Trampoline", SsaCfgSimplification.FoldEmptyTrampolines), + new("Unreach", SsaCfgSimplification.RemoveUnreachableBlocks), + new("Dedup", SsaConstantPropagation.DeduplicateConstants), + }; + bool changed; int iterations = 0; const int MaxIterations = 5; do { changed = false; - changed |= SsaConstantPropagation.Run(func); - if (TraceEval) Dump(func, $"it{iterations} SCCP"); - changed |= SsaConstantPropagation.AlgebraicSimplify(func); - if (TraceEval) Dump(func, $"it{iterations} Algebraic"); - changed |= SsaTailRecursionElimination.Eliminate(func); - changed |= SsaRedundancyElimination.PropagateCopies(func); - if (TraceEval) Dump(func, $"it{iterations} PropCopies"); - changed |= SsaRedundancyElimination.EliminateCommonSubexpressions(func); - if (TraceEval) Dump(func, $"it{iterations} CSE"); - changed |= SsaDeadCodeElimination.EliminateDeadCode(func); - if (TraceEval) Dump(func, $"it{iterations} DCE"); - changed |= SsaCfgSimplification.MergeBlocks(func); - if (TraceEval) Dump(func, $"it{iterations} Merge"); - changed |= SsaCfgSimplification.RemoveUnreachableBlocks(func); - if (TraceEval) Dump(func, $"it{iterations} Unreach"); - changed |= SsaConstantPropagation.DeduplicateConstants(func); - if (TraceEval) Dump(func, $"it{iterations} Dedup"); - + foreach (var pass in passes) + { + changed |= pass.Run(func); + if (TraceEval) Dump(func, $"it{iterations} {pass.Name}"); + } } while (changed && ++iterations < MaxIterations); #if DEBUG diff --git a/src/EasyCon.Script/Ssa/SsaRedundancyElimination.cs b/src/EasyCon.Script/Ssa/SsaRedundancyElimination.cs index fcb0d0ef..7d2adb79 100644 --- a/src/EasyCon.Script/Ssa/SsaRedundancyElimination.cs +++ b/src/EasyCon.Script/Ssa/SsaRedundancyElimination.cs @@ -127,22 +127,9 @@ internal static bool EliminateCommonSubexpressions(SsaFunction func) if (available.TryGetValue(key, out var existing)) { - // 找到相同表达式,用 existing 替换 inst 的所有使用 - SsaOptimizer.ReplaceAllUsesInBlock(block, inst, existing); - // phi 臂是跨块使用(臂值在本块终结符的边副本处读取,A-01): - // 删除本块定义须同步改写后继块 phi 的对应臂,否则臂引用悬空 - foreach (var succ in block.GetSuccessors()) - { - foreach (var phi in succ.Phis) - { - if (phi.ExtraArgs == null) continue; - for (int arm = 0; arm < succ.Predecessors.Count && arm < phi.ExtraArgs.Count; arm++) - if (succ.Predecessors[arm] == block && phi.ExtraArgs[arm] == inst) - phi.ExtraArgs[arm] = existing; - } - } - existing.Uses += inst.Uses; - inst.Uses = 0; + // 可用表达式可能被后续任意块的普通指令、phi 或分支使用。 + // 删除定义前必须在整个函数中改写这些引用,否则编码器会遇到悬空 SSA 值。 + SsaOptimizer.ReplaceAllUsesInFunction(func, inst, existing); block.Instructions.RemoveAt(i); i--; changed = true; diff --git a/src/EasyCon.Script/Ssa/SsaTailRecursionElimination.cs b/src/EasyCon.Script/Ssa/SsaTailRecursionElimination.cs index 26ed7bdd..a1ae045a 100644 --- a/src/EasyCon.Script/Ssa/SsaTailRecursionElimination.cs +++ b/src/EasyCon.Script/Ssa/SsaTailRecursionElimination.cs @@ -15,12 +15,12 @@ namespace EasyCon.Script.Ssa; /// static class SsaTailRecursionElimination { - /// 全局下一个可用 SSA ID(跨函数唯一)。由 SsaOptimizer 设置。 - internal static int GlobalNextId; + /// 注释:SSA ID 分配计数器由调用方(SsaOptimizer)按 program 持有并经 + /// ref 传入——M3 模块并行下静态计数器会被并行 Optimize 互相覆盖,导致模块内 ID 撞号。 /// /// 对函数执行尾递归消除。返回 true 表示做了变换。 /// - internal static bool Eliminate(SsaFunction func) + internal static bool Eliminate(SsaFunction func, ref int nextId) { var sym = func.Symbol; var parameters = sym.Parameters; @@ -75,7 +75,7 @@ internal static bool Eliminate(SsaFunction func) var newVal = argValues[i]; var store = new SsaValue( - System.Threading.Interlocked.Increment(ref GlobalNextId) - 1, + System.Threading.Interlocked.Increment(ref nextId) - 1, SsaOp.StoreLocal, ScriptType.Void) { Arg0 = newVal, diff --git a/src/EasyCon.Script/Ssa/SsaUseDefValidator.cs b/src/EasyCon.Script/Ssa/SsaUseDefValidator.cs new file mode 100644 index 00000000..32a21087 --- /dev/null +++ b/src/EasyCon.Script/Ssa/SsaUseDefValidator.cs @@ -0,0 +1,117 @@ +namespace EasyCon.Script.Ssa; + +/// +/// Validates that every SSA operand has a live definition available at its use site. +/// This runs immediately before bytecode encoding so optimizer defects produce a +/// compiler diagnostic instead of an encoder slot lookup failure. +/// +internal static class SsaUseDefValidator +{ + internal static void ValidateProgram(SsaProgram program) + { + var functions = program.MainFunction == null + ? program.Functions.Values + : program.Functions.Values.Prepend(program.MainFunction); + foreach (var function in functions.Distinct()) + ValidateFunction(function, "before bytecode encoding"); + } + + private static void ValidateFunction(SsaFunction function, string stage) + { + if (function.Blocks.Count == 0) + throw new InvalidOperationException($"SSA validation failed {stage}: {function.Symbol.Name} has no entry block"); + + var definitions = new Dictionary(); + foreach (var block in function.Blocks) + { + for (int i = 0; i < block.Phis.Count; i++) + AddDefinition(block.Phis[i], (block, i, true)); + for (int i = 0; i < block.Instructions.Count; i++) + AddDefinition(block.Instructions[i], (block, i, false)); + } + + var reachable = new HashSet(); + var pending = new Stack(); + pending.Push(function.Entry); + while (pending.Count > 0) + { + var block = pending.Pop(); + if (!reachable.Add(block)) + continue; + foreach (var successor in block.GetSuccessors()) + pending.Push(successor); + } + + var dominators = Dominators.Compute(function); + + foreach (var block in function.Blocks) + { + foreach (var phi in block.Phis) + { + if (phi.ExtraArgs == null || phi.ExtraArgs.Count != block.Predecessors.Count) + throw new InvalidOperationException( + $"SSA validation failed {stage}: {function.Symbol.Name} b{block.Id} " + + $"v{phi.Id}:Phi has {phi.ExtraArgs?.Count ?? 0} inputs for {block.Predecessors.Count} predecessors"); + + for (int i = 0; i < phi.ExtraArgs.Count; i++) + CheckUse(phi, phi.ExtraArgs[i], block.Predecessors[i], null, "phi input"); + } + + for (int i = 0; i < block.Instructions.Count; i++) + { + var instruction = block.Instructions[i]; + if (instruction.Op == SsaOp.Phi) + throw new InvalidOperationException( + $"SSA validation failed {stage}: {function.Symbol.Name} b{block.Id} " + + $"contains v{instruction.Id}:Phi in Instructions"); + + CheckUse(instruction, instruction.Arg0, block, i, "Arg0"); + CheckUse(instruction, instruction.Arg1, block, i, "Arg1"); + if (instruction.ExtraArgs != null) + foreach (var value in instruction.ExtraArgs) + CheckUse(instruction, value, block, i, "ExtraArgs"); + } + + // A branch condition is consumed after the block's instructions have run. + CheckUse(null, block.BranchCondition, block, null, "branch condition"); + } + + void AddDefinition(SsaValue value, (SsaBlock Block, int Index, bool IsPhi) definition) + { + if (!definitions.TryAdd(value, definition)) + throw new InvalidOperationException( + $"SSA validation failed {stage}: {function.Symbol.Name} defines v{value.Id}:{value.Op} more than once"); + } + + void CheckUse( + SsaValue? consumer, + SsaValue? value, + SsaBlock useBlock, + int? useIndex, + string operand) + { + if (value == null || value.IsConstant) + return; + + string consumerLabel = consumer == null ? "terminator" : $"v{consumer.Id}:{consumer.Op}"; + if (!definitions.TryGetValue(value, out var definition)) + throw new InvalidOperationException( + $"SSA validation failed {stage}: {function.Symbol.Name} b{useBlock.Id} " + + $"{consumerLabel} {operand} references missing v{value.Id}:{value.Op}"); + + if (definition.Block == useBlock) + { + if (useIndex.HasValue && !definition.IsPhi && definition.Index >= useIndex.Value) + throw new InvalidOperationException( + $"SSA validation failed {stage}: {function.Symbol.Name} b{useBlock.Id} " + + $"{consumerLabel} {operand} uses v{value.Id}:{value.Op} before its definition"); + return; + } + + if (reachable.Contains(useBlock) && !dominators.Dominates(definition.Block, useBlock)) + throw new InvalidOperationException( + $"SSA validation failed {stage}: {function.Symbol.Name} b{useBlock.Id} " + + $"{consumerLabel} {operand} uses v{value.Id}:{value.Op} from non-dominating b{definition.Block.Id}"); + } + } +} \ No newline at end of file diff --git a/src/EasyCon.Script/Ssa/SsaValue.cs b/src/EasyCon.Script/Ssa/SsaValue.cs index 75539e08..3a0849d2 100644 --- a/src/EasyCon.Script/Ssa/SsaValue.cs +++ b/src/EasyCon.Script/Ssa/SsaValue.cs @@ -60,9 +60,8 @@ public SsaValue(int id, SsaOp op, ScriptType type) or SsaOp.StickAction or SsaOp.StickPress or SsaOp.Wait or SsaOp.StoreLocal or SsaOp.StoreGlobal or SsaOp.StoreField or SsaOp.StoreIndex or SsaOp.StoreFieldIndex or SsaOp.Return - or SsaOp.ImageLabel or SsaOp.Capture or SsaOp.Ocr or SsaOp.Roi - or SsaOp.Rand - or SsaOp.OcrInit; + or SsaOp.ImageLabel + or SsaOp.Rand; public override string ToString() => $"v{Id}:{Op}({Type})"; } diff --git a/src/EasyCon.Script/StdLib.cs b/src/EasyCon.Script/StdLib.cs index 0644c6ee..140f272d 100644 --- a/src/EasyCon.Script/StdLib.cs +++ b/src/EasyCon.Script/StdLib.cs @@ -18,15 +18,49 @@ FUNC PRINT($output: STRING) FWRITE $STDOUT, $output ENDFUNC - # 返回脚本运行时常时间戳,单位毫秒 - FUNC time(): INT - RETURN __TIME__ - ENDFUNC + # 返回脚本运行时长时间戳,单位毫秒(运行时值 __TIME__ 编译为 TIME syscall; + # 脚本面仅此一个入口——直接写 __TIME__ 亦等价) FUNC TIME(): INT RETURN __TIME__ ENDFUNC - # __FILE__指向当前执行脚本所在目录,不含脚本文件名 + # ---- 实用工具(std 定 API/语义,热路径打洞加速)---- + + # JSON 路径查询(.a.b[0]);查询语法演化经本层 + FUNC JQ($json: STRING, $query: STRING): STRING + RETURN __JQ__($json, $query) + ENDFUNC + + # 字节→文本解码(UTF-8/UTF-16 多字节语义由宿主保证——std 无 CHR 原语不可自实现)。 + # 仅接受 byte 数组(byte[] 的脚本侧来源待后续宿主原语提供)。 + FUNC ENCODE($array: BYTE[]): STRING + RETURN __ENCODE__($array, "utf8") + ENDFUNC + FUNC ENCODE($array: BYTE[], $type: STRING): STRING + RETURN __ENCODE__($array, $type) + ENDFUNC + + # ---- 文件便捷件(std 组合低级句柄原语;IFileSystem 契约保证无效句柄安全: + # FOPEN 失败 -1、FREAD 失败空串、FWRITE 失败 -1、FCLOSE 无效句柄 no-op,无需脚本守卫)---- + + FUNC READFILE($path: STRING): STRING + $h = FOPEN($path, "r") + $s = FREAD($h, 0) + FCLOSE $h + RETURN $s + ENDFUNC + + FUNC WRITEFILE($path: STRING, $data: STRING) + $h = FOPEN($path, "w") + FWRITE $h, $data + FCLOSE $h + ENDFUNC + + FUNC APPENDFILE($path: STRING, $data: STRING) + $h = FOPEN($path, "a") + FWRITE $h, $data + FCLOSE $h + ENDFUNC """; public const string VisionSource = @@ -45,23 +79,244 @@ RETURN OCR($x, $y, $w, $h, "chi_sim") FUNC OCR($x: INT, $y: INT, $w: INT, $h: INT, $lang: STRING): STRING OCR_INIT $lang - RETURN __OCR__($x, $y, $w, $h, $lang) + RETURN __TESSERACT__($x, $y, $w, $h, $lang) ENDFUNC FUNC ROI($img: STRING, $x: INT, $y: INT, $w: INT, $h: INT): STRING RETURN __ROI__($img, $x, $y, $w, $h) ENDFUNC + # ---- PP-OCR 适配(纯标准库实现,宿主无特例)---- + # 依据 PP-OCRv5 / PP-OCRv6 官方 ONNX 导出实测同参:det = (px/255 - 0.25) / 0.5;rec = px/255 - 0.25。 + FUNC PPOCR_NORM($tensor: PTR, $kind: STRING): PTR + IF $kind == "det" + RETURN NET_SCALE($tensor, 1.00 / 255 / 0.50, 0 - 0.50) + ENDIF + RETURN NET_SCALE($tensor, 1.00 / 255, 0 - 0.25) + ENDFUNC + + # base64 图像 → PP-OCR 归一化张量(RGB [1,3,H,W]) + FUNC PPOCR_TENSOR($img: STRING, $w: INT, $h: INT): PTR + RETURN PPOCR_NORM(__NET_IMAGE__($img, $w, $h, "rgb"), "rec") + ENDFUNC + + FUNC PPOCR_TENSOR($img: STRING, $w: INT, $h: INT, $kind: STRING): PTR + RETURN PPOCR_NORM(__NET_IMAGE__($img, $w, $h, "rgb"), $kind) + ENDFUNC + + # ---- NET 句柄族(通用 ONNX;大数组驻留宿主,脚本只见句柄)---- + # mode: "gray"([1,1,H,W])/ "rgb"([1,3,H,W],CHW);像素 0-255 原始值, + # 归一化经 NET_SCALE 自行组合。 + FUNC NET_IMAGE($img: STRING, $w: INT, $h: INT): PTR + RETURN __NET_IMAGE__($img, $w, $h, "gray") + ENDFUNC + + FUNC NET_IMAGE($img: STRING, $w: INT, $h: INT, $mode: STRING): PTR + RETURN __NET_IMAGE__($img, $w, $h, $mode) + ENDFUNC + + FUNC NET_IMAGE($x: INT, $y: INT, $w: INT, $h: INT, $rw: INT, $rh: INT): PTR + RETURN __NET_IMAGE__(__CAPTURE__($x, $y, $w, $h), $rw, $rh, "gray") + ENDFUNC + + FUNC NET_IMAGE($x: INT, $y: INT, $w: INT, $h: INT, $rw: INT, $rh: INT, $mode: STRING): PTR + RETURN __NET_IMAGE__(__CAPTURE__($x, $y, $w, $h), $rw, $rh, $mode) + ENDFUNC + + # 本地图像文件 → 张量(与 NET_IMAGE 同管线同 mode 词表;'c' 后缀 = cover + 居中裁剪) + FUNC NET_IMAGE_FILE($path: STRING, $w: INT, $h: INT): PTR + RETURN __NET_IMAGE_FILE__($path, $w, $h, "gray") + ENDFUNC + + FUNC NET_IMAGE_FILE($path: STRING, $w: INT, $h: INT, $mode: STRING): PTR + RETURN __NET_IMAGE_FILE__($path, $w, $h, $mode) + ENDFUNC + FUNC OCR_INIT($lang: STRING): BOOL - RETURN __OCR_INIT__($lang, __APP__ + "/Tessdata", "DEFAULT", "SINGLE_LINE") + RETURN __TESSERACT_INIT__($lang, __APP__ + "/Tessdata", "DEFAULT", "SINGLE_LINE") + ENDFUNC + + # ---- PP-OCR rec 行识别配方(ppocr 不走 OCR 面,经 NET 族 + 本配方;宿主打洞 CTC 折叠)---- + # rec 输入尺寸 = 行高 x 等比宽度(8 对齐)。行高是模型代际常数,由调用方按所用 + # 资产显式传入,std 不钉常数:PP-OCRv6 资产 48(输入固定 3x48xW;onnxruntime 对 + # H!=48 报 INVALID_ARGUMENT,OpenCV DNN 不校验形状会静默产出劣化结果), + # PP-OCRv5 旧资产按其导出配置(32)。 + FUNC PP_REC_WIDTH($w: INT, $h: INT, $recH: INT): INT + RETURN ($w * $recH / $h + 7) / 8 * 8 + ENDFUNC + + # CTC 贪心折叠最近一次 NET_RUNH 输出 [1,T,C]:连续同类别去重、blank 帧重置、 + # charset 窗口 [base, base+len) 外类别跳过。打洞实现;替代解码策略可在脚本侧 + # 经 NET_ROWS + NET_ARGMAX 逐帧遍历自行实现。 + FUNC CTC_FOLD($blank: INT, $charset: STRING, $base: INT): STRING + RETURN __CTC_FOLD__($blank, $charset, $base) ENDFUNC - #FUNC OCR_INIT($lang: STRING, $dataPath: STRING): BOOL - # RETURN __OCR_INIT__($lang, $dataPath, "DEFAULT", "SINGLE_LINE") - #ENDFUNC + # rec 推理通用步骤:采集 ROI → 按调用方行高换算输入宽度(PP_REC_WIDTH)→ 归一化 + # (PPOCR_NORM)→ 推理。$recH = 模型代际行高(调用方显式给)。输出驻留宿主线程槽, + # 由任意解码原语消费(CTC_FOLD / NET_ARGMAX+NET_LABEL / NET_OUT 自实现策略)。 + # 返回输出元素个数(0 = 失败)。 + FUNC PP_OCR_INFER($net: PTR, $x: INT, $y: INT, $w: INT, $h: INT, $recH: INT): INT + $img = NET_IMAGE($x, $y, $w, $h, PP_REC_WIDTH($w, $h, $recH), $recH, "rgb") + $t = PPOCR_NORM($img, "rec") + $n = NET_RUNH($net, $t) + NET_FREE $t + NET_FREE $img + RETURN $n + ENDFUNC - #FUNC OCR_INIT($lang: STRING, $dataPath: STRING, $engineMode: STRING, $psmode: STRING): BOOL - # RETURN __OCR_INIT__($lang, $dataPath, $engineMode, $psmode) - #ENDFUNC + # ---- 共享采集形态:同帧多区域只采集一次。$frame 为整帧 base64(如 FRAME() 全屏), + # 区域坐标相对该帧,逐区域 ROI 裁剪在宿主内完成(省 N-1 次采集 + base64 编解码)。 + # 命名与采集形态同名重载(第二参 STRING = 帧),约定同 NET_IMAGE 的 base64 形态。 + FUNC PP_OCR_INFER($net: PTR, $frame: STRING, $x: INT, $y: INT, $w: INT, $h: INT, $recH: INT): INT + $roi = ROI($frame, $x, $y, $w, $h) + $img = NET_IMAGE($roi, PP_REC_WIDTH($w, $h, $recH), $recH, "rgb") + $t = PPOCR_NORM($img, "rec") + $n = NET_RUNH($net, $t) + NET_FREE $t + NET_FREE $img + RETURN $n + ENDFUNC + + # PP-OCR rec:固定 ROI → 数字串。$base / $recH 由脚本按所用模型代际指定('0' 的 class + # 下标:v5 字典 16163 / v6 字典 33;行高 v5 旧资产 32 / v6 资产 48——std 不硬编码任何 + # 代际常数;blank=0,'0'-'9' 需在字典中连续)。 + # 忠实输出折叠结果(含模型伪影的前导零);数值化/去前导零属游戏策略,调用方 INT() 自理。 + FUNC PP_OCR_DIGITS($net: PTR, $base: INT, $x: INT, $y: INT, $w: INT, $h: INT, $recH: INT): STRING + $n = PP_OCR_INFER($net, $x, $y, $w, $h, $recH) + RETURN CTC_FOLD(0, "0123456789", $base) + ENDFUNC + + FUNC PP_OCR_DIGITS($net: PTR, $frame: STRING, $base: INT, $x: INT, $y: INT, $w: INT, $h: INT, $recH: INT): STRING + $n = PP_OCR_INFER($net, $frame, $x, $y, $w, $h, $recH) + RETURN CTC_FOLD(0, "0123456789", $base) + ENDFUNC + + # PP-OCR rec 通用文本:PP_OCR_INFER($recH = 模型代际行高,调用方显式给)+ 标签表层 + # 整字典折叠(NET_LABELS 行号即索引,class c → NET_LABEL(c-1))。去重/blank 重置与 + # CTC_FOLD 洞同语义,无窗口过滤 + # (数字子集场景用 PP_OCR_DIGITS)。加载字典会整体替换 NET_LABELS 全局表; + # 解码策略需定制时可在脚本侧经 NET_ROWS + NET_ARGMAX 自实现。 + FUNC PP_OCR_TEXT($net: PTR, $x: INT, $y: INT, $w: INT, $h: INT, $dictPath: STRING, $recH: INT): STRING + $n = PP_OCR_INFER($net, $x, $y, $w, $h, $recH) + NET_LABELS $dictPath + $text = "" + $prev = -1 + $i = 0 + WHILE $i < NET_ROWS() + $cls = NET_ARGMAX($i) + IF $cls == 0 + $prev = -1 + ELSE + IF $cls != $prev + $text = $text + NET_LABEL($cls - 1) + ENDIF + $prev = $cls + ENDIF + $i = $i + 1 + END + RETURN $text + ENDFUNC + + FUNC PP_OCR_TEXT($net: PTR, $frame: STRING, $x: INT, $y: INT, $w: INT, $h: INT, $dictPath: STRING, $recH: INT): STRING + $n = PP_OCR_INFER($net, $frame, $x, $y, $w, $h, $recH) + NET_LABELS $dictPath + $text = "" + $prev = -1 + $i = 0 + WHILE $i < NET_ROWS() + $cls = NET_ARGMAX($i) + IF $cls == 0 + $prev = -1 + ELSE + IF $cls != $prev + $text = $text + NET_LABEL($cls - 1) + ENDIF + $prev = $cls + ENDIF + $i = $i + 1 + END + RETURN $text + ENDFUNC + + # ---- PP-OCR det 文本找框(det 全图一次 → 框 → rec 逐框;det 模型经 NET_LOAD 由脚本显式加载)---- + # 限边缩放:scale = 960/max(w,h),小图不放大(钳 1);rw/rh 32 对齐上取整(pad 在右/下, + # 不影响逆映射)。__DET_BOXES__ 阈值 0.30(PaddleOCR det_db_thresh 惯例),逆映射 + # screen = det/scale + roi,±2px unclip,阅读序缓存。返回框数(-1 = 输出缺失/形状错)。 + FUNC PP_DET_SCALE($w: INT, $h: INT): DOUBLE + $s = 960.00 / $w + IF $h > $w + $s = 960.00 / $h + ENDIF + IF $s > 1.00 + $s = 1.00 + ENDIF + RETURN $s + ENDFUNC + + FUNC PP_DET_W($w: INT, $h: INT): INT + RETURN (INT($w * PP_DET_SCALE($w, $h) + 0.5) + 31) / 32 * 32 + ENDFUNC + + FUNC PP_DET_H($w: INT, $h: INT): INT + RETURN (INT($h * PP_DET_SCALE($w, $h) + 0.5) + 31) / 32 * 32 + ENDFUNC + + FUNC PP_DET_FIND($net: PTR, $x: INT, $y: INT, $w: INT, $h: INT): INT + $s = PP_DET_SCALE($w, $h) + $img = NET_IMAGE($x, $y, $w, $h, PP_DET_W($w, $h), PP_DET_H($w, $h), "rgb") + $t = PPOCR_NORM($img, "det") + $n = NET_RUNH($net, $t) + $count = __DET_BOXES__(0.30, $s, $x, $y, PP_DET_W($w, $h), PP_DET_H($w, $h)) + NET_FREE $t + NET_FREE $img + RETURN $count + ENDFUNC + + FUNC PP_DET_FIND($net: PTR, $frame: STRING, $x: INT, $y: INT, $w: INT, $h: INT): INT + $s = PP_DET_SCALE($w, $h) + $roi = ROI($frame, $x, $y, $w, $h) + $img = NET_IMAGE($roi, PP_DET_W($w, $h), PP_DET_H($w, $h), "rgb") + $t = PPOCR_NORM($img, "det") + $n = NET_RUNH($net, $t) + $count = __DET_BOXES__(0.30, $s, $x, $y, PP_DET_W($w, $h), PP_DET_H($w, $h)) + NET_FREE $t + NET_FREE $img + RETURN $count + ENDFUNC + + # ---- YOLO26 适配(端到端免 NMS 导出;输出 [1,300,6] = x1,y1,x2,y2,conf,cls)---- + # letterbox 几何在此计算(配方持语义),与宿主 __NET_IMAGE__ "letter" 模式同式: + # scale = 640/max(w,h),newW/H = INT(w*scale + 0.5),pad = (640-new)/2(整除地板)。 + # 端到端 stride 校验(末维 = 6,防 end2end=False 导出 [1,8400,·] 错位解码)在此配方层: + # stride = 输出元素数 / NET_ROWS()。__YOLO_DECODE__ 只做过滤 + 逆映射 (v - pad)/scale + # + roi + 内容区裁剪(Ultralytics predict 同约定),命中缓存经 YOLO_COUNT / YOLO_CLS / + # YOLO_SCORE / YOLO_X1..Y2 读取(结果按分数降序)。返回命中数(-1 = stride 不符/输出缺失)。 + FUNC YOLO_DETECT($net: PTR, $x: INT, $y: INT, $w: INT, $h: INT, $thresh: DOUBLE): INT + $scale = 640.00 / $w + IF $h > $w + $scale = 640.00 / $h + ENDIF + $padX = (640 - INT($w * $scale + 0.5)) / 2 + $padY = (640 - INT($h * $scale + 0.5)) / 2 + $img = NET_IMAGE($x, $y, $w, $h, 640, 640, "letter") + $norm = NET_SCALE($img, 1.00 / 255, 0) + $n = NET_RUNH($net, $norm) + IF $n <= 0 + NET_FREE $norm + NET_FREE $img + RETURN 0 + ENDIF + $rows = NET_ROWS() + IF $n / $rows != 6 + NET_FREE $norm + NET_FREE $img + RETURN -1 + ENDIF + $count = __YOLO_DECODE__($thresh, $scale, $padX, $padY, $x, $y) + NET_FREE $norm + NET_FREE $img + RETURN $count + ENDFUNC """; } \ No newline at end of file diff --git a/src/EasyCon.Script/Symbols/Symbol.cs b/src/EasyCon.Script/Symbols/Symbol.cs index 09f57884..3ea5f836 100644 --- a/src/EasyCon.Script/Symbols/Symbol.cs +++ b/src/EasyCon.Script/Symbols/Symbol.cs @@ -62,13 +62,17 @@ public sealed class FunctionSymbol( IEnumerable parameters, ScriptType returnType, string libraryName = "internal", - string? externalName = null) : Symbol(name), IEquatable + string? externalName = null, + string? originModule = null) : Symbol(name), IEquatable { public ImmutableArray Parameters { get; } = [.. parameters]; internal FuncDeclBlock? Declaration { get; init; } public ScriptType ReturnType { get; } = returnType; public readonly string LibraryName = libraryName; public string ExternalName => externalName ?? Name; + /// 导入符号的来源模块名(独立编译注入;null = 本模块/内建。 + /// 供编码器区分跨模块同名导入(N2 层级命名),不参与 FFI 名编码。 + public string? OriginModule { get; init; } = originModule; /// 函数局部变量帧大小(含参数),由 Binder 在绑定完成后设置 public int LocalSlotCount { get; set; } diff --git a/src/EasyCon.Script/Symbols/TaggedValue.cs b/src/EasyCon.Script/Symbols/TaggedValue.cs index 5506e497..24023f33 100644 --- a/src/EasyCon.Script/Symbols/TaggedValue.cs +++ b/src/EasyCon.Script/Symbols/TaggedValue.cs @@ -54,6 +54,17 @@ public static TaggedValue FromHandle(byte tag, int handle) public static TaggedValue FromArrayHandle(int handle) => new() { Tag = ARRAY, I64 = handle }; public static TaggedValue FromStructHandle(int handle) => new() { Tag = STRUCT, I64 = handle }; + /// + /// 静态(pinned)常量串(契约 S-20 / docs/ZeroAllocVm.md §2):句柄 bit31 置位,低 31 位=常量池索引。 + /// 字符数据直接取自 EcxImage.Consts[kx].Str(是唯一实例、内容不可变),**不参与引用计数**。 + /// 与 C 侧 ECS_HANDLE_STATIC/make_static_str 同约定;静态句柄为负值, + /// 因此一切 h > 0 守卫天然拒绝它——漏改点会响亮失败而非读错堆。 + /// + public static TaggedValue FromStaticString(int kx) => new() { Tag = STRING, I64 = StaticFlag | (uint)kx }; + + /// 静态句柄标志位(句柄 bit31)。 + public const long StaticFlag = 1L << 31; + // ============ 快速提取 ============ public int AsInt() => I32; @@ -64,9 +75,15 @@ public static TaggedValue FromHandle(byte tag, int handle) public ulong AsUInt64() => unchecked((ulong)I64); public long AsPtr() => I64; - /// 引用类型的 heap handle(低 32 位)。 + /// 引用类型的 heap handle(低 32 位)。静态串返回负值(bit31 置位)。 public int Handle => (int)I64; + /// 是否静态(pinned)常量串(S-20);静态值不参与 RC,也不占对象表。 + public bool IsStatic => Tag == STRING && (I64 & StaticFlag) != 0; + + /// 静态串的常量池索引(仅 IsStatic 时有效)。 + public int StaticIndex => (int)(I64 & ~StaticFlag); + public bool IsVoid => Tag == VOID; public bool IsHandle => Tag is STRING or ARRAY or STRUCT; public bool IsString => Tag == STRING; diff --git a/src/EasyCon.Script/Syntax/Lexer.cs b/src/EasyCon.Script/Syntax/Lexer.cs index 195cffed..d39b3c8f 100644 --- a/src/EasyCon.Script/Syntax/Lexer.cs +++ b/src/EasyCon.Script/Syntax/Lexer.cs @@ -72,6 +72,14 @@ private void cleanFlags() private static readonly List direcKeywords = ["UP", "DOWN", "LEFT", "RIGHT", "DOWNLEFT", "DOWNRIGHT", "UPLEFT", "UPRIGHT"]; + // ---- 词表导出(LSP 契约消费):单一事实源,EasyCon.Lsp 的 Constants 从这里派生, + // 禁止在 LSP 侧手抄关键字/按键词表(历史上已发生 CALL/UNTIL/STRUCT 双向漂移) ---- + internal static IEnumerable KeywordNames => keywords.Keys; + internal static IEnumerable LogicWordNames => logicwords.Keys; + internal static IReadOnlyList GamepadKeywordNames => gamepadKeywords; + internal static IReadOnlyList StickKeywordNames => stickKeywords; + internal static IReadOnlyList DirectionKeywordNames => direcKeywords; + public ImmutableArray Tokenize() { _tokens.Clear(); @@ -378,15 +386,29 @@ private void ReadIdentifier() bool isAllUpper = word.All(char.IsUpper); bool isAllLower = word.All(char.IsLower); - if ((isAllUpper || isAllLower) && keywords.ContainsKey(word.ToLower())) + // true/false 字面量:任何大小写形态都按布尔字面量出 token(便于语法恢复), + // 但仅全小写合法——TRUE/True 等报 ECX0012。不与通用关键字的全大写兼容规则混用。 + if (word.Equals("true", StringComparison.OrdinalIgnoreCase) || + word.Equals("false", StringComparison.OrdinalIgnoreCase)) { - var tokenType = keywords[word.ToLower()]; - AddToken(tokenType, word.ToUpper(), start); - if (syntaxTree.LegacySyntax) + var boolType = word.Equals("true", StringComparison.OrdinalIgnoreCase) + ? TokenType.TRUE + : TokenType.FALSE; + if (!isAllLower) { - if (tokenType == TokenType.IF || tokenType == TokenType.ELIF) - _expectEqualAfterIf = true; + var kwSpan = new SourceSpan(start, word.Length); + var kwLocation = new TextLocation(_text, kwSpan); + _diagnostics.ReportKeywordMustBeLowercase(kwLocation, word); } + AddToken(boolType, word.ToUpper(), start); + } + else if ((isAllUpper || isAllLower) && keywords.ContainsKey(word.ToLower())) + { + var tokenType = keywords[word.ToLower()]; + AddToken(tokenType, word.ToUpper(), start); + // IF/ELIF 条件中的「=」按比较运算处理(语言标准行为:IF a = b ≡ IF a == b) + if (tokenType == TokenType.IF || tokenType == TokenType.ELIF) + _expectEqualAfterIf = true; } else if ((isAllUpper || isAllLower) && logicwords.ContainsKey(word.ToLower())) { @@ -433,7 +455,7 @@ private void ReadOperatorOrSymbol() Advance(); AddToken(TokenType.EQL, "==", start); } - else if (_expectEqualAfterIf && syntaxTree.LegacySyntax) + else if (_expectEqualAfterIf) { AddToken(TokenType.EQL, "=", start); _expectEqualAfterIf = false; diff --git a/src/EasyCon.Script/Syntax/Parser.Fn.cs b/src/EasyCon.Script/Syntax/Parser.Fn.cs index 36c25056..6ebd9c3c 100644 --- a/src/EasyCon.Script/Syntax/Parser.Fn.cs +++ b/src/EasyCon.Script/Syntax/Parser.Fn.cs @@ -96,12 +96,12 @@ private ImportStmt ParseImport() } MatchEOF(); - var libSrc = Path.GetFullPath(Path.Combine(_filePath, LibPath, mod.STRTrimQ())); - if (!libSrc.StartsWith(_filePath, StringComparison.OrdinalIgnoreCase) || !File.Exists(libSrc)) + var libSrc = Path.GetFullPath(Path.Combine(_importBase, mod.STRTrimQ())); + if (!File.Exists(libSrc)) _diagnostics.ReportInvalidImport(mod.Location, mod); // 使用对象初始化器设置Alias属性 - return new ImportStmt(keyword, mod, Path.Combine(_filePath, LibPath)) + return new ImportStmt(keyword, mod, _importBase) { Alias = alias }; @@ -392,10 +392,8 @@ private Statement ParseNamedExpression() _diagnostics.ReportInvalidExpressionStatement(first.Location); } var args = ParseArguments(); - // v1 特例(行为偏差注记,LegacySyntax 语义单点):PRINT 后接「&」时旧解析器 - // 把 & 当分隔符吞掉;PRINT 后接调用/成员链时走下方 CallStmtWithArgs 字面量回退 - // (不求值实参)。LegacySyntax=false(新语法模式)不执行该吞并。 - if (_syntaxTree.LegacySyntax && first.Value.Equals("print", StringComparison.CurrentCultureIgnoreCase)) + // PRINT 的「&」是输出分隔符(PRINT 语法的一部分),语句装配时吞掉 + if (first.Value.Equals("print", StringComparison.CurrentCultureIgnoreCase)) { if (Check(TokenType.BitAnd)) Advance(); } @@ -491,6 +489,10 @@ private BaseExpr ParsePrimary() var okd = double.TryParse(tokdbl.Value, out var dblval); if (!okd) _diagnostics.ReportInvalidNumber(tokdbl.Location, tokdbl.Value); return new LiteralExpr(tokdbl, dblval); + case TokenType.TRUE: + case TokenType.FALSE: + var tokbool = Advance(); + return new LiteralExpr(tokbool, tokbool.Type == TokenType.TRUE); default: var tokdef = Advance(); _diagnostics.ReportUnexpectedToken(tokdef.Location, tokdef, TokenType.INT); @@ -559,7 +561,7 @@ private BaseExpr ParseIndexDefExpression() var parseNext = true; while (parseNext && !Check(TokenType.RightBracket) && !CursorEOF) { - items.Add(ParsePrimary()); + items.Add(ParseArrayConstElement()); if (Check(TokenType.COMMA)) Advance(); else @@ -577,6 +579,63 @@ private BaseExpr ParseIndexDefExpression() return new IndexDefExpression(lb, [.. items], rb, elementTypeToken); } + /// + /// 数组初始化元素:仅允许编译期常量(数字/字符串/true/false/_常量/一元负号数值)。 + /// 语法阶段即拒绝变量、识图标签与调用,保证数组字面量可在编译期完整求值 + /// (常量模板/常量池物化的前提)。 + /// + private BaseExpr ParseArrayConstElement() + { + // 一元负号仅作用于数值字面量(-1 / -1.5) + if (Check(TokenType.SUB) && Peek(1).Type is TokenType.INT or TokenType.Number) + { + var opToken = Advance(); + return new UnaryExpression(opToken, ParseNumericLiteral()); + } + + switch (Current.Type) + { + case TokenType.INT: + case TokenType.Number: + return ParseNumericLiteral(); + case TokenType.STRING: + var strToken = Advance(); + return Formatter.GetValueEx(strToken); + case TokenType.TRUE: + case TokenType.FALSE: + var boolToken = Advance(); + return new LiteralExpr(boolToken, boolToken.Type == TokenType.TRUE); + case TokenType.CONST: + var constToken = Advance(); + // __TIME__/__APP__ 是运行时取值,不属于编译期常量 + if (Formatter.IsSpecialConst(constToken.Value)) + { + _diagnostics.ReportArrayConstantRequired(constToken.Location, constToken); + return new LiteralExpr(constToken, ""); + } + return Formatter.GetValueEx(constToken); + default: + var bad = Current; + _diagnostics.ReportArrayConstantRequired(bad.Location, bad); + Advance(); + return new LiteralExpr(bad, 0); + } + } + + private BaseExpr ParseNumericLiteral() + { + var tok = Advance(); + if (tok.Type == TokenType.INT) + { + var ok = int.TryParse(tok.Value, out var intval); + if (!ok) _diagnostics.ReportInvalidNumber(tok.Location, tok.Value); + return new LiteralExpr(tok, intval); + } + var okd = double.TryParse(tok.Value, out var dblval); + if (!okd) _diagnostics.ReportInvalidNumber(tok.Location, tok.Value); + return new LiteralExpr(tok, dblval); + } + // baseExpr[expr] or baseExpr[start:end] private BaseExpr ParseSliceExpression(BaseExpr baseExpr) { diff --git a/src/EasyCon.Script/Syntax/Parser.cs b/src/EasyCon.Script/Syntax/Parser.cs index 4b012e88..fcfb7b7b 100644 --- a/src/EasyCon.Script/Syntax/Parser.cs +++ b/src/EasyCon.Script/Syntax/Parser.cs @@ -19,11 +19,14 @@ public Parser(SyntaxTree syntaxTree) _diagnostics.AddRange(lexer.Diagnostics); } + /// IMPORT 解析基准:语法树携带的目录(R2 = 导入文件所在目录, + /// docs/ModuleImportRules.md);缺省 = 本文件同目录(单文件场景)。 + string _importBase => _syntaxTree.ImportBase ?? _filePath; + public DiagnosticBag Diagnostics => _diagnostics; string _fullPath => _text.FileName != "" ? Path.GetFullPath(_text.FileName) : AppDomain.CurrentDomain.BaseDirectory; string _filePath => Path.GetDirectoryName(_fullPath) ?? ""; - const string LibPath = "lib/"; #region Token Cursor @@ -96,7 +99,7 @@ private void SetRange(int start, int end) #endregion - public CompicationUnit ParseProgram() + public CompilationUnit ParseProgram() { var unit = new Stack>(); unit.Push([]); @@ -288,7 +291,7 @@ void startblock() _diagnostics.ReportBadStruct(first.Syntax.Location, "语句块没有正确结束"); } - return new CompicationUnit([.. result]); + return new CompilationUnit([.. result]); } /// diff --git a/src/EasyCon.Script/Syntax/Statement.cs b/src/EasyCon.Script/Syntax/Statement.cs index f4621026..a238e599 100644 --- a/src/EasyCon.Script/Syntax/Statement.cs +++ b/src/EasyCon.Script/Syntax/Statement.cs @@ -58,7 +58,7 @@ protected override string _GetString() => Alias != null : $"IMPORT \"{Lib}\""; } -sealed class CompicationUnit(ImmutableArray members) +sealed class CompilationUnit(ImmutableArray members) { public readonly ImmutableArray Members = members; } \ No newline at end of file diff --git a/src/EasyCon.Script/Syntax/SyntaxTree.cs b/src/EasyCon.Script/Syntax/SyntaxTree.cs index 17195839..0a9501b0 100644 --- a/src/EasyCon.Script/Syntax/SyntaxTree.cs +++ b/src/EasyCon.Script/Syntax/SyntaxTree.cs @@ -6,20 +6,19 @@ namespace EasyCon.Script.Syntax; public sealed class SyntaxTree { private delegate void ParseHandler(SyntaxTree syntaxTree, - out CompicationUnit root, - out ImmutableArray diagnostics); + out CompilationUnit root, + out ImmutableArray diagnostics); public SourceText Text { get; init; } - /// 旧版语法兼容(v1 PRINT/IF= 语义;默认 true 保持行为)。 - /// 经 CompileOptions.LegacySyntax 逐层下传(Parse/ParseTokens/BindProgram),无静态全局。 - public bool LegacySyntax { get; private set; } = true; public ImmutableArray Diagnostics { get; init; } - internal CompicationUnit Root { get; init; } + internal CompilationUnit Root { get; init; } - private SyntaxTree(SourceText text, ParseHandler handler, bool legacySyntax) + private SyntaxTree(SourceText text, ParseHandler handler, string? importBase) { Text = text; - LegacySyntax = legacySyntax; + ImportBase = importBase ?? (Text.FileName != "" + ? Path.GetDirectoryName(Path.GetFullPath(Text.FileName))! + : null); handler(this, out var root, out var diagnostics); @@ -27,6 +26,10 @@ private SyntaxTree(SourceText text, ParseHandler handler, bool legacySyntax) Root = root; } + /// IMPORT 解析基准(导入基准 = 导入文件所在目录(docs/ModuleImportRules.md R2))。 + /// null = 单文件无路径场景(IMPORT 一律报不存在)。 + public string? ImportBase { get; } + public static SyntaxTree Load(string fileName) { var text = File.ReadAllText(fileName); @@ -34,38 +37,38 @@ public static SyntaxTree Load(string fileName) return Parse(sourceText); } - private static void Parse(SyntaxTree syntaxTree, out CompicationUnit root, out ImmutableArray diagnostics) + private static void Parse(SyntaxTree syntaxTree, out CompilationUnit root, out ImmutableArray diagnostics) { var parser = new Parser(syntaxTree); root = parser.ParseProgram(); diagnostics = [.. parser.Diagnostics]; } - public static SyntaxTree Parse(string text, bool legacySyntax = true) + public static SyntaxTree Parse(string text) { - return Parse(SourceText.From(text), legacySyntax); + return Parse(SourceText.From(text)); } - public static SyntaxTree Parse(SourceText text, bool legacySyntax = true) + public static SyntaxTree Parse(SourceText text, string? importBase = null) { - return new SyntaxTree(text, Parse, legacySyntax); + return new SyntaxTree(text, Parse, importBase); } - public static ImmutableArray ParseTokens(string text, bool legacySyntax = true) + public static ImmutableArray ParseTokens(string text, string? importBase = null) { var sourceText = SourceText.From(text); - return ParseTokens(sourceText, out _); + return ParseTokens(sourceText, out _, importBase); } /// 带文件名的词法扫描(轻量导入扫描用;诊断位置归属正确)。 - internal static ImmutableArray ParseTokens(SourceText text, bool legacySyntax = true) - => ParseTokens(text, out _, legacySyntax); + internal static ImmutableArray ParseTokens(SourceText text, string? importBase = null) + => ParseTokens(text, out _, importBase); - private static ImmutableArray ParseTokens(SourceText text, out ImmutableArray diagnostics, bool legacySyntax = true) + private static ImmutableArray ParseTokens(SourceText text, out ImmutableArray diagnostics, string? importBase = null) { var tokens = new ImmutableArray(); - void ParseTokensHandler(SyntaxTree st, out CompicationUnit _, out ImmutableArray d) + void ParseTokensHandler(SyntaxTree st, out CompilationUnit _, out ImmutableArray d) { _ = new([]); d = default; @@ -74,7 +77,7 @@ void ParseTokensHandler(SyntaxTree st, out CompicationUnit _, out ImmutableArray d = [.. l.Diagnostics]; } - var syntaxTree = new SyntaxTree(text, ParseTokensHandler, legacySyntax); + var syntaxTree = new SyntaxTree(text, ParseTokensHandler, importBase); diagnostics = syntaxTree.Diagnostics; return tokens; } diff --git a/src/EasyCon.Script/Text/TextLocation.cs b/src/EasyCon.Script/Text/TextLocation.cs index 03629c2e..edd04420 100644 --- a/src/EasyCon.Script/Text/TextLocation.cs +++ b/src/EasyCon.Script/Text/TextLocation.cs @@ -13,7 +13,25 @@ public TextLocation(SourceText text, SourceSpan span) public string FileName => Text.FileName; public int StartLine => Text.GetLineIndex(Span.Start); - // public int StartCharacter => Span.Start - Text.Lines[StartLine].Start; public int EndLine => Text.GetLineIndex(Span.End); - // public int EndCharacter => Span.End - Text.Lines[EndLine].Start; + + /// 起始列(0 基;LSP/JSON 诊断定位用,M1 补齐)。 + public int StartCharacter + { + get + { + var line = Text.Lines[StartLine]; + return Math.Max(0, Span.Start - line.Start); + } + } + + /// 结束列(0 基,不含)。 + public int EndCharacter + { + get + { + var line = Text.Lines[EndLine]; + return Math.Max(0, Span.End - line.Start); + } + } } \ No newline at end of file diff --git a/src/EasyCon.Vm/native/ecs_main.c b/src/EasyCon.Vm/native/ecs_main.c index b9e07d14..4932a33e 100644 --- a/src/EasyCon.Vm/native/ecs_main.c +++ b/src/EasyCon.Vm/native/ecs_main.c @@ -1,8 +1,10 @@ /* - * ecs_main.c — ecs-vm CLI harness(docs/VmSemanticContract.md §四)。 + * ecs_main.c — ecs-vm CLI 参考宿主。 * 单片机参考宿主:L2 平台 syscall 的语义参考实现 + L1 域操作回调 + L3 无(按需接入)。 * - * ecs-vm run image.ecx [--trace] [--print] [-- arg0 arg1...] + * ecs-vm run image.ecx [--io] [--trace] + * --io:经 ecs_io 读取器加载(模拟 EEPROM/NVS 等位置的字节码来源) + * 缺省 = RAM 缓冲直载(可 XIP);[--print] [--strict-caps] [-- arg0 arg1...] * stdout:脚本 PRINT 输出(UTF-16 → UTF-8;--print 通道开) * stderr:--trace 时逐条域事件 TSV(格式与 C# EcxHost.EnableRecording 一致,供三方对拍) * 退出码:ECS_* 结果码;错误时 stderr 打印 func/pc 现场 @@ -16,6 +18,7 @@ * 特征位:feats = ECS_FEAT_FILE(桩覆盖文件族 syscall 的"静默/默认值"行为)。 */ #include "ecs_vm.h" +#include #include #include @@ -83,7 +86,7 @@ static void h_stick_click(void *ud, uint8_t side, int32_t x, int32_t y, int32_t static void ret_str_ascii(ecs_value *ret, const char *s) { - uint16_t buf[512]; + uint16_t buf[512] = {0}; int32_t n = 0; for (; s && s[n] != 0 && n < 511; n++) buf[n] = (uint16_t)(unsigned char)s[n]; @@ -100,8 +103,9 @@ static int h_syscall(void *ud, int32_t id, ecs_value *args, int32_t nargs, ecs_v { case ECS_SYSCALL_FWRITE: { - /* S-14:句柄 1=stdout 行断协议;0=no-op 返 len;2=print(newline);>2=文件族 → -1 */ - int64_t handle = args[0].i64; + /* S-14:句柄 1=stdout 行断协议;0=no-op 返 len;2=print(newline);>2=文件族 → -1 + *(句柄为 PTR 类型 → 经 arg_wide 解箱;编译器也可能发 INT 句柄,两态同收) */ + int32_t handle = (int32_t)ecs_vm_arg_wide(g_vm, args[0]); const uint16_t *u = NULL; int32_t len = ecs_vm_arg_str(g_vm, args[1], &u); if (handle == 1) @@ -164,9 +168,9 @@ static int h_syscall(void *ud, int32_t id, ecs_value *args, int32_t nargs, ecs_v case ECS_SYSCALL_BEEP: return 0; /* no-op(静默忽略) */ case ECS_SYSCALL_AMIIBO: - if (args[0].i32 <= 9) /* S-13:n>9 静默忽略 */ + if (args[0].i32 >= 0 && args[0].i32 <= 19) /* S-13:槽位 0–19,越界静默 */ trace_event("AMIIBO %d", (int)args[0].i32); - return 0; + return 0; /* 无接收槽:任何宿主都不写寄存器 */ case ECS_SYSCALL_OCR_CONF: case ECS_SYSCALL_FOPEN: case ECS_SYSCALL_FCLOSE: @@ -185,24 +189,80 @@ static int sigint_seen = 0; static int vm_cancel_requested(void) { return sigint_seen; } +/* 错误码符号名(ZeroAllocVm.md §5:池耗尽等须以可归因的符号名呈现) */ +static const char *err_name(int rc) +{ + switch (rc) + { + case ECS_ERR_IMAGE: return "IMAGE"; + case ECS_ERR_OPCODE: return "OPCODE"; + case ECS_ERR_SLOT: return "SLOT"; + case ECS_ERR_TYPE: return "TYPE"; + case ECS_ERR_INDEX: return "INDEX"; + case ECS_ERR_DIVZERO: return "DIVZERO"; + case ECS_ERR_DEPTH: return "DEPTH"; + case ECS_ERR_NOSUCHNATIVE: return "NOSUCHNATIVE"; + case ECS_ERR_HOST: return "HOST"; + case ECS_ERR_OOM: return "OOM"; + case ECS_ERR_IL: return "IL"; + case ECS_ERR_FEAT: return "FEAT"; + case ECS_ERR_SECTION: return "SECTION"; + case ECS_ERR_UNSUPPORTED: return "UNSUPPORTED"; + case ECS_ERR_CRC: return "CRC"; + case ECS_ERR_POOL: return "POOL"; + case ECS_WAITING: return "WAITING"; + default: return "?"; + } +} + +/* ---- 实例容量度量(容量门禁 / docs/VmBudget.md 预算自检;不执行镜像) ---- */ +static int print_meminfo(void) +{ + ecs_vm_meminfo mi; + ecs_vm_query_meminfo(&mi); + printf("instance=%" PRIu64 " host=%" PRIu64 " arena=%" PRIu64 " frame_seg=%" PRIu64 + " obj_seg=%" PRIu64 " obj_block=%u obj_block_count=%u value=%u\n", + (uint64_t)mi.instance_bytes, (uint64_t)mi.host_bytes, (uint64_t)mi.arena_bytes, + (uint64_t)mi.frame_seg_bytes, (uint64_t)mi.obj_seg_bytes, + mi.obj_block_bytes, mi.obj_block_count, mi.value_bytes); + return 0; +} + +/* ecs_io 适配:stdio 随机访问读(seek + read)——EEPROM/flash/NVS 来源按同型适配 */ +static uint32_t file_io_read(void *ud, uint32_t offset, uint8_t *out, uint32_t len) +{ + FILE *f = (FILE *)ud; + if (fseek(f, (long)offset, SEEK_SET) != 0) + return 0; + return (uint32_t)fread(out, 1, (size_t)len, f); +} + int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "--mem") == 0) + return print_meminfo(); if (argc < 3 || strcmp(argv[1], "run") != 0) { - fprintf(stderr, "用法: ecs-vm run image.ecx [--trace] [--print] [-- arg0...]\n"); + fprintf(stderr, "用法: ecs-vm run image.ecx [--io] [--trace] [--print] [--strict-caps] [-- arg0...]\n"); return 2; } const char *imagePath = argv[2]; + int useIo = 0; const char *scriptArgs[64]; int nargs = 0; g_trace = 0; int pcPrint = 0; /* PC 验证模式:启用 PRINT 输出通道(单片机构建缺省关,静默返 len) */ + int strictCaps = 0; /* S-21 strict_caps(缺省 0 = 缺省值降级) */ for (int i = 3; i < argc; i++) { if (strcmp(argv[i], "--trace") == 0) g_trace = 1; else if (strcmp(argv[i], "--print") == 0) pcPrint = 1; + else if (strcmp(argv[i], "--io") == 0) + useIo = 1; + else if (strcmp(argv[i], "--strict-caps") == 0) + strictCaps = 1; /* S-21 严格档:feats 加载期校验 + miss 响亮(迁移保底/诊断) */ else if (strcmp(argv[i], "--") == 0) { for (int j = i + 1; j < argc && nargs < 64; j++) @@ -228,12 +288,12 @@ int main(int argc, char **argv) free(image); return 2; } - fclose(f); /* 参考宿主装配:L1 域操作回调(wait/key/stick + trace)+ L2 syscall 桩 + feats。 */ ecs_host host; memset(&host, 0, sizeof(host)); host.feats = ECS_FEAT_FILE; /* 桩覆盖文件族 syscall 的"静默/默认值"行为 */ + host.strict_caps = strictCaps; /* S-21:能力缺失走缺省值表;--strict-caps 恢复响亮 */ host.args = scriptArgs; host.nargs = nargs; host.wait_ms = h_wait; @@ -250,7 +310,22 @@ int main(int argc, char **argv) ecs_vm *vm = ecs_vm_new(&host); g_vm = vm; - int rc = ecs_vm_load(vm, image, (size_t)len); + int rc; + if (useIo) + { + /* 经 ecs_io 读取器加载(随机访问 read 回调;宿主以 stdio seek 实现—— + * EEPROM/内部 flash/NVS 等来源按同型 read 适配) */ + ecs_io io; + io.ud = f; + io.size = (uint32_t)len; + io.read = file_io_read; + rc = ecs_vm_load_io(vm, &io); + } + else + { + rc = ecs_vm_load(vm, image, (size_t)len); + } + fclose(f); if (rc != ECS_OK) { fprintf(stderr, "镜像校验失败: ECS_ERR=%d\n", rc); @@ -263,12 +338,12 @@ int main(int argc, char **argv) do { rc = ecs_vm_run(vm); - } while (rc == ECS_YIELD && !vm_cancel_requested()); - if (rc != ECS_OK && rc != ECS_YIELD && rc != ECS_CANCELLED) + } while ((rc == ECS_YIELD || rc == ECS_WAITING) && !vm_cancel_requested()); + if (rc != ECS_OK && rc != ECS_YIELD && rc != ECS_CANCELLED && rc != ECS_WAITING) { int32_t ef, pc; ecs_vm_error_location(vm, &ef, &pc); - fprintf(stderr, "执行错误: ECS_ERR=%d func=%d pc=%d\n", rc, ef, pc); + fprintf(stderr, "执行错误: ECS_ERR=%d(%s) func=%d pc=%d\n", rc, err_name(rc), ef, pc); } ecs_vm_free(vm); diff --git a/src/EasyCon.Vm/native/ecs_vm.c b/src/EasyCon.Vm/native/ecs_vm.c index 55cb46f3..f8335282 100644 --- a/src/EasyCon.Vm/native/ecs_vm.c +++ b/src/EasyCon.Vm/native/ecs_vm.c @@ -1,9 +1,15 @@ /* - * ecs_vm.c — VM2 纯 C 精简虚拟机实现—— 语义契约见 docs/VmSemanticContract.md(S-01..S-19)。 + * ecs_vm.c — VM2 纯 C 精简虚拟机实现——双端语义一致性由 corpus/FullChain 对拍测试锁定。 * * 语义基准:EcxInterpreter(C# 模拟解释器,逐条对照;含 .NET 总序比较/饱和转换/无符号回绕)。 - * 内部五区段:镜像解析(§4.1)/ 堆(§4.2)/ 解释器主循环(§4.3)/ 结构体布局(§4.4)/ 核心原生表(§4.5)。 - * C99,仅依赖 libc + libm。 + * 值模型:8 字节标记值(ecs_vm.h)——宽标量 DOUBLE/UINT64/PTR 统一装箱为对象池 8B 标量块 + * (OBJ_KIND_BOX,参与引用计数);数组内宽元素仍 8B 裸存(装箱只存在于值槽); + * 数组宽元素读边界产箱(可 POOL)、写边界解箱(不分配)。 + * 内部五区段:镜像解析(§4.1,ECSC 容器)/ 堆(§4.2,固定对象池)/ 解释器主循环(§4.3, + * v3 定长取指:4B/8B 定宽指令直接从镜像读取(XIP),取指通路局部化)/ + * 结构体布局(§4.4)/ 核心原生表(§4.5)。 + * C99,仅依赖 libc + libm;malloc 族仅用于**加载期**表分配(运行期零分配—— + * docs/ZeroAllocVm.md P2 帧池 + P3 对象池;加载期表入池为 P3c,暂缓)。 */ #include "ecs_vm.h" @@ -11,18 +17,46 @@ #include #include #include +#include #ifndef ECS_MAX_CALL_DEPTH -#define ECS_MAX_CALL_DEPTH 512 +#define ECS_MAX_CALL_DEPTH 64 /* 与 C# EcxInterpreter.MaxCallDepth 对齐(对拍分裂防线) */ +#endif + +/* ---- 零分配固定池(docs/ZeroAllocVm.md §3/§4;固件常量,参考宿主取宽裕值) ---- + * D-C:单一 arena 切两段(帧段 + 对象段),两段独立判定满溢(ECS_ERR_POOL 分别归因)。 + * 参考宿主帧段须容纳 ECS_MAX_CALL_DEPTH 层典型帧(深递归对拍要求 DEPTH 先于 POOL 触发); + * 真实固件按 RAM 调小,深度上界随之由帧段容量自然收窄。 */ +#ifndef ECS_ARENA_BYTES +#define ECS_ARENA_BYTES (256 * 1024) +#endif +#ifndef ECS_FRAME_SEG_BYTES +#define ECS_FRAME_SEG_BYTES (ECS_ARENA_BYTES / 2) +#endif +#define ECS_OBJ_SEG_BYTES (ECS_ARENA_BYTES - ECS_FRAME_SEG_BYTES) +#ifndef ECS_OBJ_BLOCK_BYTES +#define ECS_OBJ_BLOCK_BYTES 128 +#endif +#ifndef ECS_OBJ_BLOCK_COUNT +#define ECS_OBJ_BLOCK_COUNT (ECS_OBJ_SEG_BYTES / ECS_OBJ_BLOCK_BYTES) +#endif +#ifndef ECS_MAX_FRAMES +#define ECS_MAX_FRAMES (ECS_FRAME_SEG_BYTES / 64) +#endif +/* 宿主容量档案(R-3:容量属宿主不属产物;加载期按档案校验每函数槽位) */ +#ifndef ECS_MAX_SLOTS +#define ECS_MAX_SLOTS 255 #endif #define ECS_DEFAULT_BUDGET 1000000 -#define ECS_NO_SLOT (-1) -#define ECS_RECEIVE_NONE 255 +/* 接收槽恒为寄存器号(无哨兵值) */ #define ECS_IMPORT_FLAG 0x80000000u #define FIELD_KIND_FIXED_ARRAY 1 #define FIELD_KIND_NESTED 2 +/* TOSTR 工作缓冲上限(有界拒绝:超大嵌套结构转串 → ECS_ERR_POOL,不做块链) */ +#define ECS_TOSTR_MAX 512 + /* Conv 种类(与 C# EcsConvKind 数值一致) */ enum { CV_INT_TO_DOUBLE = 0, CV_DOUBLE_TO_INT, CV_INT_TO_UINT, CV_UINT_TO_INT, CV_INT_TO_BYTE, @@ -39,35 +73,57 @@ enum { typedef struct ecs_obj ecs_obj; typedef struct ecs_structdef ecs_structdef; -typedef struct { - uint8_t etag; - int32_t len, cap; - ecs_value *items; -} arr_data; +/* ECSC 段 id(与 C# EcsContainer 段注册表一致,docs/SingleStreamFormat.md §3.3) */ +enum { + SEC_END = 0x0000, SEC_MANIFEST = 0x0001, SEC_CONSTANTS = 0x0002, SEC_STRUCTS = 0x0003, + SEC_GLOBALS = 0x0004, SEC_NATIVES = 0x0005, SEC_FUNCTIONS = 0x0006, SEC_CODE = 0x0007, + SEC_LINES = 0x0008, SEC_DEBUG_NAMES = 0x0009, SEC_IMPORTS = 0x0010, SEC_EXPORTS = 0x0011, + SEC_ILNAMES = 0x0012, SEC_IFACE = 0x0013, SEC_LINKFLAGS = 0x0014, + SEC_OPERAND_FIXUPS = 0x0020, SEC_CRC32 = 0x0030, +}; +#define SEC_FLAG_CRITICAL 0x01 +/* 对象头(24 B,8 字节对齐;payload 紧随)。单块 = 一对象(单块上限见 §4.2)。 + flags:bit0 = in use;bits1..7 = 元素/标量 tag(数组 D-B 定宽解包、BOX 标量类型依据)。 + sid:结构体对象 = 类型表下标;结构体元素数组的元素宽度来源(NewArrV 无 sid → 0 = 定宽回退)。 */ typedef struct { - const ecs_structdef *def; - ecs_value *slots; - int32_t view_parent; /* 嵌套视图:父结构体句柄(0=非视图);句柄 0 保留为 null,无歧义 */ - int32_t view_offset; /* 嵌套视图:本视图槽区在父槽区的起始偏移 */ -} st_data; + uint8_t kind; /* ECS_STRING / ECS_ARRAY / ECS_STRUCT / OBJ_KIND_BOX */ + uint8_t flags; + uint16_t len; /* 字符串 units 数 / 数组元素数 */ + int16_t rc; + int16_t sid; + int32_t next_free; /* 空闲链(in use 时未用) */ + int32_t view_parent; /* 嵌套视图:父结构体句柄(0=非视图) */ + int32_t view_offset; /* 嵌套视图:本视图槽区在父槽区的起始偏移 */ +} obj_hdr; + +#define OBJ_HDR_BYTES 24 +#define OBJ_PAYLOAD(o) ((uint8_t *)(o) + OBJ_HDR_BYTES) /* 对象载荷 = 块内紧跟对象头 */ +#define OBJ_PAYLOAD_BYTES (ECS_OBJ_BLOCK_BYTES - OBJ_HDR_BYTES) +#define OBJ_IN_USE 0x01u +#define OBJ_ETAG(f) (uint8_t)((f) >> 1) +#define OBJ_SET_ETAG(f, t) (uint8_t)(((f) & OBJ_IN_USE) | ((t) << 1)) +#define OBJ_KIND_BOX 0x7Fu /* 装箱宽标量块(payload = 8B 位模式,无子引用) */ + +/* C99 编译期布局/容量约束:不能把未对齐的帧或对象解释为含 64 位字段的对象。 */ +typedef char ecs_value_size_check[sizeof(ecs_value) == 8 ? 1 : -1]; +typedef char ecs_arena_layout_check[(ECS_FRAME_SEG_BYTES > 0 && + ECS_FRAME_SEG_BYTES <= ECS_ARENA_BYTES && ECS_FRAME_SEG_BYTES % 8 == 0 && + ECS_OBJ_BLOCK_BYTES >= OBJ_HDR_BYTES + 8 && ECS_OBJ_BLOCK_BYTES % 8 == 0) ? 1 : -1]; struct ecs_obj { - uint8_t kind; /* ECS_STRING / ECS_ARRAY / ECS_STRUCT */ - int32_t rc; - union { - struct { uint16_t *units; int32_t len, cap; } str; - arr_data arr; - st_data st; - }; + obj_hdr h; + /* payload:STRING = uint16 units[len];ARRAY = 按 etag 定宽元素;STRUCT = nslots × ecs_value; + * BOX = 8B 标量位模式(DOUBLE/UINT64/PTR) */ }; typedef struct { - char *name; /* 调试名(调试区剥离时为 NULL) */ - uint8_t nparams, nslots, hasret; - const uint32_t *code; /* 指向镜像缓冲(零拷贝,XIP 友好) */ - uint32_t words; - uint32_t code_off; + char *name; /* 调试名(调试段剥离时为 NULL) */ + uint8_t nparams, hasret; + uint16_t nslots; + const uint8_t *code; /* v3 定长指令流:指向镜像缓冲(零拷贝,XIP 原地取指) */ + uint32_t code_bytes; + uint32_t code_off; /* 相对 SEC_CODE 起点(= 入口字节地址) */ } ecs_funcdef; typedef struct { @@ -88,23 +144,32 @@ typedef struct { uint8_t tag; int64_t i64; double f64; - const uint16_t *units; /* 字符串:指向镜像缓冲(零拷贝) */ + const uint16_t *units; /* 字符串:指向镜像缓冲(零拷贝;S-20 pinned 的字符数据源) */ int32_t units_len; } const_ent; typedef struct { const ecs_funcdef *fn; - int32_t func_index; - uint32_t ret_pc; - int32_t ret_slot; /* ECS_NO_SLOT = 无接收槽 */ + uint32_t ret_pc; /* 续跑点 = 函数内字节偏移(定长取指;仅调用边界与让出点写回) */ + uint32_t ret_idx; /* 续跑点 = 指令下标(error_pc 诊断单位,与 ret_pc 同点写回) */ + uint16_t func_index; /* 诊断 / 函数表下标(镜像 func_count 为 u16,< 64K 函数) */ + uint8_t ret_slot; /* 接收寄存器(恒 ≤ 255 = 指令域上限;0 = RET 固定落点) */ + uint8_t _pad; ecs_value slots[]; -} frame; +} frame; /* 帧字节数由 fn->nslots 派生(frame_size),不入帧头 */ + +/* 帧字节尺寸(8 字节对齐):push 分配与 pop 回退的唯一消费者——派生消除即帧头省一个 u32 */ +static size_t frame_size(const ecs_funcdef *fn) +{ + return (sizeof(frame) + (size_t)fn->nslots * sizeof(ecs_value) + 7u) & ~(size_t)7u; +} struct ecs_vm { ecs_host host; - const uint8_t *img; /* 借用;生命周期 = vm */ + const uint8_t *img; /* 借用或 io 拉取的内部副本(owned_img=1 时 ecs_vm_free 释放) */ size_t img_len; + int owned_img; /* ecs_vm_load_io 拉取路径:镜像缓冲归 VM 所有 */ const_ent *consts; int32_t nconsts; ecs_structdef *structs; int32_t nstructs; ecs_value *globals; int32_t nglobals; @@ -112,20 +177,27 @@ struct ecs_vm { ecs_funcdef *funcs; int32_t nfuncs; int32_t entry; - frame **frames; - int32_t depth, frames_cap; - - ecs_obj **objs; - int32_t objs_cap, objs_count; - int32_t *free_list; - int32_t free_count, free_cap; + /* —— 零分配运行时(D-C 单一 arena 两段)—— */ + uint8_t arena[ECS_ARENA_BYTES]; + size_t frame_sp; /* 帧段 bump 水位(调用/返回严格嵌套,弹帧即回退) */ + uint32_t frame_off[ECS_MAX_FRAMES]; /* 帧表:arena 内偏移(u32 覆盖 PC 256KB arena) */ + int32_t depth; + int32_t free_head; /* 对象段空闲块链头(-1 = 空) */ int32_t steps, budget; int cancel_flag; + int wait_yield; /* 置位后 WAIT 变让出点:通知宿主时长后返回 ECS_WAITING(固件 tick 模型) */ + int host_error; /* ret_* 分配失败:CallN 优先传播,不降级为合法空值 */ int32_t error_func, error_pc; int pending_break; /* FWRITE 行断协议状态(S-14) */ }; +/* 帧表回查:frames 表存 arena 偏移(u32),帧指针按需还原 */ +static frame *frame_at(ecs_vm *vm, uint32_t off) +{ + return (frame *)(vm->arena + off); +} + /* ============================================================ * 工具 * ============================================================ */ @@ -159,59 +231,134 @@ static ecs_value make_int(int32_t x) ecs_value v = void_value(); v.tag = ECS_INT; v.i32 = x; return v; } -static int is_handle_tag(uint8_t tag) +/* 容器引用:值槽持句柄、数组元素为 4B 句柄裸存(引用计数对象) */ +static int is_ref_tag(uint8_t tag) { return tag == ECS_STRING || tag == ECS_ARRAY || tag == ECS_STRUCT; } +/* 装箱宽标量:值槽持对象池标量块句柄,随引用计数管理; + * 数组内宽元素是 8B 裸存、不属于本类(见 arr_get/arr_overwrite 边界)。 */ +static int is_boxed_tag(uint8_t tag) +{ + return tag == ECS_DOUBLE || tag == ECS_UINT64 || tag == ECS_PTR; +} + +/* 引用计数派发轴(retain/release/store_fresh/move_to/弹帧/兜底 sweep):容器引用 + 装箱宽标量 */ +static int is_heap_tag(uint8_t tag) +{ + return is_ref_tag(tag) || is_boxed_tag(tag); +} + +/* 静态(pinned)字符串(契约 S-20 / docs/ZeroAllocVm.md §2):ECS_STRING 的句柄 bit31 置位时, + 低 31 位是常量池索引——字符数据直接引用镜像缓冲(const_ent.units 已零拷贝指向镜像), + 该字符串是唯一实例、内容不可变、**不参与引用计数**(retain/release/弹帧对其为 no-op)。 + 编码天然安全:静态句柄在 int32 下为负,一切 `handle <= 0` 守卫都会拒绝它, + 因此漏改点会「响亮失败」(heap_obj 返回 NULL)而不是读错内存。 */ +#define ECS_HANDLE_STATIC 0x80000000u + +static int is_static_str(ecs_value v) +{ + return v.tag == ECS_STRING && (v.u32 & ECS_HANDLE_STATIC) != 0; +} + +static int32_t static_str_index(ecs_value v) +{ + return (int32_t)(v.u32 & ~ECS_HANDLE_STATIC); +} + +static ecs_value make_static_str(int32_t kx) +{ + ecs_value v = void_value(); + v.tag = ECS_STRING; + v.u32 = ECS_HANDLE_STATIC | (uint32_t)kx; + return v; +} + +/* v3 定长编码:无变长解码原语(varint 基流随 ECSC v1 退役)。 */ + +static uint16_t rd_u16(const uint8_t *p) +{ + return (uint16_t)((uint32_t)p[0] | (uint32_t)p[1] << 8); +} + +static uint32_t rd_u32(const uint8_t *p) +{ + return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; +} + /* ============================================================ - * §4.2 堆 + * §4.2 堆 —— 固定对象池(单块 = 一对象,超限拒绝;docs/ZeroAllocVm.md §4) * ============================================================ */ -static int32_t heap_alloc_slot(ecs_vm *vm) +static ecs_obj *obj_at(ecs_vm *vm, int32_t handle) +{ + if (handle <= 0 || handle > ECS_OBJ_BLOCK_COUNT) return NULL; + return (ecs_obj *)(vm->arena + ECS_FRAME_SEG_BYTES + (size_t)(handle - 1) * ECS_OBJ_BLOCK_BYTES); +} + +/* handle <= 0 同时拒掉 0 与静态(pinned)句柄:静态句柄 bit31 置位 ⇒ int32 负数, + 且静态字符串不占对象池(见 is_static_str 注释)。 */ +static ecs_obj *heap_obj(ecs_vm *vm, int32_t handle) +{ + ecs_obj *o = obj_at(vm, handle); + if (!o || !(o->h.flags & OBJ_IN_USE)) return NULL; + return o; +} + +/* 从空闲链取一块;链空 = 对象块耗尽 → -1(调用方报 ECS_ERR_POOL) */ +static int32_t pool_alloc(ecs_vm *vm) { - if (vm->free_count > 0) - return vm->free_list[--vm->free_count]; - if (vm->objs_count >= vm->objs_cap) + int32_t h = vm->free_head; + if (h < 0) { - int32_t cap = vm->objs_cap ? vm->objs_cap * 2 : 64; - ecs_obj **grown = (ecs_obj **)realloc(vm->objs, (size_t)cap * sizeof(ecs_obj *)); - if (!grown) return -1; - vm->objs = grown; - int32_t *grown_free = (int32_t *)realloc(vm->free_list, (size_t)cap * sizeof(int32_t)); - if (!grown_free) { free(grown); return -1; } - vm->free_list = grown_free; - vm->free_cap = cap; - vm->objs_cap = cap; - } - int32_t h = vm->objs_count++; - if (h == 0) - h = vm->objs_count++; /* 句柄 0 保留为 null(StrOrNull / release / retain 的零值语义) */ - vm->objs[h] = NULL; +#ifdef ECS_POOL_DEBUG + int live = 0; + for (int32_t i = 1; i <= ECS_OBJ_BLOCK_COUNT; i++) + { + ecs_obj *o = obj_at(vm, i); + if (o->h.flags & OBJ_IN_USE) { live++; fprintf(stderr, "[pool] live h=%d kind=%d rc=%d len=%u\n", i, o->h.kind, o->h.rc, o->h.len); } + } + fprintf(stderr, "[pool] exhausted, live=%d\n", live); +#endif + return -1; + } + ecs_obj *o = obj_at(vm, h); + vm->free_head = o->h.next_free; + memset(o, 0, ECS_OBJ_BLOCK_BYTES); + o->h.flags = OBJ_IN_USE; + o->h.rc = 1; return h; } -static ecs_obj *heap_obj(ecs_vm *vm, int32_t handle) +static void pool_free(ecs_vm *vm, int32_t handle) { - if (handle <= 0 || handle >= vm->objs_count) return NULL; - return vm->objs[handle]; + ecs_obj *o = obj_at(vm, handle); + o->h.flags = 0; + o->h.next_free = vm->free_head; + vm->free_head = handle; } -static int32_t hnew_str(ecs_vm *vm, int32_t len) +static void pool_init(ecs_vm *vm) { - int32_t h = heap_alloc_slot(vm); - if (h < 0) return -1; - ecs_obj *o = (ecs_obj *)calloc(1, sizeof(ecs_obj)); - if (!o) return -1; - o->kind = ECS_STRING; - o->rc = 1; - o->str.cap = len > 0 ? len : 0; - if (o->str.cap > 0) + vm->free_head = -1; + for (int32_t i = ECS_OBJ_BLOCK_COUNT; i >= 1; i--) { - o->str.units = (uint16_t *)malloc((size_t)o->str.cap * sizeof(uint16_t)); - if (!o->str.units) { free(o); return -1; } + ecs_obj *o = obj_at(vm, i); + o->h.next_free = vm->free_head; + vm->free_head = i; } - vm->objs[h] = o; +} + +/* 字符串:2 B/unit;超出单块 → 拒绝(ECS_ERR_POOL) */ +static int32_t hnew_str(ecs_vm *vm, int32_t len) +{ + if (len < 0 || (size_t)len * 2 > OBJ_PAYLOAD_BYTES) return -1; + int32_t h = pool_alloc(vm); + if (h < 0) return -1; + ecs_obj *o = obj_at(vm, h); + o->h.kind = ECS_STRING; + o->h.len = (uint16_t)len; return h; } @@ -220,156 +367,412 @@ static ecs_value new_str_units(ecs_vm *vm, const uint16_t *units, int32_t len) ecs_value v = void_value(); int32_t h = hnew_str(vm, len); if (h < 0) return v; - ecs_obj *o = vm->objs[h]; - if (len > 0 && units) memcpy(o->str.units, units, (size_t)len * sizeof(uint16_t)); - o->str.len = len; + ecs_obj *o = obj_at(vm, h); + if (len > 0 && units) memcpy(OBJ_PAYLOAD(o), units, (size_t)len * sizeof(uint16_t)); v.tag = ECS_STRING; - v.i64 = h; + v.u32 = (uint32_t)h; return v; } +/* ---- 装箱宽标量(DOUBLE/UINT64/PTR):对象池 8B 标量块,值持块句柄 ---- + * 参与引用计数(retain/release 经 is_heap_tag 与容器同路);块耗尽 *err=1 → 调用方报 + * ECS_ERR_POOL(LoadK/Conv/宽算术/宽元素读四类原本无失败路径的指令自此有界失败)。 */ -static int32_t hnew_arr(ecs_vm *vm, uint8_t etag, int32_t cap) +static ecs_value box_make(ecs_vm *vm, uint8_t tag, uint64_t bits, int *err) { - int32_t h = heap_alloc_slot(vm); - if (h < 0) return -1; - ecs_obj *o = (ecs_obj *)calloc(1, sizeof(ecs_obj)); - if (!o) return -1; - o->kind = ECS_ARRAY; - o->rc = 1; - o->arr.etag = etag; - o->arr.cap = cap > 0 ? cap : 0; - if (o->arr.cap > 0) + ecs_value v = void_value(); + *err = 0; + int32_t h = pool_alloc(vm); + if (h < 0) { *err = 1; return v; } + ecs_obj *o = obj_at(vm, h); + o->h.kind = OBJ_KIND_BOX; + o->h.flags = OBJ_SET_ETAG(OBJ_IN_USE, tag); + memcpy(OBJ_PAYLOAD(o), &bits, 8); + v.tag = tag; + v.u32 = (uint32_t)h; + return v; +} + +static ecs_value box_double(ecs_vm *vm, double d, int *err) +{ + uint64_t bits; + memcpy(&bits, &d, 8); + return box_make(vm, ECS_DOUBLE, bits, err); +} + +static ecs_value box_u64(ecs_vm *vm, int64_t x, int *err) +{ + return box_make(vm, ECS_UINT64, (uint64_t)x, err); +} + +static ecs_value box_ptr(ecs_vm *vm, int64_t x, int *err) +{ + return box_make(vm, ECS_PTR, (uint64_t)x, err); +} + +/* 解箱读:信任值 tag(坏句柄/错 kind → 0,与字节码同一信任面) */ +static uint64_t box_bits(ecs_vm *vm, ecs_value v) +{ + ecs_obj *o = heap_obj(vm, (int32_t)v.u32); + if (!o || o->h.kind != OBJ_KIND_BOX) return 0; + uint64_t bits; + memcpy(&bits, OBJ_PAYLOAD(o), 8); + return bits; +} + +static double box_d(ecs_vm *vm, ecs_value v) +{ + uint64_t bits = box_bits(vm, v); + double d; + memcpy(&d, &bits, 8); + return d; +} + +static int64_t box_l(ecs_vm *vm, ecs_value v) +{ + return (int64_t)box_bits(vm, v); +} + +/* 数组元素定宽(D-B):4B 标量/句柄、8B 宽类型(裸存,不装箱)、结构体元素 = nslots × ecs_value。 + 返回 0 = 单元素字节数;*total = len 元素总载荷;超块返回 -1。 */ +static int32_t arr_elem_bytes(const ecs_vm *vm, uint8_t etag, int32_t sid) +{ + switch (etag) { - o->arr.items = (ecs_value *)malloc((size_t)o->arr.cap * sizeof(ecs_value)); - if (!o->arr.items) { free(o); return -1; } + case ECS_UINT64: case ECS_DOUBLE: case ECS_PTR: return 8; + case ECS_STRUCT: + if (sid > 0 && sid < vm->nstructs) + return vm->structs[sid].nslots * (int32_t)sizeof(ecs_value); + return (int32_t)sizeof(ecs_value); + default: return 4; /* BOOL/BYTE/INT/UINT + 句柄(STRING/ARRAY) */ } - vm->objs[h] = o; +} + +static int32_t hnew_arr(ecs_vm *vm, uint8_t etag, int32_t sid, int32_t len) +{ + if (len < 0 || len > 0xFFFF) return -1; + int32_t eb = arr_elem_bytes(vm, etag, sid); + if (eb < 0 || (size_t)len * (size_t)eb > OBJ_PAYLOAD_BYTES) return -1; + int32_t h = pool_alloc(vm); + if (h < 0) return -1; + ecs_obj *o = obj_at(vm, h); + o->h.kind = ECS_ARRAY; + o->h.flags = OBJ_SET_ETAG(OBJ_IN_USE, etag); + o->h.sid = (int16_t)sid; + o->h.len = (uint16_t)len; return h; } static int32_t hnew_st(ecs_vm *vm, const ecs_structdef *def) { - int32_t h = heap_alloc_slot(vm); + if ((size_t)def->nslots * sizeof(ecs_value) > OBJ_PAYLOAD_BYTES) return -1; /* 结构体尺寸编译期可知,超块即拒 */ + int32_t h = pool_alloc(vm); if (h < 0) return -1; - ecs_obj *o = (ecs_obj *)calloc(1, sizeof(ecs_obj)); - if (!o) return -1; - o->kind = ECS_STRUCT; - o->rc = 1; - o->st.def = def; - /* 全零位 = 各类型零值(+0.0 / 句柄 0(null 串) / 整型 0)≈ 参考实现 ZeroFill */ - o->st.slots = (ecs_value *)calloc((size_t)(def->nslots > 0 ? def->nslots : 1), sizeof(ecs_value)); - if (!o->st.slots) { free(o); return -1; } - vm->objs[h] = o; + ecs_obj *o = obj_at(vm, h); + o->h.kind = ECS_STRUCT; + o->h.sid = (int16_t)(def - vm->structs); + /* 全零位 = 各类型零值(+0.0 / 句柄 0(null 串) / 整型 0)≈ 参考实现 ZeroFill(pool_alloc 已清零) */ return h; } static void release(ecs_vm *vm, ecs_value v); + +/* 数组元素句柄遍历(按元素定宽解包;结构体元素再走槽区) */ +static void arr_foreach_handle(ecs_vm *vm, ecs_obj *o, int is_release) +{ + uint8_t et = OBJ_ETAG(o->h.flags); + int32_t n = o->h.len; + if (et == ECS_STRUCT) + { + int32_t eb = arr_elem_bytes(vm, et, o->h.sid); + uint8_t *p = OBJ_PAYLOAD(o); + for (int32_t i = 0; i < n; i++) + { + ecs_value *slots = (ecs_value *)(p + (size_t)i * eb); + const ecs_structdef *def = &vm->structs[o->h.sid]; + for (int32_t k = 0; k < def->nslots; k++) + { + if (is_release) release(vm, slots[k]); + } + } + return; + } + if (!is_ref_tag(et)) return; + uint8_t *p = OBJ_PAYLOAD(o); + for (int32_t i = 0; i < n; i++) + { + int32_t h; + memcpy(&h, p + (size_t)i * 4, 4); + ecs_value v = void_value(); + v.tag = et; + v.u32 = (uint32_t)h; + if (is_release) release(vm, v); + } +} + static void retain(ecs_vm *vm, ecs_value v) { - if (!is_handle_tag(v.tag)) return; - ecs_obj *o = heap_obj(vm, (int32_t)v.i64); - if (o) o->rc++; + if (!is_heap_tag(v.tag)) return; + if (is_static_str(v)) return; /* S-20:静态值不参与 RC(heap_obj 亦拒负句柄,此处显式化契约) */ + ecs_obj *o = heap_obj(vm, (int32_t)v.u32); + if (o && o->h.rc < INT16_MAX) o->h.rc++; } static void release(ecs_vm *vm, ecs_value v) { - if (!is_handle_tag(v.tag)) return; - ecs_obj *o = heap_obj(vm, (int32_t)v.i64); + if (!is_heap_tag(v.tag)) return; + if (is_static_str(v)) return; /* S-20:静态值不参与 RC —— 弹帧/覆写/容器释放对其均为 no-op */ + int32_t h = (int32_t)v.u32; + ecs_obj *o = heap_obj(vm, h); if (!o) return; - if (--o->rc > 0) return; - switch (o->kind) + if (--o->h.rc > 0) return; + switch (o->h.kind) { - case ECS_STRING: free(o->str.units); break; - case ECS_ARRAY: - for (int32_t i = 0; i < o->arr.len; i++) - release(vm, o->arr.items[i]); - free(o->arr.items); - break; + case ECS_STRING: break; /* payload 随块回收 */ + case OBJ_KIND_BOX: break; /* 装箱宽标量:8B 位模式,无子引用 */ + case ECS_ARRAY: arr_foreach_handle(vm, o, 1); break; case ECS_STRUCT: - for (int32_t i = 0; i < o->st.def->nslots; i++) - release(vm, o->st.slots[i]); - free(o->st.slots); - if (o->st.view_parent) + { + const ecs_structdef *def = &vm->structs[o->h.sid]; + ecs_value *slots = (ecs_value *)OBJ_PAYLOAD(o); + for (int32_t i = 0; i < def->nslots; i++) + release(vm, slots[i]); + if (o->h.view_parent) { ecs_value parent = void_value(); parent.tag = ECS_STRUCT; - parent.i64 = o->st.view_parent; + parent.u32 = (uint32_t)o->h.view_parent; release(vm, parent); /* 归还视图持有的父引用 */ } break; + } } - vm->objs[(int32_t)v.i64] = NULL; - vm->free_list[vm->free_count++] = (int32_t)v.i64; - free(o); + pool_free(vm, h); } static void move_to(ecs_vm *vm, ecs_value *slot, ecs_value v) +{ + /* 同柄自赋值 no-op(对齐 EcxInterpreter.MoveToSlot 槽写规则):先释放会悬垂—— + 槽位已持有该引用,rc 减到 0 会把仍被本槽持有的块打回空闲链 */ + if (is_heap_tag(v.tag) && slot->tag == v.tag && slot->u32 == v.u32) + return; + /* 标量快径:release/retain 对标量本为 no-op,用 tag 判别免掉函数调用。 + 注意本体不能再拆「慢路径辅助函数」——函数体内一含调用,clang 会对整个 + move_to 拒绝内联(实测回退 20%+),标量热路径必须留在本体内联展开。 */ + if (is_heap_tag(v.tag)) + retain(vm, v); + /* v 可能是旧槽容器中的子值;先 retain,再释放旧容器,避免子值被提前回收。 */ + if (is_heap_tag(slot->tag)) + release(vm, *slot); + *slot = v; +} + +/* 槽写慢路径(旧值为句柄才走):release 调用收拢在此。store_fresh 用 always_inline + 强制展开(clang 按调用点×代码尺寸启发式不肯内联叶子,实测采样 ~15-18% 纯调用 + 开销;GCC/Clang 族工具链均支持该属性,其余编译器自动退化为普通静态函数)。 */ +#if defined(__GNUC__) || defined(__clang__) +#define ECS_STORE_FRESH_INLINE __attribute__((always_inline)) +#else +#define ECS_STORE_FRESH_INLINE +#endif + +static void store_fresh_slow(ecs_vm *vm, ecs_value *slot, ecs_value v) { release(vm, *slot); *slot = v; - retain(vm, v); } /* 槽位写入「出生引用」值(新建对象/标量/深拷贝结果):仅释放旧值、不 retain—— 出生引用即本槽位的引用(对齐 EcxInterpreter.StoreFresh)。 */ -static void store_fresh(ecs_vm *vm, ecs_value *slot, ecs_value v) +ECS_STORE_FRESH_INLINE static inline void store_fresh(ecs_vm *vm, ecs_value *slot, ecs_value v) { - release(vm, *slot); - *slot = v; + if (!is_heap_tag(slot->tag)) /* 标量旧值:免调用快径 */ + { + *slot = v; + return; + } + store_fresh_slow(vm, slot, v); } /* 嵌套视图写穿透(F3 后续项,对齐 EcxInterpreter.WriteStructSlot 的 ViewParent 链回写): 视图槽区是父槽切片的拷贝,槽写入后逐级回写父槽区;视图单向持有父引用,无环 */ static void sync_view(ecs_vm *vm, ecs_obj *obj) { - while (obj->st.view_parent) + while (obj->h.view_parent) { - ecs_obj *parent = heap_obj(vm, obj->st.view_parent); + ecs_obj *parent = heap_obj(vm, obj->h.view_parent); if (!parent) break; - for (int32_t i = 0; i < obj->st.def->nslots; i++) + const ecs_structdef *def = &vm->structs[obj->h.sid]; + ecs_value *slots = (ecs_value *)OBJ_PAYLOAD(obj); + ecs_value *pslots = (ecs_value *)OBJ_PAYLOAD(parent); + for (int32_t i = 0; i < def->nslots; i++) { - ecs_value *pslot = &parent->st.slots[obj->st.view_offset + i]; + ecs_value *pslot = &pslots[obj->h.view_offset + i]; release(vm, *pslot); - *pslot = obj->st.slots[i]; + *pslot = slots[i]; retain(vm, *pslot); } obj = parent; } } +/* 数组元素打包读(D-B):按 etag 定宽解包为 ecs_value。 + * 宽元素为块内 8B 裸存,读边界装箱产出(出生引用;块耗尽 *err=1,调用方报 POOL)。 */ +static ecs_value arr_get(ecs_vm *vm, const ecs_obj *o, int32_t i, int *err) +{ + uint8_t et = OBJ_ETAG(o->h.flags); + uint8_t *p = OBJ_PAYLOAD((ecs_obj *)o) + (size_t)i * (size_t)arr_elem_bytes(vm, et, o->h.sid); + ecs_value v = void_value(); + *err = 0; + switch (et) + { + case ECS_UINT64: case ECS_PTR: + case ECS_DOUBLE: + { + uint64_t bits; + memcpy(&bits, p, 8); + v = box_make(vm, et, bits, err); + break; + } + case ECS_STRUCT: + memcpy(&v, p, sizeof(ecs_value)); + break; + case ECS_STRING: case ECS_ARRAY: + { + int32_t h; + memcpy(&h, p, 4); + v.tag = et; + v.u32 = (uint32_t)h; /* 句柄 int32 符号扩展往返(静态串 bit31 负句柄原样往返) */ + break; + } + default: + v.tag = et; + memcpy(&v.i32, p, 4); + break; + } + return v; +} + +/* 数组元素覆写(SetI 语义):旧元素释放 + 打包写入 + 容器引用 retain。 + * 旧元素按 etag 就地判定(不经 arr_get,避免宽元素装箱分配);宽新值在写边界解箱为 + * 8B 裸存(箱的生命期属于值槽,数组不持箱引用 → 不 retain)。本函数不分配、不失败。 */ +static void arr_overwrite(ecs_vm *vm, ecs_obj *o, int32_t i, ecs_value v) +{ + uint8_t et = OBJ_ETAG(o->h.flags); + uint8_t *p = OBJ_PAYLOAD(o) + (size_t)i * (size_t)arr_elem_bytes(vm, et, o->h.sid); + /* 旧元素释放(与 release 同规则) */ + if (et == ECS_STRUCT) + { + const ecs_structdef *def = &vm->structs[o->h.sid]; + ecs_value *old_slots = (ecs_value *)p; + for (int32_t k = 0; k < def->nslots; k++) + release(vm, old_slots[k]); + } + else if (et == ECS_STRING || et == ECS_ARRAY) + { + int32_t h; + memcpy(&h, p, 4); + ecs_value old = void_value(); + old.tag = et; + old.u32 = (uint32_t)h; + release(vm, old); + } + switch (v.tag) + { + case ECS_UINT64: case ECS_PTR: + case ECS_DOUBLE: + { + uint64_t bits = box_bits(vm, v); + memcpy(p, &bits, 8); + break; + } + case ECS_STRUCT: + memcpy(p, &v, sizeof(ecs_value)); + break; + case ECS_STRING: case ECS_ARRAY: + { + int32_t h = (int32_t)v.u32; + memcpy(p, &h, 4); + break; + } + default: + memcpy(p, &v.i32, 4); + break; + } + if (is_ref_tag(v.tag)) + retain(vm, v); +} + +/* 数组内部复制宽元素直接搬运 8B 位模式,无需产生中间箱;引用元素维持原 retain 规则。 */ +static void arr_copy_item(ecs_vm *vm, ecs_obj *dst, int32_t di, const ecs_obj *src, int32_t si) +{ + uint8_t et = OBJ_ETAG(src->h.flags); + if (is_boxed_tag(et)) + { + memcpy(OBJ_PAYLOAD(dst) + (size_t)di * 8, OBJ_PAYLOAD(src) + (size_t)si * 8, 8); + return; + } + int err; + ecs_value item = arr_get(vm, src, si, &err); + (void)err; /* 非宽元素不分配,不可能耗尽对象块。 */ + arr_overwrite(vm, dst, di, item); +} + /* S-01:字符串共享;数组/结构体一层新容器 + 子项 retain */ static ecs_value deep_copy(ecs_vm *vm, ecs_value v) { if (v.tag == ECS_ARRAY) { - ecs_obj *src = heap_obj(vm, (int32_t)v.i64); + ecs_obj *src = heap_obj(vm, (int32_t)v.u32); if (!src) return void_value(); - int32_t h = hnew_arr(vm, src->arr.etag, src->arr.len); + uint8_t et = OBJ_ETAG(src->h.flags); + int32_t h = hnew_arr(vm, et, src->h.sid, src->h.len); if (h < 0) return void_value(); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = 0; i < src->arr.len; i++) + ecs_obj *dst = obj_at(vm, h); + if (et == ECS_STRUCT) + { + int32_t eb = arr_elem_bytes(vm, et, src->h.sid); + const ecs_structdef *def = &vm->structs[src->h.sid]; + for (int32_t i = 0; i < src->h.len; i++) + { + ecs_value *dst_slots = (ecs_value *)(OBJ_PAYLOAD(dst) + (size_t)i * eb); + ecs_value *src_slots = (ecs_value *)(OBJ_PAYLOAD(src) + (size_t)i * eb); + memcpy(dst_slots, src_slots, (size_t)def->nslots * sizeof(ecs_value)); + for (int32_t k = 0; k < def->nslots; k++) + retain(vm, dst_slots[k]); + } + } + else { - dst->arr.items[i] = src->arr.items[i]; - retain(vm, dst->arr.items[i]); + int32_t eb = arr_elem_bytes(vm, et, src->h.sid); + memcpy(OBJ_PAYLOAD(dst), OBJ_PAYLOAD(src), (size_t)src->h.len * (size_t)eb); + /* 容器句柄元素逐个 retain;宽/内联标量元素为裸存无引用(不装箱、不增计数) */ + if (et == ECS_STRING || et == ECS_ARRAY) + for (int32_t i = 0; i < src->h.len; i++) + { + int err; + ecs_value item = arr_get(vm, src, i, &err); + (void)err; + retain(vm, item); + } } - dst->arr.len = src->arr.len; - ecs_value r = void_value(); r.tag = ECS_ARRAY; r.i64 = h; + ecs_value r = void_value(); r.tag = ECS_ARRAY; r.u32 = (uint32_t)h; return r; } if (v.tag == ECS_STRUCT) { - ecs_obj *src = heap_obj(vm, (int32_t)v.i64); + ecs_obj *src = heap_obj(vm, (int32_t)v.u32); if (!src) return void_value(); - int32_t h = hnew_st(vm, src->st.def); + int32_t h = hnew_st(vm, &vm->structs[src->h.sid]); if (h < 0) return void_value(); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = 0; i < src->st.def->nslots; i++) - { - dst->st.slots[i] = src->st.slots[i]; - retain(vm, dst->st.slots[i]); - } - ecs_value r = void_value(); r.tag = ECS_STRUCT; r.i64 = h; + ecs_obj *dst = obj_at(vm, h); + const ecs_structdef *def = &vm->structs[src->h.sid]; + ecs_value *dslots = (ecs_value *)OBJ_PAYLOAD(dst); + ecs_value *sslots = (ecs_value *)OBJ_PAYLOAD(src); + memcpy(dslots, sslots, (size_t)def->nslots * sizeof(ecs_value)); + for (int32_t i = 0; i < def->nslots; i++) + retain(vm, dslots[i]); + ecs_value r = void_value(); r.tag = ECS_STRUCT; r.u32 = (uint32_t)h; return r; } ecs_value r = v; @@ -378,13 +781,14 @@ static ecs_value deep_copy(ecs_vm *vm, ecs_value v) } /* COW(move-on-unique):句柄唯一引用(rc==1,恒为出生临时槽持有)时共享移交,跳过容器 - 深拷贝;否则维持 S-01 深拷贝。可观察值语义不变——变量/全局槽永不与另一变量/全局共享容器。 */ + 深拷贝;否则维持 S-01 深拷贝。可观察值语义不变——变量/全局槽永不与另一变量/全局共享容器。 + (装箱宽标量值不可变,共享移交与逐位拷贝不可区分,走同一 COW 快径。) */ static ecs_value deep_copy_cow(ecs_vm *vm, ecs_value v) { - if (is_handle_tag(v.tag)) + if (is_heap_tag(v.tag)) { - ecs_obj *o = heap_obj(vm, (int32_t)v.i64); - if (o && o->rc == 1) + ecs_obj *o = heap_obj(vm, (int32_t)v.u32); + if (o && o->h.rc == 1) { retain(vm, v); return v; @@ -393,29 +797,27 @@ static ecs_value deep_copy_cow(ecs_vm *vm, ecs_value v) return deep_copy(vm, v); } -static void unify(ecs_vm *vm, ecs_value *slot, ecs_value v, int deep) -{ - if (deep) - { - ecs_value copied = deep_copy_cow(vm, v); - move_to(vm, slot, copied); - } - else - { - move_to(vm, slot, v); - } -} /* ---- 字符串工具 ---- */ static void str_or_null(ecs_vm *vm, ecs_value v, const uint16_t **units, int32_t *len) { if (v.tag != ECS_STRING) { *units = NULL; *len = 0; return; } - if (v.i64 == 0) { *units = NULL; *len = 0; return; } - ecs_obj *o = heap_obj(vm, (int32_t)v.i64); + if (is_static_str(v)) + { + /* S-20:pinned 常量串 —— 字符数据在镜像里,零拷贝直接引用(units_len 以字节计) */ + int32_t kx = static_str_index(v); + if (kx < 0 || kx >= vm->nconsts) { *units = NULL; *len = 0; return; } + const_ent *k = &vm->consts[kx]; + *units = k->units; + *len = k->units_len / 2; + return; + } + if (v.u32 == 0) { *units = NULL; *len = 0; return; } + ecs_obj *o = heap_obj(vm, (int32_t)v.u32); if (!o) { *units = NULL; *len = 0; return; } - *units = o->str.units; - *len = o->str.len; + *units = (const uint16_t *)OBJ_PAYLOAD(o); + *len = o->h.len; } static int units_equal(const uint16_t *a, int32_t alen, const uint16_t *b, int32_t blen) @@ -437,20 +839,14 @@ static int units_contains(const uint16_t *hay, int32_t hlen, const uint16_t *nee } /* ============================================================ - * S-07/S-08 TOSTR 两层上下文 + * S-07/S-08 TOSTR 两层上下文(有界工作缓冲:溢出 = ECS_ERR_POOL,不做块链) * ============================================================ */ -typedef struct { uint16_t *units; int32_t len, cap; } strbuf; +typedef struct { uint16_t units[ECS_TOSTR_MAX]; int32_t len; int overflow; } strbuf; static int sb_reserve(strbuf *sb, int32_t extra) { - if (sb->len + extra <= sb->cap) return 1; - int32_t cap = sb->cap ? sb->cap : 32; - while (cap < sb->len + extra) cap *= 2; - uint16_t *grown = (uint16_t *)realloc(sb->units, (size_t)cap * sizeof(uint16_t)); - if (!grown) return 0; - sb->units = grown; - sb->cap = cap; + if (sb->len + extra > ECS_TOSTR_MAX) { sb->overflow = 1; return 0; } return 1; } @@ -491,9 +887,9 @@ static void tostring_top(ecs_vm *vm, strbuf *sb, ecs_value v) case ECS_BYTE: case ECS_INT: case ECS_UINT: snprintf(tmp, sizeof(tmp), "%d", v.i32); sb_ascii(sb, tmp); break; - case ECS_UINT64: snprintf(tmp, sizeof(tmp), "%llu", (unsigned long long)v.i64); sb_ascii(sb, tmp); break; - case ECS_PTR: snprintf(tmp, sizeof(tmp), "%lld", (long long)v.i64); sb_ascii(sb, tmp); break; - case ECS_DOUBLE: sb_double(sb, v.f64); break; + case ECS_UINT64: snprintf(tmp, sizeof(tmp), "%" PRIu64, (uint64_t)box_l(vm, v)); sb_ascii(sb, tmp); break; + case ECS_PTR: snprintf(tmp, sizeof(tmp), "%" PRId64, box_l(vm, v)); sb_ascii(sb, tmp); break; + case ECS_DOUBLE: sb_double(sb, box_d(vm, v)); break; case ECS_STRING: { const uint16_t *u; int32_t len; @@ -512,28 +908,33 @@ static void tostring_nested(ecs_vm *vm, strbuf *sb, ecs_value v) { case ECS_ARRAY: { - ecs_obj *o = heap_obj(vm, (int32_t)v.i64); + ecs_obj *o = heap_obj(vm, (int32_t)v.u32); sb_ascii(sb, "["); if (o) - for (int32_t i = 0; i < o->arr.len; i++) + for (int32_t i = 0; i < o->h.len; i++) { if (i > 0) sb_ascii(sb, ", "); - tostring_nested(vm, sb, o->arr.items[i]); + int err; + ecs_value item = arr_get(vm, o, i, &err); + if (err) { sb->overflow = 1; return; } /* 宽元素装箱块耗尽 → 同 TOSTR 有界拒绝 */ + tostring_nested(vm, sb, item); + if (is_boxed_tag(item.tag)) release(vm, item); } sb_ascii(sb, "]"); break; } case ECS_STRUCT: { - ecs_obj *o = heap_obj(vm, (int32_t)v.i64); + ecs_obj *o = heap_obj(vm, (int32_t)v.u32); sb_ascii(sb, "struct:"); - if (o && o->st.def && o->st.def->name) sb_ascii(sb, o->st.def->name); + if (o && o->h.sid >= 0 && o->h.sid < vm->nstructs && vm->structs[o->h.sid].name) + sb_ascii(sb, vm->structs[o->h.sid].name); break; } - case ECS_PTR: snprintf(tmp, sizeof(tmp), "0x%llX", (unsigned long long)v.i64); sb_ascii(sb, tmp); break; + case ECS_PTR: snprintf(tmp, sizeof(tmp), "0x%" PRIX64, box_bits(vm, v)); sb_ascii(sb, tmp); break; case ECS_BOOL: sb_ascii(sb, v.i32 ? "true" : "false"); break; - case ECS_UINT64: snprintf(tmp, sizeof(tmp), "%llu", (unsigned long long)v.i64); sb_ascii(sb, tmp); break; - case ECS_DOUBLE: sb_double(sb, v.f64); break; + case ECS_UINT64: snprintf(tmp, sizeof(tmp), "%" PRIu64, (uint64_t)box_l(vm, v)); sb_ascii(sb, tmp); break; + case ECS_DOUBLE: sb_double(sb, box_d(vm, v)); break; case ECS_STRING: { const uint16_t *u; int32_t len; @@ -546,13 +947,15 @@ static void tostring_nested(ecs_vm *vm, strbuf *sb, ecs_value v) } } -static ecs_value tostring_value(ecs_vm *vm, ecs_value v) +static ecs_value tostring_value(ecs_vm *vm, ecs_value v, int *pool_full) { strbuf sb; memset(&sb, 0, sizeof(sb)); tostring_top(vm, &sb, v); - ecs_value r = new_str_units(vm, sb.units, sb.len); - free(sb.units); - return r; + *pool_full = sb.overflow; + if (sb.overflow) return void_value(); + ecs_value result = new_str_units(vm, sb.units, sb.len); + if (result.tag == ECS_VOID) *pool_full = 1; + return result; } /* S-09 元素值相等 */ @@ -567,12 +970,14 @@ static int value_equals(ecs_vm *vm, ecs_value x, ecs_value y) return units_equal(ux, lx, uy, ly); } if (x.tag == ECS_DOUBLE) - return x.f64 == y.f64; - return x.i64 == y.i64; + return box_d(vm, x) == box_d(vm, y); /* 值相等,非句柄同一性 */ + if (x.tag == ECS_UINT64 || x.tag == ECS_PTR) + return box_bits(vm, x) == box_bits(vm, y); /* 64 位位模式比较,非句柄 */ + return x.u32 == y.u32; /* 内联标量按位;容器句柄按同一性(与 16B 时代一致) */ } /* ============================================================ - * §4.1 镜像解析 + §4.4 结构体布局展开 + * §4.1 镜像解析(ECSC 容器)+ §4.4 结构体布局展开 * ============================================================ */ typedef struct { @@ -610,6 +1015,7 @@ static int32_t r_count(reader *r) return (int32_t)v; } + /* 嵌套展开(§4.4):Scalar/Boxed=1 槽,FixedArray=count,NestedStruct=嵌套 nslots;环 → -1 */ static int32_t expand_struct_slots(ecs_structdef *defs, int32_t sid, uint8_t *visiting) { @@ -638,103 +1044,121 @@ static int32_t expand_struct_slots(ecs_structdef *defs, int32_t sid, uint8_t *vi return total; } -int ecs_vm_load(ecs_vm *vm, const uint8_t *image, size_t len) +static int32_t load_image(ecs_vm *vm, const uint8_t *image, size_t len) { - if (!vm || !image || len < 0x24) return ECS_ERR_IMAGE; + if (!vm || !image || len < 36) return ECS_ERR_IMAGE; + vm->owned_img = 0; vm->img = image; vm->img_len = len; - reader r = { image, len, 0, 0 }; - if ((uint32_t)r_bytes(&r, 4) != 0x32435845u) return ECS_ERR_IMAGE; /* "ECX2" */ - if (r_bytes(&r, 2) != 2) return ECS_ERR_IMAGE; /* format_ver = 2(全局名在调试区) */ - uint32_t flags = (uint32_t)r_bytes(&r, 2); - (void)r_bytes(&r, 1); /* max_slots */ - (void)r_bytes(&r, 1); /* max_depth */ - /* 特征需求掩码(原保留位 u16 @0x0C,VM2.md §9.1):IL 由 flags.I 投影(EcmEcxFormat §2.1)。 - 加载规则:宿主 feats 缺位 → 拒跑(IL → ECS_ERR_IL 既有码,其余 → ECS_ERR_FEAT)。 */ - uint32_t feats = (uint32_t)r_bytes(&r, 2); - if ((flags & 0x4) != 0) - feats |= ECS_FEAT_IL; - if ((feats & ECS_FEAT_IL) != 0) - return ECS_ERR_IL; - if ((feats & ~vm->host.feats) != 0) - return ECS_ERR_FEAT; - int32_t nconsts = r_count(&r); - int32_t nstructs = r_count(&r); - int32_t nglobals = r_count(&r); - int32_t nnatives = r_count(&r); - int32_t nfuncs = r_count(&r); - int32_t debug_count = r_count(&r); - if (r.overflow || nfuncs <= 0) return ECS_ERR_IMAGE; - - /* ---- 常量池 ---- */ - vm->consts = (const_ent *)calloc((size_t)(nconsts > 0 ? nconsts : 1), sizeof(const_ent)); - vm->nconsts = nconsts; - if (!vm->consts) return ECS_ERR_OOM; - for (int32_t i = 0; i < nconsts; i++) + /* ---- ECX1 平铺头(36B)---- */ + if (rd_u32(image) != 0x31584345u) return ECS_ERR_IMAGE; /* "ECX1" */ + if (rd_u16(image + 4) != 1) return ECS_ERR_IMAGE; /* format = 1 */ + if (rd_u16(image + 6) != (uint16_t)ECS_ABI_REV) return ECS_ERR_UNSUPPORTED; + uint16_t feats = rd_u16(image + 8); + vm->entry = (int16_t)rd_u16(image + 10); + uint8_t flags = image[12]; + uint8_t name_len = image[13]; + uint16_t func_count = rd_u16(image + 14); + uint32_t code_size = rd_u32(image + 16); + uint32_t dbg_size = rd_u32(image + 20); + if (vm->entry < 0 || 36 + (size_t)name_len > len) return ECS_ERR_IMAGE; + + /* 信任面:不做 CRC / 指令流内容校验——相信宿主提供的字节码 + * (Lua 式最小验证面);解析期仅保证宿主内存安全(r_* 越界防护)。 */ + + /* 特征需求投影(flags.I → FEAT_IL);strict 语义保留——能力降级为后续契约批次 */ + uint32_t img_feats = feats | ((flags & 0x4) ? ECS_FEAT_IL : 0); + /* Background audio is mandatory even without strict_caps: unsupported never means a zero score. */ + if ((img_feats & ECS_FEAT_AUDIO) && !(vm->host.feats & ECS_FEAT_AUDIO)) return ECS_ERR_FEAT; + /* S-21:feats = 元信息,缺省不拒载(运行期按缺省值表降级);strict_caps 恢复响亮校验 */ + if (vm->host.strict_caps) { + if (img_feats & ECS_FEAT_IL) return ECS_ERR_IL; + if (img_feats & ~vm->host.feats) return ECS_ERR_FEAT; + } + + size_t pos = 36 + name_len; + + /* ---- 常量池(count + entries)---- */ { - const_ent *c = &vm->consts[i]; - c->tag = (uint8_t)r_bytes(&r, 1); - switch (c->tag) + reader r = { image, len, pos, 0 }; + int32_t nconsts = r_count(&r); + if (r.overflow) return ECS_ERR_IMAGE; + vm->consts = (const_ent *)calloc((size_t)(nconsts > 0 ? nconsts : 1), sizeof(const_ent)); + if (!vm->consts) return ECS_ERR_OOM; + vm->nconsts = nconsts; + for (int32_t i = 0; i < nconsts; i++) { - case ECS_INT: - case ECS_UINT: - c->i64 = r_bytes(&r, 4); - break; - case ECS_UINT64: - case ECS_PTR: - c->i64 = r_bytes(&r, 8); - break; - case ECS_DOUBLE: - { - uint64_t bits = (uint64_t)r_bytes(&r, 8); - memcpy(&c->f64, &bits, sizeof(double)); - break; - } - case ECS_STRING: + const_ent *c = &vm->consts[i]; + c->tag = (uint8_t)r_bytes(&r, 1); + switch (c->tag) { - int64_t units = r_bytes(&r, 2); - if (r.overflow || units < 0) return ECS_ERR_IMAGE; - c->units_len = (int32_t)units * 2; - if (r.pos + (size_t)c->units_len > r.len) return ECS_ERR_IMAGE; - c->units = (const uint16_t *)(const void *)(image + r.pos); - r.pos += (size_t)c->units_len; - break; + case ECS_INT: + case ECS_UINT: + c->i64 = r_bytes(&r, 4); + break; + case ECS_UINT64: + case ECS_PTR: + c->i64 = r_bytes(&r, 8); + break; + case ECS_DOUBLE: + { + uint64_t bits = (uint64_t)r_bytes(&r, 8); + memcpy(&c->f64, &bits, sizeof(double)); + break; + } + case ECS_STRING: + { + int64_t units = r_bytes(&r, 2); + if (r.overflow || units < 0) return ECS_ERR_IMAGE; + c->units_len = (int32_t)units * 2; + if (r.pos + (size_t)c->units_len > len) return ECS_ERR_IMAGE; + c->units = (const uint16_t *)(const void *)(image + r.pos); + r.pos += (size_t)c->units_len; + break; + } + default: + return ECS_ERR_IMAGE; } - default: - return ECS_ERR_IMAGE; } + if (r.overflow) return ECS_ERR_IMAGE; + pos = r.pos; } - if (r.overflow) return ECS_ERR_IMAGE; - /* ---- 类型表(计数消费后统一布局展开,§4.4) ---- */ - vm->structs = (ecs_structdef *)calloc((size_t)(nstructs > 0 ? nstructs : 1), sizeof(ecs_structdef)); - vm->nstructs = nstructs; - if (!vm->structs) return ECS_ERR_OOM; - for (int32_t s = 0; s < nstructs; s++) + /* ---- 类型表(count + entries;布局展开)---- */ { - ecs_structdef *def = &vm->structs[s]; - def->name = r_utf8(&r); - def->nfields = (int32_t)r_bytes(&r, 1); - def->fields = (ecs_fielddef *)calloc((size_t)(def->nfields > 0 ? def->nfields : 1), sizeof(ecs_fielddef)); - if (!def->name || !def->fields) return r.overflow ? ECS_ERR_IMAGE : ECS_ERR_OOM; - for (int32_t f = 0; f < def->nfields; f++) + reader r = { image, len, pos, 0 }; + int32_t nstructs = r_count(&r); + if (r.overflow) return ECS_ERR_IMAGE; + vm->structs = (ecs_structdef *)calloc((size_t)(nstructs > 0 ? nstructs : 1), sizeof(ecs_structdef)); + if (!vm->structs) return ECS_ERR_OOM; + vm->nstructs = nstructs; + for (int32_t st = 0; st < nstructs; st++) { - ecs_fielddef *fd = &def->fields[f]; - fd->name = r_utf8(&r); - fd->kind = (uint8_t)r_bytes(&r, 1); - fd->type = (uint8_t)r_bytes(&r, 1); - fd->elem = (uint8_t)r_bytes(&r, 1); - fd->ext = (uint16_t)r_bytes(&r, 2); - if (!fd->name) return ECS_ERR_IMAGE; + ecs_structdef *def = &vm->structs[st]; + def->name = r_utf8(&r); + def->nfields = (int32_t)r_bytes(&r, 1); + def->fields = (ecs_fielddef *)calloc((size_t)(def->nfields > 0 ? def->nfields : 1), sizeof(ecs_fielddef)); + if (!def->name || !def->fields) return r.overflow ? ECS_ERR_IMAGE : ECS_ERR_OOM; + for (int32_t f = 0; f < def->nfields; f++) + { + ecs_fielddef *fd = &def->fields[f]; + fd->name = r_utf8(&r); + fd->kind = (uint8_t)r_bytes(&r, 1); + fd->type = (uint8_t)r_bytes(&r, 1); + fd->elem = (uint8_t)r_bytes(&r, 1); + fd->ext = (uint16_t)r_bytes(&r, 2); + if (!fd->name) return ECS_ERR_IMAGE; + } } + if (r.overflow) return ECS_ERR_IMAGE; + pos = r.pos; } - if (r.overflow) return ECS_ERR_IMAGE; { - uint8_t *visiting = (uint8_t *)calloc((size_t)(nstructs > 0 ? nstructs : 1), 1); + uint8_t *visiting = (uint8_t *)calloc((size_t)(vm->nstructs > 0 ? vm->nstructs : 1), 1); if (!visiting) return ECS_ERR_OOM; - for (int32_t s = 0; s < nstructs; s++) - if (expand_struct_slots(vm->structs, s, visiting) < 0) + for (int32_t st = 0; st < vm->nstructs; st++) + if (expand_struct_slots(vm->structs, st, visiting) < 0) { free(visiting); return ECS_ERR_IMAGE; /* 嵌套环 */ @@ -742,80 +1166,135 @@ int ecs_vm_load(ecs_vm *vm, const uint8_t *image, size_t len) free(visiting); } - /* ---- 全局槽 ---- */ - vm->globals = (ecs_value *)calloc((size_t)(nglobals > 0 ? nglobals : 1), sizeof(ecs_value)); - vm->nglobals = nglobals; - if (!vm->globals) return ECS_ERR_OOM; - for (int32_t g = 0; g < nglobals; g++) + /* ---- 全局槽(count + (module_idx u8 + type u8)——VM 只占位不消费名字)---- */ { - (void)r_bytes(&r, 2); /* 模块 idx + 类型码(v2:名字在调试区) */ + reader r = { image, len, pos, 0 }; + int32_t nglobals = r_count(&r); if (r.overflow) return ECS_ERR_IMAGE; + vm->globals = (ecs_value *)calloc((size_t)(nglobals > 0 ? nglobals : 1), sizeof(ecs_value)); + if (!vm->globals) return ECS_ERR_OOM; + vm->nglobals = nglobals; + for (int32_t g = 0; g < nglobals; g++) + { + (void)r_bytes(&r, 2); + if (r.overflow) return ECS_ERR_IMAGE; + } + pos = r.pos; } /* ---- 原生名表 ---- */ - vm->natives = (char **)calloc((size_t)(nnatives > 0 ? nnatives : 1), sizeof(char *)); - vm->nnatives = nnatives; - if (!vm->natives) return ECS_ERR_OOM; - for (int32_t n = 0; n < nnatives; n++) { - vm->natives[n] = r_utf8(&r); - if (!vm->natives[n]) return ECS_ERR_IMAGE; - } - - /* ---- 函数表(11 字节定长)+ 代码区指针 ---- */ - vm->funcs = (ecs_funcdef *)calloc((size_t)nfuncs, sizeof(ecs_funcdef)); - vm->nfuncs = nfuncs; - if (!vm->funcs) return ECS_ERR_OOM; - uint32_t last_off = 0, last_words = 0; - for (int32_t f = 0; f < nfuncs; f++) - { - ecs_funcdef *fd = &vm->funcs[f]; - fd->nparams = (uint8_t)r_bytes(&r, 1); - fd->nslots = (uint8_t)r_bytes(&r, 1); - fd->hasret = (uint8_t)r_bytes(&r, 1); - fd->code_off = (uint32_t)r_bytes(&r, 4); - fd->words = (uint32_t)r_bytes(&r, 4); + reader r = { image, len, pos, 0 }; + int32_t nnatives = r_count(&r); if (r.overflow) return ECS_ERR_IMAGE; - if (fd->code_off != last_off && fd->code_off != last_off + last_words) - return ECS_ERR_IMAGE; /* 非连续(允许首函数偏移 0) */ - last_off = fd->code_off; - last_words = fd->words; - } - uint64_t code_bytes = (uint64_t)last_off * 4 + (uint64_t)last_words * 4; - if (r.pos + code_bytes > r.len) return ECS_ERR_IMAGE; - { - const uint8_t *code_base = image + r.pos; - for (int32_t f = 0; f < nfuncs; f++) - vm->funcs[f].code = (const uint32_t *)(code_base + (size_t)vm->funcs[f].code_off * 4); - r.pos += (size_t)code_bytes; + vm->natives = (char **)calloc((size_t)(nnatives > 0 ? nnatives : 1), sizeof(char *)); + if (!vm->natives) return ECS_ERR_OOM; + vm->nnatives = nnatives; + for (int32_t n = 0; n < nnatives; n++) + { + vm->natives[n] = r_utf8(&r); + if (!vm->natives[n]) return ECS_ERR_IMAGE; + } + if (r.overflow) return ECS_ERR_IMAGE; + pos = r.pos; } - /* ---- 调试名区(可选):函数名表 + 全局名表(debug_count == nfuncs + nglobals, - * EcmEcxFormat §2.9)。名字纯诊断用,全局名读后即弃。 ---- */ - if (debug_count > 0) + /* ---- 函数表(count + (nslots u16 + code_off u32))+ .text ---- */ { - if (debug_count != nfuncs + nglobals) return ECS_ERR_IMAGE; + reader r = { image, len, pos, 0 }; + int32_t nfuncs = r_count(&r); + if (r.overflow || nfuncs <= 0) return ECS_ERR_IMAGE; + if ((uint16_t)nfuncs != func_count) return ECS_ERR_IMAGE; /* 头/表计数一致 */ + vm->funcs = (ecs_funcdef *)calloc((size_t)nfuncs, sizeof(ecs_funcdef)); + if (!vm->funcs) return ECS_ERR_OOM; + vm->nfuncs = nfuncs; for (int32_t f = 0; f < nfuncs; f++) { - vm->funcs[f].name = r_utf8(&r); + ecs_funcdef *fd = &vm->funcs[f]; + fd->nslots = (uint16_t)r_bytes(&r, 2); + fd->code_off = (uint32_t)r_bytes(&r, 4); if (r.overflow) return ECS_ERR_IMAGE; + if (fd->nslots > ECS_REG_MAX) return ECS_ERR_IMAGE; /* 帧长上限 = 指令域 u8 */ } - for (int32_t g = 0; g < nglobals; g++) + if (code_size > len - r.pos) return ECS_ERR_IMAGE; + const uint8_t *stream = image + r.pos; + for (int32_t f = 0; f < nfuncs; f++) { - char *gname = r_utf8(&r); - free(gname); - if (r.overflow) return ECS_ERR_IMAGE; + vm->funcs[f].code = stream + vm->funcs[f].code_off; + uint32_t next_off = (f + 1 < nfuncs) ? vm->funcs[f + 1].code_off : code_size; + vm->funcs[f].code_bytes = next_off - vm->funcs[f].code_off; } + /* 连续性(第二遍,code_bytes 已知) */ + for (int32_t f = 0; f + 1 < nfuncs; f++) + if (vm->funcs[f + 1].code_off != vm->funcs[f].code_off + vm->funcs[f].code_bytes) + return ECS_ERR_IMAGE; + pos = r.pos + code_size; + } + + /* ---- 调试块(可选;VM 不消费,跳过)---- */ + if (dbg_size > 0) + { + if (dbg_size > len - pos) return ECS_ERR_IMAGE; + pos += dbg_size; } + if (pos != len) return ECS_ERR_IMAGE; /* 平铺布局严格等长(fail-closed) */ + + if (vm->entry < 0 || vm->entry >= vm->nfuncs) return ECS_ERR_IMAGE; + return ECS_OK; +} + +int ecs_vm_load(ecs_vm *vm, const uint8_t *image, size_t len) +{ + if (!vm) return ECS_ERR_IMAGE; + return load_image(vm, image, len); +} - /* ---- entry 收尾(§2.10:恰剩 4 字节) ---- */ - int32_t entry = (int32_t)r_bytes(&r, 4); - if (r.overflow || r.pos != r.len) return ECS_ERR_IMAGE; - if (entry < 0 || entry >= nfuncs) return ECS_ERR_IMAGE; - vm->entry = entry; +int ecs_vm_load_io(ecs_vm *vm, const ecs_io *io) +{ + if (!vm || !io || !io->read || io->size < 36) return ECS_ERR_IMAGE; + uint8_t *buf = (uint8_t *)malloc(io->size); + if (!buf) return ECS_ERR_OOM; + if (io->read(io->ud, 0, buf, io->size) != io->size) + { + free(buf); + return ECS_ERR_IMAGE; /* 读失败(EEPROM/NVS 短读) */ + } + int32_t rc = load_image(vm, buf, io->size); + if (rc != ECS_OK) + { + free(buf); + return rc; + } + vm->img = buf; /* 字符串/名字等 pinned 指向内部副本 */ + vm->img_len = io->size; + vm->owned_img = 1; return ECS_OK; } +/* ---- 字节码来源抽象(uLua zIO 式)---- */ + +typedef struct { const uint8_t *buf; uint32_t len; int direct; } mem_io_state; + +static uint32_t mem_io_read(void *ud, uint32_t offset, uint8_t *out, uint32_t len) +{ + mem_io_state *m = (mem_io_state *)ud; + if (offset > m->len) return 0; + uint32_t n = m->len - offset < len ? m->len - offset : len; + memcpy(out, m->buf + offset, n); + return n; +} + +void ecs_io_mem(ecs_io *io, const void *buf, uint32_t len, int direct) +{ + static mem_io_state state; /* 单宿主线程约定(VM 本身非线程安全) */ + state.buf = (const uint8_t *)buf; + state.len = len; + state.direct = direct; + io->ud = &state; + io->size = len; + io->read = mem_io_read; +} + /* ============================================================ * §4.5 核心原生表 * ============================================================ */ @@ -832,20 +1311,101 @@ int32_t ecs_vm_arg_str(ecs_vm *vm, ecs_value v, const uint16_t **units) void ecs_vm_ret_str(ecs_vm *vm, ecs_value *ret, const uint16_t *units, int32_t len) { *ret = new_str_units(vm, units, len); + if (ret->tag == ECS_VOID) vm->host_error = ECS_ERR_POOL; } -/* ============================================================ - * 公共 API:创建 / 配置 / 销毁 - * ============================================================ */ +int64_t ecs_vm_arg_wide(ecs_vm *vm, ecs_value v) +{ + if (v.tag == ECS_PTR || v.tag == ECS_UINT64) return box_l(vm, v); + return v.i32; /* 内联 tag:i32 位模式符号扩展(宿主按 PTR/INT 语义自行解释) */ +} -ecs_vm *ecs_vm_new(const ecs_host *host) +int ecs_vm_ret_wide(ecs_vm *vm, ecs_value *ret, uint8_t tag, int64_t x) { - ecs_vm *vm = (ecs_vm *)calloc(1, sizeof(ecs_vm)); - if (!vm) return NULL; + *ret = void_value(); + if (tag != ECS_PTR && tag != ECS_UINT64) + return vm->host_error = ECS_ERR_TYPE; + int err = 0; + *ret = (tag == ECS_UINT64) ? box_u64(vm, x, &err) : box_ptr(vm, x, &err); + if (err) return vm->host_error = ECS_ERR_POOL; + return ECS_OK; +} + +double ecs_vm_arg_double(ecs_vm *vm, ecs_value v) +{ + return box_d(vm, v); +} + +int ecs_vm_ret_double(ecs_vm *vm, ecs_value *ret, double x) +{ + int err = 0; + *ret = box_double(vm, x, &err); + if (err) return vm->host_error = ECS_ERR_POOL; + return ECS_OK; +} + +/* ---- S-21 能力降级缺省值表(与 C# EcsCapabilityDefaults 双生;能力矩阵对拍锁定) ---- + * 宿主回调缺失/未实现时按表返回类型正确的中性值;返回 1=命中缺省,0=表外(保持响亮)。 + * 表外原则:未知 syscall 编号 / 未知原生名不降级(打错名不静默)。 */ + +static int ecs_cap_syscall_default(ecs_vm *vm, int32_t id, ecs_value *args, ecs_value *ret) +{ + const uint16_t *u = NULL; + switch (id) { + case ECS_SYSCALL_FWRITE: /* PRINT 降级:静默仍返 len(S-14) */ + *ret = (ecs_value){0}; + ret->tag = ECS_INT; + ret->i32 = ecs_vm_arg_str(vm, args[1], &u); + return 1; + case ECS_SYSCALL_FREAD: + case ECS_SYSCALL_READFILE: + ecs_vm_ret_str(vm, ret, NULL, 0); + return 1; + case ECS_SYSCALL_FOPEN: + { + ecs_vm_ret_wide(vm, ret, ECS_PTR, -1); /* 无效句柄;池耗尽由 CallN 响亮传播 */ + return 1; + } + case ECS_SYSCALL_FEOF: + case ECS_SYSCALL_FILE_EXISTS: + case ECS_SYSCALL_TIME: + case ECS_SYSCALL_OCR_CONF: + *ret = (ecs_value){0}; + ret->tag = ECS_INT; + ret->i32 = 0; + return 1; + case ECS_SYSCALL_FCLOSE: + case ECS_SYSCALL_WRITEFILE: + case ECS_SYSCALL_APPENDFILE: + case ECS_SYSCALL_ALERT: + case ECS_SYSCALL_BEEP: + case ECS_SYSCALL_AMIIBO: + *ret = (ecs_value){0}; /* Void:无接收槽语义,等价 no-op(S-13:任何宿主不写寄存器) */ + return 1; + case ECS_SYSCALL_ARG: + case ECS_SYSCALL_ENV: + case ECS_SYSCALL_APP: + ecs_vm_ret_str(vm, ret, NULL, 0); + return 1; + default: + return 0; /* 未知编号不降级 */ + } +} + +/* ============================================================ + * 公共 API:创建 / 配置 / 销毁 + * ============================================================ */ + +ecs_vm *ecs_vm_new(const ecs_host *host) +{ + ecs_vm *vm = (ecs_vm *)calloc(1, sizeof(ecs_vm)); + if (!vm) return NULL; if (host) vm->host = *host; vm->budget = ECS_DEFAULT_BUDGET; vm->error_func = -1; vm->error_pc = -1; + vm->free_head = -1; + pool_init(vm); return vm; } @@ -856,68 +1416,87 @@ void ecs_vm_set_budget(ecs_vm *vm, int32_t steps_per_yield) if (vm && steps_per_yield > 0) vm->budget = steps_per_yield; } +void ecs_vm_set_wait_yield(ecs_vm *vm, int enable) +{ + if (vm) vm->wait_yield = enable ? 1 : 0; +} + +void ecs_vm_query_meminfo(ecs_vm_meminfo *out) +{ + if (!out) return; + out->instance_bytes = sizeof(ecs_vm) - sizeof(ecs_host); + out->host_bytes = sizeof(ecs_host); + out->arena_bytes = ECS_ARENA_BYTES; + out->frame_seg_bytes = ECS_FRAME_SEG_BYTES; + out->obj_seg_bytes = (size_t)ECS_OBJ_SEG_BYTES; + out->obj_block_bytes = ECS_OBJ_BLOCK_BYTES; + out->obj_block_count = ECS_OBJ_BLOCK_COUNT; + out->value_bytes = (uint32_t)sizeof(ecs_value); +} + void ecs_vm_error_location(const ecs_vm *vm, int32_t *out_func, int32_t *out_pc) { if (out_func) *out_func = vm ? vm->error_func : -1; if (out_pc) *out_pc = vm ? vm->error_pc : -1; } -/* 兜底 sweep 单对象:先摘除表项(阻断环/重复/悬挂子引用),再递归释放子引用后释放自身。 +/* 兜底 sweep 单对象:先摘除 in-use 位(阻断环/重复/悬挂子引用),再递归释放子引用。 不走 release/引用计数——残余对象可能互相引用且 rc 不归零(§4.2)。 */ static void sweep_obj(ecs_vm *vm, int32_t h) { - ecs_obj *o = (h >= 1 && h < vm->objs_count) ? vm->objs[h] : NULL; - if (!o) return; - vm->objs[h] = NULL; - switch (o->kind) + ecs_obj *o = (h >= 1 && h <= ECS_OBJ_BLOCK_COUNT) ? obj_at(vm, h) : NULL; + if (!o || !(o->h.flags & OBJ_IN_USE)) return; + o->h.flags = 0; + switch (o->h.kind) { - case ECS_STRING: free(o->str.units); break; + case ECS_STRING: break; + case OBJ_KIND_BOX: break; case ECS_ARRAY: - for (int32_t i = 0; i < o->arr.len; i++) - if (is_handle_tag(o->arr.items[i].tag)) - sweep_obj(vm, (int32_t)o->arr.items[i].i64); - free(o->arr.items); + arr_foreach_handle(vm, o, 1); break; case ECS_STRUCT: - for (int32_t i = 0; i < o->st.def->nslots; i++) - if (is_handle_tag(o->st.slots[i].tag)) - sweep_obj(vm, (int32_t)o->st.slots[i].i64); - free(o->st.slots); + { + const ecs_structdef *def = &vm->structs[o->h.sid]; + ecs_value *slots = (ecs_value *)OBJ_PAYLOAD(o); + for (int32_t i = 0; i < def->nslots; i++) + if (is_heap_tag(slots[i].tag)) + sweep_obj(vm, (int32_t)slots[i].u32); break; + } } - free(o); } void ecs_vm_free(ecs_vm *vm) { if (!vm) return; - /* 帧槽位先行:错误现场可能仍有活句柄,走引用计数正常回收 */ + /* 帧槽位先行:错误现场可能仍有活句柄,走引用计数正常回收(arena 属 vm,无需 free) */ for (int32_t d = 0; d < vm->depth; d++) { - frame *fr = vm->frames[d]; + frame *fr = frame_at(vm, vm->frame_off[d]); for (int32_t s = 0; s < fr->fn->nslots; s++) release(vm, fr->slots[s]); - free(fr); } - free(vm->frames); /* 全量 sweep:不依赖引用计数正确性兜底(§4.2) */ - for (int32_t h = 1; h < vm->objs_count; h++) + for (int32_t h = 1; h <= ECS_OBJ_BLOCK_COUNT; h++) sweep_obj(vm, h); - free(vm->objs); - free(vm->free_list); - for (int32_t i = 0; i < vm->nstructs; i++) - { - for (int32_t f = 0; f < vm->structs[i].nfields; f++) - free(vm->structs[i].fields[f].name); - free(vm->structs[i].fields); - free(vm->structs[i].name); - } + if (vm->structs) + for (int32_t i = 0; i < vm->nstructs; i++) + { + for (int32_t f = 0; f < vm->structs[i].nfields; f++) + free(vm->structs[i].fields[f].name); + free(vm->structs[i].fields); + free(vm->structs[i].name); + } free(vm->structs); - for (int32_t i = 0; i < vm->nnatives; i++) - free(vm->natives[i]); + if (vm->owned_img) + free((void *)vm->img); /* io 拉取路径:镜像内部副本归 VM */ + if (vm->natives) + for (int32_t i = 0; i < vm->nnatives; i++) + free(vm->natives[i]); free(vm->natives); - for (int32_t i = 0; i < vm->nfuncs; i++) - free(vm->funcs[i].name); + if (vm->funcs) + for (int32_t i = 0; i < vm->nfuncs; i++) + free(vm->funcs[i].name); free(vm->funcs); free(vm->consts); free(vm->globals); @@ -949,29 +1528,36 @@ static ecs_value do_conv(ecs_vm *vm, uint32_t kind, ecs_value v, int *err) { ecs_value r = void_value(); *err = 0; + int pe = 0; /* 装箱块耗尽 → 统一映射 *err = 2(POOL) */ switch (kind) { - case CV_INT_TO_DOUBLE: r.tag = ECS_DOUBLE; r.f64 = (double)v.i32; break; - case CV_DOUBLE_TO_INT: r.tag = ECS_INT; r.i32 = saturate_d2i(v.f64); break; - case CV_INT_TO_UINT: r.tag = ECS_UINT; r.i64 = (uint32_t)v.i32; break; + case CV_INT_TO_DOUBLE: r = box_double(vm, (double)v.i32, &pe); if (pe) *err = 2; break; + case CV_DOUBLE_TO_INT: r = make_int(saturate_d2i(box_d(vm, v))); break; + case CV_INT_TO_UINT: r.tag = ECS_UINT; r.i32 = v.i32; break; case CV_UINT_TO_INT: r.tag = ECS_INT; r.i32 = v.i32; break; case CV_INT_TO_BYTE: r.tag = ECS_BYTE; r.i32 = (int32_t)(uint8_t)v.i32; break; case CV_BOOL_TO_INT: r.tag = ECS_INT; r.i32 = v.i32; break; - case CV_INT_TO_UINT64: r.tag = ECS_UINT64; r.i64 = (int64_t)v.i32; break; - case CV_UINT_TO_UINT64: r.tag = ECS_UINT64; r.i64 = (int64_t)(uint32_t)v.i32; break; - case CV_UINT64_TO_INT: r.tag = ECS_INT; r.i32 = (int32_t)v.i64; break; - case CV_INT_TO_PTR: r.tag = ECS_PTR; r.i64 = (int64_t)v.i32; break; - case CV_PTR_TO_INT: r.tag = ECS_INT; r.i32 = (int32_t)v.i64; break; - case CV_UINT64_TO_PTR: r.tag = ECS_PTR; r.i64 = v.i64; break; - case CV_PTR_TO_UINT64: r.tag = ECS_UINT64; r.i64 = v.i64; break; - case CV_DOUBLE_TO_UINT64: r.tag = ECS_UINT64; r.i64 = saturate_d2l(v.f64); break; - case CV_UINT64_TO_DOUBLE: r.tag = ECS_DOUBLE; r.f64 = (double)(uint64_t)v.i64; break; - case CV_TOSTR: return tostring_value(vm, v); + case CV_INT_TO_UINT64: r = box_u64(vm, (int64_t)v.i32, &pe); if (pe) *err = 2; break; + case CV_UINT_TO_UINT64: r = box_u64(vm, (int64_t)(uint32_t)v.i32, &pe); if (pe) *err = 2; break; + case CV_UINT64_TO_INT: r = make_int((int32_t)box_l(vm, v)); break; + case CV_INT_TO_PTR: r = box_ptr(vm, (int64_t)v.i32, &pe); if (pe) *err = 2; break; + case CV_PTR_TO_INT: r = make_int((int32_t)box_bits(vm, v)); break; + case CV_UINT64_TO_PTR: r = box_ptr(vm, box_l(vm, v), &pe); if (pe) *err = 2; break; + case CV_PTR_TO_UINT64: r = box_u64(vm, box_l(vm, v), &pe); if (pe) *err = 2; break; + case CV_DOUBLE_TO_UINT64: r = box_u64(vm, saturate_d2l(box_d(vm, v)), &pe); if (pe) *err = 2; break; + case CV_UINT64_TO_DOUBLE: r = box_double(vm, (double)(uint64_t)box_l(vm, v), &pe); if (pe) *err = 2; break; + case CV_TOSTR: + { + int pool_full = 0; + r = tostring_value(vm, v, &pool_full); + if (pool_full) *err = 2; /* TOSTR 结果超有界缓冲 → ECS_ERR_POOL */ + return r; + } case CV_TOINT: if (v.tag == ECS_BOOL || v.tag == ECS_BYTE || v.tag == ECS_INT || v.tag == ECS_UINT) { r.tag = ECS_INT; r.i32 = v.i32; } else if (v.tag == ECS_DOUBLE) - { r.tag = ECS_INT; r.i32 = saturate_d2i(v.f64); } + { r.tag = ECS_INT; r.i32 = saturate_d2i(box_d(vm, v)); } else if (v.tag == ECS_STRING) { r.tag = ECS_INT; r.i32 = 0; } /* 字符串解析为 PC 端实现;MCU 端静默返回 0 */ else @@ -982,533 +1568,157 @@ static ecs_value do_conv(ecs_vm *vm, uint32_t kind, ecs_value v, int *err) return r; } -static int32_t sign16(uint32_t v) { return (int32_t)(int16_t)(uint16_t)v; } -static int32_t sign24(uint32_t v) { return (int32_t)((v & 0xFFFFFFu) ^ 0x800000u) - 0x800000; } - +/* 帧段 bump 分配(P2,docs/ZeroAllocVm.md §3):无碎片、O(1)、无元数据; + 深度上界由帧段容量自然给出,ECS_MAX_CALL_DEPTH 仍为先行硬上限(双端对拍 DEPTH 一致性)。 */ static int push_frame(ecs_vm *vm, const ecs_funcdef *fn, int32_t func_index) { if (vm->depth >= ECS_MAX_CALL_DEPTH) return ECS_ERR_DEPTH; - if (vm->depth >= vm->frames_cap) - { - int32_t cap = vm->frames_cap ? vm->frames_cap * 2 : 16; - frame **grown = (frame **)realloc(vm->frames, (size_t)cap * sizeof(frame *)); - if (!grown) return ECS_ERR_OOM; - vm->frames = grown; - vm->frames_cap = cap; - } - frame *fr = (frame *)malloc(sizeof(frame) + (size_t)fn->nslots * sizeof(ecs_value)); - if (!fr) return ECS_ERR_OOM; - memset(fr, 0, sizeof(frame) + (size_t)fn->nslots * sizeof(ecs_value)); + if (vm->depth >= ECS_MAX_FRAMES) + return ECS_ERR_POOL; /* 帧段满(帧表容量) */ + size_t need = frame_size(fn); + if (vm->frame_sp + need > (size_t)ECS_FRAME_SEG_BYTES) + return ECS_ERR_POOL; /* 帧段满(字节容量) */ + frame *fr = (frame *)(vm->arena + vm->frame_sp); + vm->frame_sp += need; + memset(fr, 0, need); fr->fn = fn; - fr->func_index = func_index; - fr->ret_slot = ECS_NO_SLOT; - vm->frames[vm->depth++] = fr; + fr->func_index = (uint16_t)func_index; + fr->ret_slot = ECS_REG_RET; /* 入口帧返回落点(顶层 Ret 走 Result 路径,不消费) */ + vm->frame_off[vm->depth++] = (uint32_t)(vm->frame_sp - need); return 0; } -/* - * 数据/容器/结构体/域操作指令(主循环 default 分派)。 - * 返回 0 = 成功;非 0 = 错误码(错误现场由主循环设置)。 - */ -static int exec_data_op(ecs_vm *vm, uint32_t op, ecs_value *R, - int32_t a, int32_t b, int32_t c, - frame *fr) +/* 弹帧:槽位释放后 bump 水位回退(调用/返回严格嵌套,无碎片;帧尺寸按 fn 派生回退) */ +static void pop_frame(ecs_vm *vm, frame *fr) { - const uint32_t *code = fr->fn->code; - uint32_t words = fr->fn->words; - -#define REQUIRE_EXT() do { if (!ecs_op_has_ext(op) || fr->ret_pc >= words) return ECS_ERR_OPCODE; } while (0) -#define FAIL(code) return (code) - - switch (op) - { - /* ---- 算术(S-02/S-05;无符号回绕对齐 C# unchecked) ---- */ - case OP_AddI: store_fresh(vm, &R[a], make_int((int32_t)((uint32_t)R[b].i32 + (uint32_t)R[c].i32))); break; - case OP_SubI: store_fresh(vm, &R[a], make_int((int32_t)((uint32_t)R[b].i32 - (uint32_t)R[c].i32))); break; - case OP_MulI: store_fresh(vm, &R[a], make_int((int32_t)((uint32_t)R[b].i32 * (uint32_t)R[c].i32))); break; - case OP_DivI: - { - if (R[c].i32 == 0) FAIL(ECS_ERR_DIVZERO); - store_fresh(vm, &R[a], make_int(R[c].i32 == -1 ? (int32_t)(0u - (uint32_t)R[b].i32) : R[b].i32 / R[c].i32)); - break; - } - case OP_ModI: - { - if (R[c].i32 == 0) FAIL(ECS_ERR_DIVZERO); - store_fresh(vm, &R[a], make_int(R[c].i32 == -1 ? 0 : R[b].i32 % R[c].i32)); - break; - } - case OP_RDivI: /* S-05:`(a + b/2) / b */ - { - if (R[c].i32 == 0) FAIL(ECS_ERR_DIVZERO); - int32_t num = (int32_t)((uint32_t)R[b].i32 + (uint32_t)(R[c].i32 / 2)); - store_fresh(vm, &R[a], make_int(R[c].i32 == -1 ? (int32_t)(0u - (uint32_t)num) : num / R[c].i32)); - break; - } - case OP_AddU: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT; R[a].i64 = (uint32_t)R[b].i32 + (uint32_t)R[c].i32; break; - case OP_SubU: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT; R[a].i64 = (uint32_t)R[b].i32 - (uint32_t)R[c].i32; break; - case OP_MulU: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT; R[a].i64 = (uint32_t)R[b].i32 * (uint32_t)R[c].i32; break; - case OP_DivU: - { - if ((uint32_t)R[c].i32 == 0) FAIL(ECS_ERR_DIVZERO); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT; R[a].i64 = (uint32_t)R[b].i32 / (uint32_t)R[c].i32; - break; - } - case OP_ModU: - { - if ((uint32_t)R[c].i32 == 0) FAIL(ECS_ERR_DIVZERO); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT; R[a].i64 = (uint32_t)R[b].i32 % (uint32_t)R[c].i32; - break; - } - case OP_AddL: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT64; R[a].i64 = (int64_t)((uint64_t)R[b].i64 + (uint64_t)R[c].i64); break; - case OP_SubL: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT64; R[a].i64 = (int64_t)((uint64_t)R[b].i64 - (uint64_t)R[c].i64); break; - case OP_MulL: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT64; R[a].i64 = (int64_t)((uint64_t)R[b].i64 * (uint64_t)R[c].i64); break; - case OP_DivL: - { - if (R[c].i64 == 0) FAIL(ECS_ERR_DIVZERO); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT64; - R[a].i64 = R[c].i64 == -1 ? (int64_t)(0ULL - (uint64_t)R[b].i64) : R[b].i64 / R[c].i64; - break; - } - case OP_ModL: - { - if (R[c].i64 == 0) FAIL(ECS_ERR_DIVZERO); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT64; - R[a].i64 = R[c].i64 == -1 ? 0 : R[b].i64 % R[c].i64; - break; - } - case OP_AddD: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_DOUBLE; R[a].f64 = R[b].f64 + R[c].f64; break; - case OP_SubD: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_DOUBLE; R[a].f64 = R[b].f64 - R[c].f64; break; - case OP_MulD: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_DOUBLE; R[a].f64 = R[b].f64 * R[c].f64; break; - case OP_DivD: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_DOUBLE; R[a].f64 = R[b].f64 / R[c].f64; break; - - /* ---- 位运算(S-03 掩码;左移用无符号回绕) ---- */ - case OP_BandI: store_fresh(vm, &R[a], make_int(R[b].i32 & R[c].i32)); break; - case OP_BorI: store_fresh(vm, &R[a], make_int(R[b].i32 | R[c].i32)); break; - case OP_BxorI: store_fresh(vm, &R[a], make_int(R[b].i32 ^ R[c].i32)); break; - case OP_ShlI: store_fresh(vm, &R[a], make_int((int32_t)((uint32_t)R[b].i32 << (R[c].i32 & 31)))); break; - case OP_ShrI: store_fresh(vm, &R[a], make_int(R[b].i32 >> (R[c].i32 & 31))); break; - case OP_BnotI: store_fresh(vm, &R[a], make_int((int32_t)(~(uint32_t)R[b].i32))); break; - - /* ---- 比较 ---- */ - case OP_EqI: store_fresh(vm, &R[a], make_bool(R[b].i32 == R[c].i32)); break; - case OP_LtI: store_fresh(vm, &R[a], make_bool(R[b].i32 < R[c].i32)); break; - case OP_LeI: store_fresh(vm, &R[a], make_bool(R[b].i32 <= R[c].i32)); break; - case OP_GtI: store_fresh(vm, &R[a], make_bool(R[b].i32 > R[c].i32)); break; - case OP_GeI: store_fresh(vm, &R[a], make_bool(R[b].i32 >= R[c].i32)); break; - case OP_EqU: store_fresh(vm, &R[a], make_bool((uint32_t)R[b].i32 == (uint32_t)R[c].i32)); break; - case OP_LtU: store_fresh(vm, &R[a], make_bool((uint32_t)R[b].i32 < (uint32_t)R[c].i32)); break; - case OP_LeU: store_fresh(vm, &R[a], make_bool((uint32_t)R[b].i32 <= (uint32_t)R[c].i32)); break; - case OP_GtU: store_fresh(vm, &R[a], make_bool((uint32_t)R[b].i32 > (uint32_t)R[c].i32)); break; - case OP_GeU: store_fresh(vm, &R[a], make_bool((uint32_t)R[b].i32 >= (uint32_t)R[c].i32)); break; - case OP_EqD: store_fresh(vm, &R[a], make_bool(cmp_double(R[b].f64, R[c].f64) == 0)); break; - case OP_LtD: store_fresh(vm, &R[a], make_bool(cmp_double(R[b].f64, R[c].f64) < 0)); break; - case OP_LeD: store_fresh(vm, &R[a], make_bool(cmp_double(R[b].f64, R[c].f64) <= 0)); break; - case OP_GtD: store_fresh(vm, &R[a], make_bool(cmp_double(R[b].f64, R[c].f64) > 0)); break; - case OP_GeD: store_fresh(vm, &R[a], make_bool(cmp_double(R[b].f64, R[c].f64) >= 0)); break; - case OP_EqL: store_fresh(vm, &R[a], make_bool(R[b].i64 == R[c].i64)); break; - case OP_LtL: store_fresh(vm, &R[a], make_bool(R[b].i64 < R[c].i64)); break; - case OP_LeL: store_fresh(vm, &R[a], make_bool(R[b].i64 <= R[c].i64)); break; - case OP_GtL: store_fresh(vm, &R[a], make_bool(R[b].i64 > R[c].i64)); break; - case OP_GeL: store_fresh(vm, &R[a], make_bool(R[b].i64 >= R[c].i64)); break; - case OP_EqS: - { - const uint16_t *lx, *ly; int32_t llx, lly; - str_or_null(vm, R[b], &lx, &llx); - str_or_null(vm, R[c], &ly, &lly); - store_fresh(vm, &R[a], make_bool(units_equal(lx, llx, ly, lly))); - break; - } - case OP_EqP: store_fresh(vm, &R[a], make_bool(R[b].i64 == R[c].i64)); break; - - /* ---- 一元与转换 ---- */ - case OP_Not: store_fresh(vm, &R[a], make_bool(R[b].i32 == 0)); break; - case OP_NegI: store_fresh(vm, &R[a], make_int((int32_t)(0u - (uint32_t)R[b].i32))); break; - case OP_NegD: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_DOUBLE; R[a].f64 = -R[b].f64; break; - case OP_Conv: - { - int err = 0; - ecs_value r = do_conv(vm, (uint32_t)c, R[b], &err); - if (err) FAIL(ECS_ERR_TYPE); - store_fresh(vm, &R[a], r); - break; - } - - /* ---- 数组 / 字符串 ---- */ - case OP_NewArrV: - { - REQUIRE_EXT(); - uint32_t etag = code[fr->ret_pc++]; - int32_t h = hnew_arr(vm, (uint8_t)etag, b); - if (h < 0) FAIL(ECS_ERR_OOM); - ecs_obj *o = vm->objs[h]; - for (int32_t i = 0; i < b; i++) - { - o->arr.items[i] = R[c + i]; - retain(vm, o->arr.items[i]); - } - o->arr.len = b; - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_ARRAY; R[a].i64 = h; - break; - } - case OP_NewArrE: - { - int32_t h = hnew_arr(vm, (uint8_t)(b | (c << 8)), 0); - if (h < 0) FAIL(ECS_ERR_OOM); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_ARRAY; R[a].i64 = h; - break; - } - case OP_GetI: - { - ecs_value container = R[b]; - int32_t idx = R[c].i32; - if (container.tag == ECS_STRING) - { - const uint16_t *u; int32_t len; - str_or_null(vm, container, &u, &len); - if (idx < 0 || idx >= len) FAIL(ECS_ERR_INDEX); - store_fresh(vm, &R[a], new_str_units(vm, u ? u + idx : NULL, 1)); - } - else - { - ecs_obj *arr = heap_obj(vm, (int32_t)container.i64); - if (!arr || container.tag != ECS_ARRAY) FAIL(ECS_ERR_TYPE); - if (idx < 0 || idx >= arr->arr.len) FAIL(ECS_ERR_INDEX); - /* S-10 按 elem tag 归一读 */ - ecs_value item = arr->arr.items[idx]; - if (arr->arr.etag == ECS_BYTE) { item.tag = ECS_BYTE; item.i32 &= 0xFF; } - else if (arr->arr.etag == ECS_BOOL) { item = make_bool(item.i32 != 0); } - move_to(vm, &R[a], item); - } - break; - } - case OP_SetI: - { - ecs_obj *arr = heap_obj(vm, (int32_t)R[b].i64); - if (!arr || R[b].tag != ECS_ARRAY) FAIL(ECS_ERR_TYPE); - int32_t idx = R[c].i32; - if (idx < 0 || idx >= arr->arr.len) FAIL(ECS_ERR_INDEX); - ecs_value v = R[a]; - if (arr->arr.etag == ECS_BOOL) v = make_bool(v.i32 != 0); /* CoerceWrite */ - release(vm, arr->arr.items[idx]); - arr->arr.items[idx] = v; - retain(vm, v); - break; - } - case OP_Slice: - { - REQUIRE_EXT(); - uint32_t ext = code[fr->ret_pc++]; - ecs_value container = R[b]; - int32_t start = R[c].i32; - if (container.tag == ECS_STRING) - { - const uint16_t *u; int32_t len; - str_or_null(vm, container, &u, &len); - int32_t end = ext == 0xFFFFFFFFu ? len : R[(int32_t)ext].i32; - if (start < 0 || start > len || end > len || start > end) FAIL(ECS_ERR_INDEX); - store_fresh(vm, &R[a], new_str_units(vm, u ? u + start : NULL, end - start)); - } - else - { - ecs_obj *arr = heap_obj(vm, (int32_t)container.i64); - if (!arr || container.tag != ECS_ARRAY) FAIL(ECS_ERR_TYPE); - int32_t end = ext == 0xFFFFFFFFu ? arr->arr.len : R[(int32_t)ext].i32; - if (start < 0 || start > arr->arr.len || end > arr->arr.len || start > end) FAIL(ECS_ERR_INDEX); - int32_t h = hnew_arr(vm, arr->arr.etag, end - start); - if (h < 0) FAIL(ECS_ERR_OOM); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = start; i < end; i++) - { - dst->arr.items[dst->arr.len] = arr->arr.items[i]; - retain(vm, dst->arr.items[dst->arr.len]); - dst->arr.len++; - } - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_ARRAY; R[a].i64 = h; - } - break; - } - case OP_Cont: /* S-09 */ - { - ecs_value item = R[b]; - ecs_value container = R[c]; - if (container.tag == ECS_STRING) - { - const uint16_t *hay; int32_t hlen; - str_or_null(vm, container, &hay, &hlen); - if (item.tag != ECS_STRING) FAIL(ECS_ERR_TYPE); - const uint16_t *sub; int32_t slen; - str_or_null(vm, item, &sub, &slen); - store_fresh(vm, &R[a], make_bool(units_contains(hay, hlen, sub, slen))); - } - else if (container.tag == ECS_ARRAY) - { - ecs_obj *arr = heap_obj(vm, (int32_t)container.i64); - if (!arr) FAIL(ECS_ERR_TYPE); - if (item.tag != arr->arr.etag) - store_fresh(vm, &R[a], make_bool(0)); /* 元素类型不匹配 → false */ - else - { - int found = 0; - for (int32_t i = 0; i < arr->arr.len; i++) - if (value_equals(vm, arr->arr.items[i], item)) { found = 1; break; } - store_fresh(vm, &R[a], make_bool(found)); - } - } - else - FAIL(ECS_ERR_TYPE); - break; - } - case OP_Append: - { - ecs_obj *src = heap_obj(vm, (int32_t)R[b].i64); - if (!src || R[b].tag != ECS_ARRAY) FAIL(ECS_ERR_TYPE); - int32_t h = hnew_arr(vm, src->arr.etag, src->arr.len + 1); - if (h < 0) FAIL(ECS_ERR_OOM); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = 0; i < src->arr.len; i++) - { - dst->arr.items[i] = src->arr.items[i]; - retain(vm, dst->arr.items[i]); - } - dst->arr.items[src->arr.len] = R[c]; - retain(vm, R[c]); - dst->arr.len = src->arr.len + 1; - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_ARRAY; R[a].i64 = h; - break; - } - case OP_Cat: /* S-11 */ - { - ecs_value l = R[b], r = R[c]; - if (l.tag == ECS_STRING || r.tag == ECS_STRING) - { - strbuf sb; memset(&sb, 0, sizeof(sb)); - tostring_top(vm, &sb, l); - tostring_top(vm, &sb, r); - store_fresh(vm, &R[a], new_str_units(vm, sb.units, sb.len)); - free(sb.units); - } - else if (l.tag == ECS_ARRAY && r.tag == ECS_ARRAY) - { - ecs_obj *la = heap_obj(vm, (int32_t)l.i64); - ecs_obj *ra = heap_obj(vm, (int32_t)r.i64); - if (!la || !ra) FAIL(ECS_ERR_TYPE); - if (la->arr.etag != ra->arr.etag) FAIL(ECS_ERR_TYPE); - int32_t h = hnew_arr(vm, la->arr.etag, la->arr.len + ra->arr.len); - if (h < 0) FAIL(ECS_ERR_OOM); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = 0; i < la->arr.len; i++) - { - dst->arr.items[i] = la->arr.items[i]; - retain(vm, dst->arr.items[i]); - } - for (int32_t i = 0; i < ra->arr.len; i++) - { - dst->arr.items[la->arr.len + i] = ra->arr.items[i]; - retain(vm, dst->arr.items[la->arr.len + i]); - } - dst->arr.len = la->arr.len + ra->arr.len; - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_ARRAY; R[a].i64 = h; - } - else - FAIL(ECS_ERR_TYPE); - break; - } - case OP_Len: - { - if (R[b].tag == ECS_STRING) - { - const uint16_t *u; int32_t len; - str_or_null(vm, R[b], &u, &len); - store_fresh(vm, &R[a], make_int(len)); - } - else if (R[b].tag == ECS_ARRAY) - { - ecs_obj *arr = heap_obj(vm, (int32_t)R[b].i64); - if (!arr) FAIL(ECS_ERR_TYPE); - store_fresh(vm, &R[a], make_int(arr->arr.len)); - } - else FAIL(ECS_ERR_TYPE); - break; - } + for (int32_t s = 0; s < fr->fn->nslots; s++) + if (is_heap_tag(fr->slots[s].tag)) /* 标量槽免调用快径(对标量 release 本为 no-op) */ + release(vm, fr->slots[s]); + vm->frame_sp -= frame_size(fr->fn); + vm->depth--; +} - /* ---- 结构体 ---- */ - case OP_NewSt: - { - int32_t sid = b | (c << 8); - if (sid >= vm->nstructs) FAIL(ECS_ERR_SLOT); - int32_t h = hnew_st(vm, &vm->structs[sid]); - if (h < 0) FAIL(ECS_ERR_OOM); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_STRUCT; R[a].i64 = h; - break; - } - case OP_GetF: - { - ecs_obj *st = heap_obj(vm, (int32_t)R[b].i64); - if (!st || R[b].tag != ECS_STRUCT) FAIL(ECS_ERR_TYPE); - if (c >= st->st.def->nfields) FAIL(ECS_ERR_SLOT); - const ecs_fielddef *f = &st->st.def->fields[c]; - switch (f->kind) - { - case 0: /* Scalar */ - if (f->type == ECS_BYTE) { store_fresh(vm, &R[a], st->st.slots[f->slot_offset]); R[a].tag = ECS_BYTE; R[a].i32 &= 0xFF; } - else if (f->type == ECS_BOOL) store_fresh(vm, &R[a], make_bool(st->st.slots[f->slot_offset].i32 != 0)); - else { move_to(vm, &R[a], st->st.slots[f->slot_offset]); } - break; - case FIELD_KIND_FIXED_ARRAY: - { - int32_t h = hnew_arr(vm, f->elem, f->ext); - if (h < 0) FAIL(ECS_ERR_OOM); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = 0; i < f->ext; i++) - { - dst->arr.items[i] = st->st.slots[f->slot_offset + i]; - retain(vm, dst->arr.items[i]); - } - dst->arr.len = f->ext; - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_ARRAY; R[a].i64 = h; - break; - } - case FIELD_KIND_NESTED: - { - const ecs_structdef *nested = &vm->structs[f->ext]; - int32_t h = hnew_st(vm, nested); - if (h < 0) FAIL(ECS_ERR_OOM); - ecs_obj *dst = vm->objs[h]; - for (int32_t i = 0; i < nested->nslots; i++) - { - dst->st.slots[i] = st->st.slots[f->slot_offset + i]; - retain(vm, dst->st.slots[i]); - } - /* 写穿透视图(对齐 v1 GetNested / 解释器 ViewParent):记录父链, - 后续槽写入经 sync_view 逐级回写父槽区 */ - dst->st.view_parent = (int32_t)R[b].i64; - dst->st.view_offset = f->slot_offset; - retain(vm, R[b]); - store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_STRUCT; R[a].i64 = h; - break; - } - default: FAIL(ECS_ERR_TYPE); /* Boxed 不受支持(S-16) */ - } - break; - } - case OP_PutF: - { - ecs_obj *st = heap_obj(vm, (int32_t)R[b].i64); - if (!st || R[b].tag != ECS_STRUCT) FAIL(ECS_ERR_TYPE); - if (c >= st->st.def->nfields) FAIL(ECS_ERR_SLOT); - const ecs_fielddef *f = &st->st.def->fields[c]; - if (f->kind == 0) - { - ecs_value v = R[a]; - if (f->type == ECS_BOOL) v = make_bool(v.i32 != 0); - move_to(vm, &st->st.slots[f->slot_offset], v); - if (st->st.view_parent) sync_view(vm, st); - } - else if (f->kind == FIELD_KIND_NESTED) - { - ecs_obj *src = heap_obj(vm, (int32_t)R[a].i64); - if (!src || R[a].tag != ECS_STRUCT) FAIL(ECS_ERR_TYPE); - /* 对齐校验:src 槽数 vs 字段嵌套类型槽数(与 GetF 取嵌套布局一致) */ - const ecs_structdef *nested_put = &vm->structs[f->ext]; - if (src->st.def->nslots != nested_put->nslots) FAIL(ECS_ERR_TYPE); - for (int32_t i = 0; i < src->st.def->nslots; i++) - { - release(vm, st->st.slots[f->slot_offset + i]); - st->st.slots[f->slot_offset + i] = src->st.slots[i]; - retain(vm, st->st.slots[f->slot_offset + i]); - } - if (st->st.view_parent) sync_view(vm, st); - } - else FAIL(ECS_ERR_TYPE); - break; - } - case OP_GetFI: - { - REQUIRE_EXT(); - uint32_t ext = code[fr->ret_pc++]; - ecs_obj *st = heap_obj(vm, (int32_t)R[b].i64); - if (!st || R[b].tag != ECS_STRUCT) FAIL(ECS_ERR_TYPE); - if (c >= st->st.def->nfields) FAIL(ECS_ERR_SLOT); - const ecs_fielddef *f = &st->st.def->fields[c]; - if (f->kind != FIELD_KIND_FIXED_ARRAY) FAIL(ECS_ERR_TYPE); - int32_t idx = R[(int32_t)ext].i32; - if (idx < 0 || idx >= f->ext) FAIL(ECS_ERR_INDEX); - ecs_value item = st->st.slots[f->slot_offset + idx]; - if (f->type == ECS_BYTE) { item.tag = ECS_BYTE; item.i32 &= 0xFF; } - else if (f->type == ECS_BOOL) item = make_bool(item.i32 != 0); - move_to(vm, &R[a], item); - break; - } - case OP_PutFI: - { - REQUIRE_EXT(); - uint32_t ext = code[fr->ret_pc++]; - ecs_obj *st = heap_obj(vm, (int32_t)R[b].i64); - if (!st || R[b].tag != ECS_STRUCT) FAIL(ECS_ERR_TYPE); - if (c >= st->st.def->nfields) FAIL(ECS_ERR_SLOT); - const ecs_fielddef *f = &st->st.def->fields[c]; - if (f->kind != FIELD_KIND_FIXED_ARRAY) FAIL(ECS_ERR_TYPE); - int32_t idx = R[(int32_t)ext].i32; - if (idx < 0 || idx >= f->ext) FAIL(ECS_ERR_INDEX); - ecs_value v = R[a]; - if (f->type == ECS_BOOL) v = make_bool(v.i32 != 0); - release(vm, st->st.slots[f->slot_offset + idx]); - st->st.slots[f->slot_offset + idx] = v; - retain(vm, v); - if (st->st.view_parent) sync_view(vm, st); - break; - } +/* ============================================================ + * §4.5 主派发(直接线程化 / switch 双模)—— 干净重写版 + * + * gcc/clang:computed goto——每操作码一个标签,dispatch 表按操作码值索引, + * 取指预检后即跳;handler 按自身格式特化取操作数(FETCH_OPS_*,无二级格式 switch)。 + * 其余编译器(MSVC 等):宏回退 switch 循环(同一份 handler 体,VM_CASE=case, + * 操作数由 VM_FETCH 统一解码,FETCH_OPS_* 为空)。 + * + * 数据操作已自 exec_data_op 扁平化并入本派发(消除每条数据指令的函数调用)。 + * 信任字节码:handler 内除语义必需(除零/越界/类型等脚本可见错误)外无验证。 + * ============================================================ */ +#if defined(__GNUC__) && !defined(ECS_NO_DIRECT_THREADING) +#define ECS_DIRECT_THREADED 1 +#else +#define ECS_DIRECT_THREADED 0 +#endif - /* ---- 域操作 ---- */ - case OP_WaitI: if (vm->host.wait_ms) vm->host.wait_ms(vm->host.ud, b | (c << 8)); break; - case OP_WaitV: if (vm->host.wait_ms) vm->host.wait_ms(vm->host.ud, R[a].i32); break; - case OP_KeyI: if (vm->host.key) vm->host.key(vm->host.ud, (uint8_t)a, b | (c << 8)); break; - case OP_KeyV: if (vm->host.key) vm->host.key(vm->host.ud, (uint8_t)a, R[b].i32); break; - case OP_KeySt: if (vm->host.key_state) vm->host.key_state(vm->host.ud, (uint8_t)a, b); break; - case OP_StickSet: if (vm->host.stick_set) vm->host.stick_set(vm->host.ud, (uint8_t)a, b, c); break; - case OP_StickP: - { - REQUIRE_EXT(); - uint32_t ext = code[fr->ret_pc++]; - if (vm->host.stick_click) - vm->host.stick_click(vm->host.ud, (uint8_t)a, b, c, (int32_t)ext); - break; - } - case OP_StickPv: - { - REQUIRE_EXT(); - uint32_t ext = code[fr->ret_pc++]; - if (vm->host.stick_click) - vm->host.stick_click(vm->host.ud, (uint8_t)a, (int32_t)(ext & 0xFF), (int32_t)((ext >> 16) & 0xFF), R[c].i32); - break; - } - case OP_Img: - /* 单片机约束:图像标签镜像已在加载期拒绝(NeedIL → ECS_ERR_IL); - 运行期兜底防御(镜像标志被篡改的场景) */ - FAIL(ECS_ERR_IL); - case OP_Rand: /* S-12 */ - { - int32_t max = R[b].i32; - if (max < 0) FAIL(ECS_ERR_INDEX); - int32_t v = 0; - if (max != 0 && vm->host.rand) v = vm->host.rand(vm->host.ud, max); - store_fresh(vm, &R[a], make_int(v)); - break; - } +#if !ECS_DIRECT_THREADED +/* ---- 回退模式专用:格式类表 + 统一取指宏 ---- + * 类别:ABC=三槽位;ABX=A+Bx16;SBXI=A+sBx16(LoadI);AJ=A+跳转 s16(Jpt/Jpf); + * J=跳转 s24(Jmp);WJ=iABC+跳转字(ForStep/CmpJ,跳转字延迟读);WEXT=iABC+数据字。 + * 未列操作码 = FMT_ABC(多数算术/比较/容器族)。与 C# EcsFormat 表逐项对齐。 */ +enum { FMT_ABC, FMT_ABX, FMT_SBXI, FMT_AJ, FMT_J, FMT_WJ, FMT_WEXT }; +static const uint8_t ecs_op_fmt[] = { + [OP_LoadI] = FMT_SBXI, + [OP_LoadK] = FMT_ABX, [OP_LoadG] = FMT_ABX, [OP_StoreG] = FMT_ABX, + [OP_NewSt] = FMT_ABX, [OP_NewArrE] = FMT_ABX, [OP_Img] = FMT_ABX, + [OP_Jmp] = FMT_J, [OP_Jpt] = FMT_AJ, [OP_Jpf] = FMT_AJ, + [OP_Call] = FMT_WEXT, [OP_CallN] = FMT_WEXT, [OP_NewArrV] = FMT_WEXT, + [OP_Slice] = FMT_WEXT, [OP_GetFI] = FMT_WEXT, [OP_PutFI] = FMT_WEXT, + [OP_WaitI] = FMT_WEXT, [OP_KeyI] = FMT_WEXT, + [OP_StickP] = FMT_WEXT, [OP_StickPv] = FMT_WEXT, + [OP_ForStep] = FMT_WJ, [OP_CmpJ] = FMT_WJ, +}; - default: - FAIL(ECS_ERR_OPCODE); +#define VM_FETCH() do { \ + if (vm->cancel_flag) { vm->steps = steps; fr->ret_pc = pc; fr->ret_idx = idx; return ECS_CANCELLED; } \ + if (++steps >= vm->budget) { vm->steps = 0; fr->ret_pc = pc; fr->ret_idx = idx; return ECS_YIELD; } \ + if (pc >= cend) \ + { \ + vm->steps = steps; \ + vm->error_func = fr->func_index; \ + vm->error_pc = (int32_t)idx; \ + return ECS_ERR_OPCODE; \ + } \ + opv = codebuf[pc]; \ + fmt = ecs_op_fmt[opv]; \ + op_size = (fmt >= FMT_WJ) ? 8 : 4; \ + if (pc + op_size > cend) \ + { \ + vm->steps = steps; \ + vm->error_func = fr->func_index; \ + vm->error_pc = (int32_t)idx; \ + return ECS_ERR_OPCODE; \ + } \ + ext_ = 0; \ + jmp_ = 0; \ + switch (fmt) \ + { \ + case FMT_ABC: \ + a_ = codebuf[pc + 1]; b_ = codebuf[pc + 2]; c_ = codebuf[pc + 3]; \ + break; \ + case FMT_ABX: \ + a_ = codebuf[pc + 1]; b_ = codebuf[pc + 2] | codebuf[pc + 3] << 8; c_ = 0; \ + break; \ + case FMT_SBXI: \ + a_ = codebuf[pc + 1]; b_ = (int16_t)(codebuf[pc + 2] | codebuf[pc + 3] << 8); c_ = 0; \ + break; \ + case FMT_AJ: \ + a_ = codebuf[pc + 1]; jmp_ = (int16_t)(codebuf[pc + 2] | codebuf[pc + 3] << 8); b_ = c_ = 0; \ + break; \ + case FMT_J: \ + jmp_ = (int32_t)((codebuf[pc + 1] | codebuf[pc + 2] << 8 | codebuf[pc + 3] << 16) << 8) >> 8; \ + a_ = b_ = c_ = 0; \ + break; \ + case FMT_WJ: \ + a_ = codebuf[pc + 1]; b_ = codebuf[pc + 2]; c_ = codebuf[pc + 3]; \ + break; /* 跳转字延迟读取:仅转移成立时才碰(热环出口多数不成立) */\ + default: /* FMT_WEXT */ \ + a_ = codebuf[pc + 1]; b_ = codebuf[pc + 2]; c_ = codebuf[pc + 3]; \ + ext_ = rd_u32(codebuf + pc + 4); \ + break; \ + } \ + op_start = pc; \ + op_index = idx; \ + idx++; \ + pc += op_size; \ +} while (0) +#endif /* !ECS_DIRECT_THREADED */ + +/* ---- CmpJ 比较辅助:kind = typeBlock*6 + op(0=Eq,1=Neq,2=Lt,3=Le,4=Gt,5=Ge) ---- */ +static inline uint32_t kind_block(int32_t kind) { return (uint32_t)kind / 6u; } +static inline int cmp_kind6_i32(int32_t l, int32_t r, int32_t kind) +{ + switch (kind % 6) { + case 0: return l == r; case 1: return l != r; case 2: return l < r; + case 3: return l <= r; case 4: return l > r; default: return l >= r; + } +} +static inline int cmp_kind6_u32(uint32_t l, uint32_t r, int32_t kind) +{ + switch (kind % 6) { + case 0: return l == r; case 1: return l != r; case 2: return l < r; + case 3: return l <= r; case 4: return l > r; default: return l >= r; + } +} +static inline int cmp_kind6_f64(double l, double r, int32_t kind) +{ + switch (kind % 6) { /* NaN → 不成立(与 C# cmp_double 语义对齐) */ + case 0: return l == r; case 1: return l != r; case 2: return l < r; + case 3: return l <= r; case 4: return l > r; default: return l >= r; + } +} +static inline int cmp_kind6_i64(int64_t l, int64_t r, int32_t kind) +{ + switch (kind % 6) { + case 0: return l == r; case 1: return l != r; case 2: return l < r; + case 3: return l <= r; case 4: return l > r; default: return l >= r; } - return 0; -#undef FAIL -#undef REQUIRE_EXT } - -/* ============================================================ - * §4.3 主循环 - * ============================================================ */ int ecs_vm_run(ecs_vm *vm) { @@ -1519,202 +1729,1139 @@ int ecs_vm_run(ecs_vm *vm) if (rc != 0) return rc; } + /* 取指通路全部局部化:跨指令存活,换帧仅 Call/Ret */ + frame *fr = frame_at(vm, vm->frame_off[vm->depth - 1]); + const uint8_t *codebuf = fr->fn->code; + uint32_t cend = fr->fn->code_bytes; + ecs_value *R = fr->slots; + uint32_t pc = fr->ret_pc; + uint32_t idx = fr->ret_idx; + int32_t steps = vm->steps; + uint32_t opv = 0, op_start = 0, op_size = 4, op_index = 0; + int32_t a_ = 0, b_ = 0, c_ = 0, jmp_ = 0; + uint32_t ext_ = 0; +#if !ECS_DIRECT_THREADED + uint8_t fmt = FMT_ABC; +#endif + +#if ECS_DIRECT_THREADED + /* 派发表:标签地址按操作码值索引(computed goto,gcc/clang 扩展) */ + static const void *const dispatch[OP_CmpJ + 1] = { + [OP_Nop] = &&L_OP_Nop, [OP_Halt] = &&L_OP_Halt, + [OP_LoadI] = &&L_OP_LoadI, [OP_LoadK] = &&L_OP_LoadK, [OP_LoadBool] = &&L_OP_LoadBool, + [OP_Move] = &&L_OP_Move, [OP_SetVar] = &&L_OP_SetVar, + [OP_LoadG] = &&L_OP_LoadG, [OP_StoreG] = &&L_OP_StoreG, + [OP_AddI] = &&L_OP_AddI, [OP_SubI] = &&L_OP_SubI, [OP_MulI] = &&L_OP_MulI, + [OP_DivI] = &&L_OP_DivI, [OP_ModI] = &&L_OP_ModI, [OP_RDivI] = &&L_OP_RDivI, + [OP_AddU] = &&L_OP_AddU, [OP_SubU] = &&L_OP_SubU, [OP_MulU] = &&L_OP_MulU, + [OP_DivU] = &&L_OP_DivU, [OP_ModU] = &&L_OP_ModU, + [OP_AddL] = &&L_OP_AddL, [OP_SubL] = &&L_OP_SubL, [OP_MulL] = &&L_OP_MulL, + [OP_DivL] = &&L_OP_DivL, [OP_ModL] = &&L_OP_ModL, + [OP_AddD] = &&L_OP_AddD, [OP_SubD] = &&L_OP_SubD, [OP_MulD] = &&L_OP_MulD, [OP_DivD] = &&L_OP_DivD, + [OP_BandI] = &&L_OP_BandI, [OP_BorI] = &&L_OP_BorI, [OP_BxorI] = &&L_OP_BxorI, + [OP_ShlI] = &&L_OP_ShlI, [OP_ShrI] = &&L_OP_ShrI, [OP_BnotI] = &&L_OP_BnotI, + [OP_EqI] = &&L_OP_EqI, [OP_LtI] = &&L_OP_LtI, [OP_LeI] = &&L_OP_LeI, [OP_GtI] = &&L_OP_GtI, [OP_GeI] = &&L_OP_GeI, + [OP_EqU] = &&L_OP_EqU, [OP_LtU] = &&L_OP_LtU, [OP_LeU] = &&L_OP_LeU, [OP_GtU] = &&L_OP_GtU, [OP_GeU] = &&L_OP_GeU, + [OP_EqD] = &&L_OP_EqD, [OP_LtD] = &&L_OP_LtD, [OP_LeD] = &&L_OP_LeD, [OP_GtD] = &&L_OP_GtD, [OP_GeD] = &&L_OP_GeD, + [OP_EqL] = &&L_OP_EqL, [OP_LtL] = &&L_OP_LtL, [OP_LeL] = &&L_OP_LeL, [OP_GtL] = &&L_OP_GtL, [OP_GeL] = &&L_OP_GeL, + [OP_EqS] = &&L_OP_EqS, [OP_EqP] = &&L_OP_EqP, + [OP_Not] = &&L_OP_Not, [OP_NegI] = &&L_OP_NegI, [OP_NegD] = &&L_OP_NegD, [OP_Conv] = &&L_OP_Conv, + [OP_Jmp] = &&L_OP_Jmp, [OP_Jpt] = &&L_OP_Jpt, [OP_Jpf] = &&L_OP_Jpf, + [OP_Call] = &&L_OP_Call, [OP_CallN] = &&L_OP_CallN, [OP_Ret] = &&L_OP_Ret, [OP_Ret0] = &&L_OP_Ret0, + [OP_NewArrV] = &&L_OP_NewArrV, [OP_NewArrE] = &&L_OP_NewArrE, + [OP_GetI] = &&L_OP_GetI, [OP_SetI] = &&L_OP_SetI, [OP_Slice] = &&L_OP_Slice, + [OP_Cont] = &&L_OP_Cont, [OP_Append] = &&L_OP_Append, [OP_Cat] = &&L_OP_Cat, [OP_Len] = &&L_OP_Len, + [OP_NewSt] = &&L_OP_NewSt, [OP_GetF] = &&L_OP_GetF, [OP_PutF] = &&L_OP_PutF, + [OP_GetFI] = &&L_OP_GetFI, [OP_PutFI] = &&L_OP_PutFI, + [OP_WaitI] = &&L_OP_WaitI, [OP_WaitV] = &&L_OP_WaitV, + [OP_KeyI] = &&L_OP_KeyI, [OP_KeyV] = &&L_OP_KeyV, [OP_KeySt] = &&L_OP_KeySt, + [OP_StickSet] = &&L_OP_StickSet, [OP_StickP] = &&L_OP_StickP, [OP_StickPv] = &&L_OP_StickPv, + [OP_Img] = &&L_OP_Img, [OP_Rand] = &&L_OP_Rand, + [OP_ForStep] = &&L_OP_ForStep, [OP_CmpJ] = &&L_OP_CmpJ, + }; + /* 取指(线程化):取消/预算/边界预检 + 操作码与尺寸;操作数由 handler 特化取 */ +#define VM_FETCH_TH() do { \ + if (vm->cancel_flag) { vm->steps = steps; fr->ret_pc = pc; fr->ret_idx = idx; return ECS_CANCELLED; } \ + if (++steps >= vm->budget) { vm->steps = 0; fr->ret_pc = pc; fr->ret_idx = idx; return ECS_YIELD; } \ + if (pc >= cend) { vm->steps = steps; vm->error_func = fr->func_index; vm->error_pc = (int32_t)idx; return ECS_ERR_OPCODE; } \ + opv = codebuf[pc]; \ + op_size = (uint32_t)ECS_OP_WORDS(opv) * 4u; \ + if (opv > OP_CmpJ || pc + op_size > cend) { vm->steps = steps; vm->error_func = fr->func_index; vm->error_pc = (int32_t)idx; return ECS_ERR_OPCODE; } \ + op_start = pc; op_index = idx; idx++; pc += op_size; \ +} while (0) +#define VM_CASE(op) L_##op: +#define VM_NEXT() do { VM_FETCH_TH(); goto *dispatch[opv]; } while (0) +#else +#define VM_CASE(op) case op: +#define VM_NEXT() break; +#endif + + /* 错误现场统一出口(脚本可见错误:除零/越界/类型等) */ +#define VM_FAIL(code) do { vm->steps = steps; vm->error_func = fr->func_index; vm->error_pc = (int32_t)op_index; return (code); } while (0) + + /* 操作数取指(handler 按自身格式特化;回退模式由 VM_FETCH 统一解码,此处为空) */ +#if ECS_DIRECT_THREADED +#define FETCH_OPS_ABC() do { a_ = codebuf[op_start + 1]; b_ = codebuf[op_start + 2]; c_ = codebuf[op_start + 3]; } while (0) +#define FETCH_OPS_ABX() do { a_ = codebuf[op_start + 1]; b_ = codebuf[op_start + 2] | codebuf[op_start + 3] << 8; c_ = 0; } while (0) +#define FETCH_OPS_SBXI() do { a_ = codebuf[op_start + 1]; b_ = (int16_t)(codebuf[op_start + 2] | codebuf[op_start + 3] << 8); c_ = 0; } while (0) +#define FETCH_OPS_AJ() do { a_ = codebuf[op_start + 1]; jmp_ = (int16_t)(codebuf[op_start + 2] | codebuf[op_start + 3] << 8); b_ = c_ = 0; } while (0) +#define FETCH_OPS_J() do { jmp_ = (int32_t)((codebuf[op_start + 1] | codebuf[op_start + 2] << 8 | codebuf[op_start + 3] << 16) << 8) >> 8; a_ = b_ = c_ = 0; } while (0) +#define FETCH_OPS_WJ() do { a_ = codebuf[op_start + 1]; b_ = codebuf[op_start + 2]; c_ = codebuf[op_start + 3]; } while (0) +#define FETCH_OPS_WEXT() do { a_ = codebuf[op_start + 1]; b_ = codebuf[op_start + 2]; c_ = codebuf[op_start + 3]; ext_ = rd_u32(codebuf + op_start + 4); } while (0) +#else +#define FETCH_OPS_ABC() do { } while (0) +#define FETCH_OPS_ABX() do { } while (0) +#define FETCH_OPS_SBXI() do { } while (0) +#define FETCH_OPS_AJ() do { } while (0) +#define FETCH_OPS_J() do { } while (0) +#define FETCH_OPS_WJ() do { } while (0) +#define FETCH_OPS_WEXT() do { } while (0) +#endif + +#if ECS_DIRECT_THREADED + VM_NEXT(); +#else for (;;) { - if (vm->cancel_flag) return ECS_CANCELLED; - if (++vm->steps >= vm->budget) + VM_FETCH(); + switch (opv) + { +#endif + +/* ---- 杂项 ---- */ +VM_CASE(OP_Nop) +{ + FETCH_OPS_ABC(); + VM_NEXT(); +} +VM_CASE(OP_Halt) +{ + vm->steps = steps; + vm->error_func = fr->func_index; + vm->error_pc = (int32_t)op_index; + return ECS_OK; +} + +/* ---- 常量与移动 ---- */ +VM_CASE(OP_LoadI) +{ + FETCH_OPS_SBXI(); + store_fresh(vm, &R[a_], make_int(b_)); + VM_NEXT(); +} +VM_CASE(OP_LoadK) +{ + FETCH_OPS_ABX(); + const_ent *k = &vm->consts[b_]; + switch (k->tag) + { + /* S-20:常量串 → pinned(零拷贝引用镜像、零分配、不参与 RC) */ + case ECS_STRING: store_fresh(vm, &R[a_], make_static_str(b_)); break; + case ECS_UINT: store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_UINT; R[a_].u32 = (uint32_t)k->i64; break; + case ECS_DOUBLE: case ECS_UINT64: case ECS_PTR: { - vm->steps = 0; - return ECS_YIELD; /* 可再次 Run 继续 */ + /* 宽常量装箱:LoadK 自此有界失败(块耗尽 → POOL) */ + int perr = 0; + ecs_value v = (k->tag == ECS_DOUBLE) ? box_double(vm, k->f64, &perr) + : (k->tag == ECS_UINT64) ? box_u64(vm, k->i64, &perr) + : box_ptr(vm, k->i64, &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], v); + break; } + default: store_fresh(vm, &R[a_], make_int((int32_t)k->i64)); break; + } + VM_NEXT(); +} +VM_CASE(OP_LoadBool) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(b_ != 0)); + VM_NEXT(); +} +VM_CASE(OP_Move) +{ + FETCH_OPS_ABC(); + move_to(vm, &R[a_], R[b_]); + VM_NEXT(); +} +VM_CASE(OP_SetVar) +{ + FETCH_OPS_ABC(); + ecs_value copied = deep_copy_cow(vm, R[b_]); + if (R[b_].tag != ECS_VOID && copied.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], copied); /* S-01 + COW;出生引用直写 */ + VM_NEXT(); +} +VM_CASE(OP_LoadG) +{ + FETCH_OPS_ABX(); + move_to(vm, &R[a_], vm->globals[b_]); + VM_NEXT(); +} +VM_CASE(OP_StoreG) +{ + FETCH_OPS_ABX(); + ecs_value copied = deep_copy_cow(vm, R[a_]); + if (R[a_].tag != ECS_VOID && copied.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &vm->globals[b_], copied); /* COW;出生引用直写 */ + VM_NEXT(); +} - frame *fr = vm->frames[vm->depth - 1]; - const uint32_t *code = fr->fn->code; - uint32_t words = fr->fn->words; - if (fr->ret_pc >= words) +/* ---- 算术(S-02/S-05;无符号回绕对齐 C# unchecked) ---- */ +VM_CASE(OP_AddI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int((int32_t)((uint32_t)R[b_].i32 + (uint32_t)R[c_].i32))); + VM_NEXT(); +} +VM_CASE(OP_SubI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int((int32_t)((uint32_t)R[b_].i32 - (uint32_t)R[c_].i32))); + VM_NEXT(); +} +VM_CASE(OP_MulI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int((int32_t)((uint32_t)R[b_].i32 * (uint32_t)R[c_].i32))); + VM_NEXT(); +} +VM_CASE(OP_DivI) +{ + FETCH_OPS_ABC(); + if (R[c_].i32 == 0) VM_FAIL(ECS_ERR_DIVZERO); + store_fresh(vm, &R[a_], make_int(R[c_].i32 == -1 ? (int32_t)(0u - (uint32_t)R[b_].i32) : R[b_].i32 / R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_ModI) +{ + FETCH_OPS_ABC(); + if (R[c_].i32 == 0) VM_FAIL(ECS_ERR_DIVZERO); + store_fresh(vm, &R[a_], make_int(R[c_].i32 == -1 ? 0 : R[b_].i32 % R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_RDivI) /* S-05:`(a + b/2) / b` */ +{ + FETCH_OPS_ABC(); + if (R[c_].i32 == 0) VM_FAIL(ECS_ERR_DIVZERO); + int32_t num = (int32_t)((uint32_t)R[b_].i32 + (uint32_t)(R[c_].i32 / 2)); + store_fresh(vm, &R[a_], make_int(R[c_].i32 == -1 ? (int32_t)(0u - (uint32_t)num) : num / R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_AddU) +{ + FETCH_OPS_ABC(); + ecs_value r = void_value(); r.tag = ECS_UINT; r.u32 = R[b_].u32 + R[c_].u32; + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_SubU) +{ + FETCH_OPS_ABC(); + ecs_value r = void_value(); r.tag = ECS_UINT; r.u32 = R[b_].u32 - R[c_].u32; + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_MulU) +{ + FETCH_OPS_ABC(); + ecs_value r = void_value(); r.tag = ECS_UINT; r.u32 = R[b_].u32 * R[c_].u32; + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_DivU) +{ + FETCH_OPS_ABC(); + if ((uint32_t)R[c_].i32 == 0) VM_FAIL(ECS_ERR_DIVZERO); + ecs_value r = void_value(); r.tag = ECS_UINT; r.u32 = R[b_].u32 / R[c_].u32; + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_ModU) +{ + FETCH_OPS_ABC(); + if ((uint32_t)R[c_].i32 == 0) VM_FAIL(ECS_ERR_DIVZERO); + ecs_value r = void_value(); r.tag = ECS_UINT; r.u32 = R[b_].u32 % R[c_].u32; + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_AddL) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_u64(vm, (int64_t)((uint64_t)box_l(vm, R[b_]) + (uint64_t)box_l(vm, R[c_])), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_SubL) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_u64(vm, (int64_t)((uint64_t)box_l(vm, R[b_]) - (uint64_t)box_l(vm, R[c_])), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_MulL) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_u64(vm, (int64_t)((uint64_t)box_l(vm, R[b_]) * (uint64_t)box_l(vm, R[c_])), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_DivL) +{ + FETCH_OPS_ABC(); + int64_t l = box_l(vm, R[b_]), c = box_l(vm, R[c_]); + if (c == 0) VM_FAIL(ECS_ERR_DIVZERO); + int perr = 0; + ecs_value r = box_u64(vm, c == -1 ? (int64_t)(0ULL - (uint64_t)l) : l / c, &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_ModL) +{ + FETCH_OPS_ABC(); + int64_t l = box_l(vm, R[b_]), c = box_l(vm, R[c_]); + if (c == 0) VM_FAIL(ECS_ERR_DIVZERO); + int perr = 0; + ecs_value r = box_u64(vm, c == -1 ? 0 : l % c, &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_AddD) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_double(vm, box_d(vm, R[b_]) + box_d(vm, R[c_]), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_SubD) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_double(vm, box_d(vm, R[b_]) - box_d(vm, R[c_]), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_MulD) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_double(vm, box_d(vm, R[b_]) * box_d(vm, R[c_]), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_DivD) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_double(vm, box_d(vm, R[b_]) / box_d(vm, R[c_]), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} + +/* ---- 位运算(S-03 掩码;左移用无符号回绕) ---- */ +VM_CASE(OP_BandI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int(R[b_].i32 & R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_BorI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int(R[b_].i32 | R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_BxorI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int(R[b_].i32 ^ R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_ShlI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int((int32_t)((uint32_t)R[b_].i32 << (R[c_].i32 & 31)))); + VM_NEXT(); +} +VM_CASE(OP_ShrI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int(R[b_].i32 >> (R[c_].i32 & 31))); + VM_NEXT(); +} +VM_CASE(OP_BnotI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int((int32_t)(~(uint32_t)R[b_].i32))); + VM_NEXT(); +} + +/* ---- 比较 ---- */ +VM_CASE(OP_EqI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(R[b_].i32 == R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_LtI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(R[b_].i32 < R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_LeI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(R[b_].i32 <= R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_GtI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(R[b_].i32 > R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_GeI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(R[b_].i32 >= R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_EqU) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool((uint32_t)R[b_].i32 == (uint32_t)R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_LtU) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool((uint32_t)R[b_].i32 < (uint32_t)R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_LeU) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool((uint32_t)R[b_].i32 <= (uint32_t)R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_GtU) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool((uint32_t)R[b_].i32 > (uint32_t)R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_GeU) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool((uint32_t)R[b_].i32 >= (uint32_t)R[c_].i32)); + VM_NEXT(); +} +VM_CASE(OP_EqD) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(cmp_double(box_d(vm, R[b_]), box_d(vm, R[c_])) == 0)); + VM_NEXT(); +} +VM_CASE(OP_LtD) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(cmp_double(box_d(vm, R[b_]), box_d(vm, R[c_])) < 0)); + VM_NEXT(); +} +VM_CASE(OP_LeD) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(cmp_double(box_d(vm, R[b_]), box_d(vm, R[c_])) <= 0)); + VM_NEXT(); +} +VM_CASE(OP_GtD) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(cmp_double(box_d(vm, R[b_]), box_d(vm, R[c_])) > 0)); + VM_NEXT(); +} +VM_CASE(OP_GeD) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(cmp_double(box_d(vm, R[b_]), box_d(vm, R[c_])) >= 0)); + VM_NEXT(); +} +VM_CASE(OP_EqL) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(box_l(vm, R[b_]) == box_l(vm, R[c_]))); + VM_NEXT(); +} +VM_CASE(OP_LtL) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(box_l(vm, R[b_]) < box_l(vm, R[c_]))); + VM_NEXT(); +} +VM_CASE(OP_LeL) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(box_l(vm, R[b_]) <= box_l(vm, R[c_]))); + VM_NEXT(); +} +VM_CASE(OP_GtL) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(box_l(vm, R[b_]) > box_l(vm, R[c_]))); + VM_NEXT(); +} +VM_CASE(OP_GeL) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(box_l(vm, R[b_]) >= box_l(vm, R[c_]))); + VM_NEXT(); +} +VM_CASE(OP_EqS) +{ + FETCH_OPS_ABC(); + const uint16_t *lx, *ly; int32_t llx, lly; + str_or_null(vm, R[b_], &lx, &llx); + str_or_null(vm, R[c_], &ly, &lly); + store_fresh(vm, &R[a_], make_bool(units_equal(lx, llx, ly, lly))); + VM_NEXT(); +} +VM_CASE(OP_EqP) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(box_bits(vm, R[b_]) == box_bits(vm, R[c_]))); /* 64 位值相等,非句柄 */ + VM_NEXT(); +} + +/* ---- 一元与转换 ---- */ +VM_CASE(OP_Not) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_bool(R[b_].i32 == 0)); + VM_NEXT(); +} +VM_CASE(OP_NegI) +{ + FETCH_OPS_ABC(); + store_fresh(vm, &R[a_], make_int((int32_t)(0u - (uint32_t)R[b_].i32))); + VM_NEXT(); +} +VM_CASE(OP_NegD) +{ + FETCH_OPS_ABC(); + int perr = 0; + ecs_value r = box_double(vm, -box_d(vm, R[b_]), &perr); + if (perr) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} +VM_CASE(OP_Conv) +{ + FETCH_OPS_ABC(); + int err = 0; + ecs_value r = do_conv(vm, (uint32_t)c_, R[b_], &err); + if (err == 2) VM_FAIL(ECS_ERR_POOL); + if (err) VM_FAIL(ECS_ERR_TYPE); + store_fresh(vm, &R[a_], r); + VM_NEXT(); +} + +/* ---- 控制流 ---- */ +VM_CASE(OP_Jmp) +{ + FETCH_OPS_J(); + pc = op_start + op_size + (uint32_t)jmp_; + VM_NEXT(); +} +VM_CASE(OP_Jpt) +{ + FETCH_OPS_AJ(); + if (R[a_].i32 != 0) pc = op_start + op_size + (uint32_t)jmp_; + VM_NEXT(); +} +VM_CASE(OP_Jpf) +{ + FETCH_OPS_AJ(); + if (R[a_].i32 == 0) pc = op_start + op_size + (uint32_t)jmp_; + VM_NEXT(); +} + +/* ---- 调用(EXT:目标 ID;接收槽 C 恒为寄存器,0 = RET) ---- */ +VM_CASE(OP_Call) +{ + FETCH_OPS_WEXT(); + fr->ret_pc = pc; /* 调用者续跑点(下一条指令):仅调用边界写回 */ + fr->ret_idx = op_index + 1; + int rc = push_frame(vm, &vm->funcs[ext_], (int32_t)ext_); + if (rc != 0) + { + vm->steps = steps; + vm->error_func = fr->func_index; vm->error_pc = (int32_t)op_index; + return rc; + } + frame *nf = frame_at(vm, vm->frame_off[vm->depth - 1]); + nf->ret_pc = 0; + nf->ret_idx = 0; + nf->ret_slot = (uint8_t)c_; + for (int32_t i = 0; i < b_; i++) + { + ecs_value copied = deep_copy_cow(vm, R[a_ + i]); + if (R[a_ + i].tag != ECS_VOID && copied.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &nf->slots[ECS_REG_ARG_BASE + i], copied); /* S-17 实参深拷贝 → ARG 窗 */ + } + fr = nf; codebuf = fr->fn->code; cend = fr->fn->code_bytes; R = fr->slots; pc = 0; idx = fr->ret_idx; + VM_NEXT(); +} +VM_CASE(OP_CallN) +{ + FETCH_OPS_WEXT(); + /* 旗标置位 = 编号 syscall(VM2.md §9.1);否则原生名表索引。 + * S-21:miss → 缺省值表降级,strict_caps 或表外 → 响亮。 */ + /* C99 栈快照按实际参数数分配(0 参数也保留合法数组长度),运行期不申请堆内存。 */ + ecs_value args[b_ > 0 ? b_ : 1]; + for (int32_t i = 0; i < b_; i++) + args[i] = R[a_ + i]; + int nerr = 0; + ecs_value ret = void_value(); + vm->host_error = 0; + if ((ext_ & ECS_SYSCALL_FLAG) != 0) + { + int32_t sid = (int32_t)(ext_ & ~ECS_SYSCALL_FLAG); + int handled = vm->host.syscall && vm->host.syscall(vm->host.ud, sid, args, b_, &ret) == 0; + if (!handled && !vm->host_error) { - vm->error_func = fr->func_index; - vm->error_pc = (int32_t)fr->ret_pc; - return ECS_ERR_OPCODE; + release(vm, ret); + ret = void_value(); + handled = !vm->host.strict_caps && ecs_cap_syscall_default(vm, sid, args, &ret); } - uint32_t ins = code[fr->ret_pc++]; - uint32_t op = ins & 0xFF; - int32_t a = (int32_t)((ins >> 8) & 0xFF); - int32_t b = (int32_t)((ins >> 16) & 0xFF); - int32_t c = (int32_t)((ins >> 24) & 0xFF); - ecs_value *R = fr->slots; - - switch (op) + nerr = !handled; + } + else + { + if (vm->host.native && vm->host.native(vm->host.ud, vm->natives[ext_], args, b_, &ret) == 0) + nerr = 0; + else + nerr = 1; + } + if (vm->host_error || nerr) + { + release(vm, ret); + VM_FAIL(vm->host_error ? vm->host_error : ECS_ERR_NOSUCHNATIVE); + } + store_fresh(vm, &R[c_], ret); /* 接管 ret_* 出生引用;额外 retain 会泄漏每次宿主返回 */ + if (vm->cancel_flag) + { + vm->steps = steps; + fr->ret_pc = pc; fr->ret_idx = op_index + 1; + return ECS_CANCELLED; /* 宿主调用后即时取消(PRINT/READ/WAIT 类长延迟感知点) */ + } + VM_NEXT(); +} +VM_CASE(OP_Ret) +{ + FETCH_OPS_ABC(); + /* C API 不导出顶层返回值;无需为将立即丢弃的值创建第二份引用。 */ + if (vm->depth == 1) { pop_frame(vm, fr); vm->steps = steps; return ECS_OK; } + /* 拷贝到调用者接收槽(S-17;COW:唯一引用移交);值须跨弹帧持有 */ + ecs_value copied = deep_copy_cow(vm, R[a_]); + if (R[a_].tag != ECS_VOID && copied.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + int32_t ret_slot = fr->ret_slot; + pop_frame(vm, fr); + if (vm->depth == 0) { vm->steps = steps; return ECS_OK; } + fr = frame_at(vm, vm->frame_off[vm->depth - 1]); + codebuf = fr->fn->code; cend = fr->fn->code_bytes; R = fr->slots; pc = fr->ret_pc; idx = fr->ret_idx; + store_fresh(vm, &R[ret_slot], copied); /* 出生引用直写(对齐 EcxInterpreter.Ret) */ + VM_NEXT(); +} +VM_CASE(OP_Ret0) +{ + pop_frame(vm, fr); + if (vm->depth == 0) { vm->steps = steps; return ECS_OK; } + fr = frame_at(vm, vm->frame_off[vm->depth - 1]); + codebuf = fr->fn->code; cend = fr->fn->code_bytes; R = fr->slots; pc = fr->ret_pc; idx = fr->ret_idx; + VM_NEXT(); +} + +/* ---- 数组 / 字符串 ---- */ +VM_CASE(OP_NewArrV) +{ + FETCH_OPS_WEXT(); + int32_t h = hnew_arr(vm, (uint8_t)ext_, 0, b_); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + ecs_obj *o = obj_at(vm, h); + for (int32_t i = 0; i < b_; i++) + arr_overwrite(vm, o, i, R[c_ + i]); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_ARRAY; R[a_].u32 = (uint32_t)h; + VM_NEXT(); +} +VM_CASE(OP_NewArrE) +{ + FETCH_OPS_ABX(); + int32_t h = hnew_arr(vm, (uint8_t)b_, 0, 0); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_ARRAY; R[a_].u32 = (uint32_t)h; + VM_NEXT(); +} +VM_CASE(OP_GetI) +{ + FETCH_OPS_ABC(); + ecs_value container = R[b_]; + int32_t ix = R[c_].i32; + if (container.tag == ECS_STRING) + { + const uint16_t *u; int32_t len; + str_or_null(vm, container, &u, &len); + if (ix < 0 || ix >= len) VM_FAIL(ECS_ERR_INDEX); + ecs_value result = new_str_units(vm, u ? u + ix : NULL, 1); + if (result.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], result); + } + else + { + ecs_obj *arr = heap_obj(vm, (int32_t)container.u32); + if (!arr || container.tag != ECS_ARRAY) VM_FAIL(ECS_ERR_TYPE); + if (ix < 0 || ix >= arr->h.len) VM_FAIL(ECS_ERR_INDEX); + /* S-10 按 elem tag 归一读(宽元素装箱,块耗尽 → POOL) */ + int aerr = 0; + ecs_value item = arr_get(vm, arr, ix, &aerr); + if (aerr) VM_FAIL(ECS_ERR_POOL); + if (OBJ_ETAG(arr->h.flags) == ECS_BYTE) { item.tag = ECS_BYTE; item.i32 &= 0xFF; } + else if (OBJ_ETAG(arr->h.flags) == ECS_BOOL) { item = make_bool(item.i32 != 0); } + if (is_boxed_tag(item.tag)) store_fresh(vm, &R[a_], item); /* arr_get 创建出生引用 */ + else move_to(vm, &R[a_], item); + } + VM_NEXT(); +} +VM_CASE(OP_SetI) +{ + FETCH_OPS_ABC(); + ecs_obj *arr = heap_obj(vm, (int32_t)R[b_].u32); + if (!arr || R[b_].tag != ECS_ARRAY) VM_FAIL(ECS_ERR_TYPE); + int32_t ix = R[c_].i32; + if (ix < 0 || ix >= arr->h.len) VM_FAIL(ECS_ERR_INDEX); + ecs_value v = R[a_]; + if (OBJ_ETAG(arr->h.flags) == ECS_BOOL) v = make_bool(v.i32 != 0); /* CoerceWrite */ + arr_overwrite(vm, arr, ix, v); + VM_NEXT(); +} +VM_CASE(OP_Slice) +{ + FETCH_OPS_WEXT(); + ecs_value container = R[b_]; + int32_t start = R[c_].i32; + if (container.tag == ECS_STRING) + { + const uint16_t *u; int32_t len; + str_or_null(vm, container, &u, &len); + int32_t end = ext_ == 0xFFFFFFFFu ? len : R[(int32_t)ext_].i32; + if (start < 0 || start > len || end > len || start > end) VM_FAIL(ECS_ERR_INDEX); + ecs_value result = new_str_units(vm, u ? u + start : NULL, end - start); + if (result.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], result); + } + else + { + ecs_obj *arr = heap_obj(vm, (int32_t)container.u32); + if (!arr || container.tag != ECS_ARRAY) VM_FAIL(ECS_ERR_TYPE); + int32_t end = ext_ == 0xFFFFFFFFu ? arr->h.len : R[(int32_t)ext_].i32; + if (start < 0 || start > arr->h.len || end > arr->h.len || start > end) VM_FAIL(ECS_ERR_INDEX); + int32_t h = hnew_arr(vm, OBJ_ETAG(arr->h.flags), arr->h.sid, end - start); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + ecs_obj *dst = obj_at(vm, h); + for (int32_t i = start; i < end; i++) + arr_copy_item(vm, dst, i - start, arr, i); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_ARRAY; R[a_].u32 = (uint32_t)h; + } + VM_NEXT(); +} +VM_CASE(OP_Cont) /* S-09 */ +{ + FETCH_OPS_ABC(); + ecs_value item = R[b_]; + ecs_value container = R[c_]; + if (container.tag == ECS_STRING) + { + const uint16_t *hay; int32_t hlen; + str_or_null(vm, container, &hay, &hlen); + if (item.tag != ECS_STRING) VM_FAIL(ECS_ERR_TYPE); + const uint16_t *sub; int32_t slen; + str_or_null(vm, item, &sub, &slen); + store_fresh(vm, &R[a_], make_bool(units_contains(hay, hlen, sub, slen))); + } + else if (container.tag == ECS_ARRAY) + { + ecs_obj *arr = heap_obj(vm, (int32_t)container.u32); + if (!arr) VM_FAIL(ECS_ERR_TYPE); + if (item.tag != OBJ_ETAG(arr->h.flags)) + store_fresh(vm, &R[a_], make_bool(0)); /* 元素类型不匹配 → false */ + else { - case OP_Nop: break; - case OP_Halt: - vm->error_func = fr->func_index; - vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_OK; - - case OP_LoadI: store_fresh(vm, &R[a], make_int(sign16(ins >> 16))); break; - case OP_LoadK: - { - int32_t bx = b | (c << 8); - if (bx >= vm->nconsts) { vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; return ECS_ERR_SLOT; } - const_ent *k = &vm->consts[bx]; - switch (k->tag) - { - case ECS_STRING: store_fresh(vm, &R[a], new_str_units(vm, k->units, k->units_len / 2)); break; - case ECS_DOUBLE: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_DOUBLE; R[a].f64 = k->f64; break; - case ECS_UINT64: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT64; R[a].i64 = k->i64; break; - case ECS_PTR: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_PTR; R[a].i64 = k->i64; break; - case ECS_UINT: store_fresh(vm, &R[a], void_value()); R[a].tag = ECS_UINT; R[a].i64 = (uint32_t)k->i64; break; - default: store_fresh(vm, &R[a], make_int((int32_t)k->i64)); break; - } - break; - } - case OP_LoadBool: store_fresh(vm, &R[a], make_bool(b != 0)); break; - case OP_Move: move_to(vm, &R[a], R[b]); break; - case OP_SetVar: unify(vm, &R[a], R[b], 1); break; - case OP_LoadG: - { - int32_t gx = b | (c << 8); - if (gx >= vm->nglobals) { vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; return ECS_ERR_SLOT; } - move_to(vm, &R[a], vm->globals[gx]); - break; - } - case OP_StoreG: + int found = 0; + for (int32_t i = 0; i < arr->h.len; i++) { - int32_t gx = b | (c << 8); - if (gx >= vm->nglobals) { vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; return ECS_ERR_SLOT; } - unify(vm, &vm->globals[gx], R[a], 1); - break; + int aerr = 0; + ecs_value el = arr_get(vm, arr, i, &aerr); + if (aerr) VM_FAIL(ECS_ERR_POOL); + int equal = value_equals(vm, el, item); + if (is_boxed_tag(el.tag)) release(vm, el); + if (equal) { found = 1; break; } } + store_fresh(vm, &R[a_], make_bool(found)); + } + } + else + VM_FAIL(ECS_ERR_TYPE); + VM_NEXT(); +} +VM_CASE(OP_Append) +{ + FETCH_OPS_ABC(); + ecs_obj *src = heap_obj(vm, (int32_t)R[b_].u32); + if (!src || R[b_].tag != ECS_ARRAY) VM_FAIL(ECS_ERR_TYPE); + int32_t h = hnew_arr(vm, OBJ_ETAG(src->h.flags), src->h.sid, src->h.len + 1); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + ecs_obj *dst = obj_at(vm, h); + for (int32_t i = 0; i < src->h.len; i++) + arr_copy_item(vm, dst, i, src, i); + arr_overwrite(vm, dst, src->h.len, R[c_]); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_ARRAY; R[a_].u32 = (uint32_t)h; + VM_NEXT(); +} +VM_CASE(OP_Cat) /* S-11 */ +{ + FETCH_OPS_ABC(); + ecs_value l = R[b_], r = R[c_]; + if (l.tag == ECS_STRING || r.tag == ECS_STRING) + { + strbuf sb; memset(&sb, 0, sizeof(sb)); + tostring_top(vm, &sb, l); + tostring_top(vm, &sb, r); + if (sb.overflow) VM_FAIL(ECS_ERR_POOL); + ecs_value result = new_str_units(vm, sb.units, sb.len); + if (result.tag == ECS_VOID) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], result); + } + else if (l.tag == ECS_ARRAY && r.tag == ECS_ARRAY) + { + ecs_obj *la = heap_obj(vm, (int32_t)l.u32); + ecs_obj *ra = heap_obj(vm, (int32_t)r.u32); + if (!la || !ra) VM_FAIL(ECS_ERR_TYPE); + if (OBJ_ETAG(la->h.flags) != OBJ_ETAG(ra->h.flags)) VM_FAIL(ECS_ERR_TYPE); + int32_t h = hnew_arr(vm, OBJ_ETAG(la->h.flags), la->h.sid, la->h.len + ra->h.len); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + ecs_obj *dst = obj_at(vm, h); + for (int32_t i = 0; i < la->h.len; i++) + arr_copy_item(vm, dst, i, la, i); + for (int32_t i = 0; i < ra->h.len; i++) + arr_copy_item(vm, dst, la->h.len + i, ra, i); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_ARRAY; R[a_].u32 = (uint32_t)h; + } + else + VM_FAIL(ECS_ERR_TYPE); + VM_NEXT(); +} +VM_CASE(OP_Len) +{ + FETCH_OPS_ABC(); + if (R[b_].tag == ECS_STRING) + { + const uint16_t *u; int32_t len; + str_or_null(vm, R[b_], &u, &len); + store_fresh(vm, &R[a_], make_int(len)); + } + else if (R[b_].tag == ECS_ARRAY) + { + ecs_obj *arr = heap_obj(vm, (int32_t)R[b_].u32); + if (!arr) VM_FAIL(ECS_ERR_TYPE); + store_fresh(vm, &R[a_], make_int(arr->h.len)); + } + else VM_FAIL(ECS_ERR_TYPE); + VM_NEXT(); +} - case OP_Jmp: fr->ret_pc = (uint32_t)((int32_t)fr->ret_pc + sign24(ins >> 8)); break; - case OP_Jpt: if (R[a].i32 != 0) fr->ret_pc = (uint32_t)((int32_t)fr->ret_pc + sign16(ins >> 16)); break; - case OP_Jpf: if (R[a].i32 == 0) fr->ret_pc = (uint32_t)((int32_t)fr->ret_pc + sign16(ins >> 16)); break; - - case OP_Call: - { - if (!ecs_op_has_ext(op) || (uint32_t)fr->ret_pc >= words) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_OPCODE; - } - uint32_t target = code[fr->ret_pc++]; - if (target & ECS_IMPORT_FLAG || target >= (uint32_t)vm->nfuncs) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_OPCODE; /* 链接后不应残留导入标记 */ - } - int rc = push_frame(vm, &vm->funcs[target], (int32_t)target); - if (rc != 0) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return rc; - } - frame *nf = vm->frames[vm->depth - 1]; - nf->ret_pc = 0; - nf->ret_slot = (c == ECS_RECEIVE_NONE) ? ECS_NO_SLOT : c; - for (int32_t i = 0; i < b; i++) - { - if (a + i >= fr->fn->nslots) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_SLOT; - } - unify(vm, &nf->slots[i], R[a + i], 1); /* S-17 实参深拷贝 */ - } - break; - } - case OP_CallN: - { - if (!ecs_op_has_ext(op) || (uint32_t)fr->ret_pc >= words) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_OPCODE; - } - uint32_t target = code[fr->ret_pc++]; - if (b > 8) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_NOSUCHNATIVE; - } - /* 旗标置位 = 文件族 syscall 编号(VM2.md §9.1);否则原生名表索引 */ - if ((target & ECS_SYSCALL_FLAG) == 0 && target >= (uint32_t)vm->nnatives) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_NOSUCHNATIVE; - } - ecs_value args[8]; - for (int32_t i = 0; i < b; i++) - { - if (a + i >= fr->fn->nslots) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_SLOT; - } - args[i] = R[a + i]; - } - int nerr = 0; - ecs_value ret = void_value(); - if ((target & ECS_SYSCALL_FLAG) != 0) - { - /* L2:编号 syscall,语义在宿主参考实现(VM 核纯调度) */ - if (!vm->host.syscall || vm->host.syscall(vm->host.ud, (int32_t)(target & ~ECS_SYSCALL_FLAG), args, b, &ret) != 0) - nerr = 1; - } - else - { - /* L3:名表动态原生(采集洞 / EXTERN FFI / ENCODE / JQ) */ - if (!vm->host.native || vm->host.native(vm->host.ud, vm->natives[target], args, b, &ret) != 0) - nerr = 1; - } - if (nerr) - { - vm->error_func = fr->func_index; vm->error_pc = (int32_t)fr->ret_pc - 1; - return ECS_ERR_NOSUCHNATIVE; - } - if (c != ECS_RECEIVE_NONE) - move_to(vm, &R[c], ret); - break; - } - case OP_Ret: - { - /* 拷贝到调用者接收槽(S-17;COW:唯一引用移交);值须跨弹帧持有 */ - ecs_value copied = deep_copy_cow(vm, R[a]); - int32_t ret_slot = fr->ret_slot; - const ecs_funcdef *done = fr->fn; - (void)done; - vm->depth--; - for (int32_t s = 0; s < fr->fn->nslots; s++) - release(vm, fr->slots[s]); - free(fr); - if (vm->depth == 0) return ECS_OK; - if (ret_slot != ECS_NO_SLOT) - move_to(vm, &vm->frames[vm->depth - 1]->slots[ret_slot], copied); - else - release(vm, copied); +/* ---- 结构体 ---- */ +VM_CASE(OP_NewSt) +{ + FETCH_OPS_ABX(); + int32_t sid = b_; + if (sid >= vm->nstructs) VM_FAIL(ECS_ERR_SLOT); + int32_t h = hnew_st(vm, &vm->structs[sid]); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_STRUCT; R[a_].u32 = (uint32_t)h; + VM_NEXT(); +} +VM_CASE(OP_GetF) +{ + FETCH_OPS_ABC(); + ecs_obj *st = heap_obj(vm, (int32_t)R[b_].u32); + if (!st || R[b_].tag != ECS_STRUCT) VM_FAIL(ECS_ERR_TYPE); + const ecs_structdef *def = &vm->structs[st->h.sid]; + if (c_ >= def->nfields) VM_FAIL(ECS_ERR_SLOT); + const ecs_fielddef *f = &def->fields[c_]; + ecs_value *slots = (ecs_value *)OBJ_PAYLOAD(st); + do + { + switch (f->kind) + { + case 0: /* Scalar */ + if (f->type == ECS_BYTE) { store_fresh(vm, &R[a_], slots[f->slot_offset]); R[a_].tag = ECS_BYTE; R[a_].i32 &= 0xFF; } + else if (f->type == ECS_BOOL) store_fresh(vm, &R[a_], make_bool(slots[f->slot_offset].i32 != 0)); + else { move_to(vm, &R[a_], slots[f->slot_offset]); } break; - } - case OP_Ret0: + case FIELD_KIND_FIXED_ARRAY: { - vm->depth--; - for (int32_t s = 0; s < fr->fn->nslots; s++) - release(vm, fr->slots[s]); - free(fr); - if (vm->depth == 0) return ECS_OK; + int32_t h = hnew_arr(vm, f->elem, 0, f->ext); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + ecs_obj *dst = obj_at(vm, h); + for (int32_t i = 0; i < f->ext; i++) + arr_overwrite(vm, dst, i, slots[f->slot_offset + i]); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_ARRAY; R[a_].u32 = (uint32_t)h; break; } - - default: + case FIELD_KIND_NESTED: { - int rc = exec_data_op(vm, op, R, a, b, c, fr); - if (rc != 0) + const ecs_structdef *nested = &vm->structs[f->ext]; + int32_t h = hnew_st(vm, nested); + if (h < 0) VM_FAIL(ECS_ERR_POOL); + ecs_obj *dst = obj_at(vm, h); + ecs_value *dslots = (ecs_value *)OBJ_PAYLOAD(dst); + for (int32_t i = 0; i < nested->nslots; i++) { - vm->error_func = fr->func_index; - vm->error_pc = (int32_t)fr->ret_pc - 1; - return rc; + dslots[i] = slots[f->slot_offset + i]; + retain(vm, dslots[i]); } + /* 写穿透视图(对齐 v1 GetNested / 解释器 ViewParent):记录父链, + 后续槽写入经 sync_view 逐级回写父槽区 */ + dst->h.view_parent = (int32_t)R[b_].u32; + dst->h.view_offset = f->slot_offset; + retain(vm, R[b_]); + store_fresh(vm, &R[a_], void_value()); R[a_].tag = ECS_STRUCT; R[a_].u32 = (uint32_t)h; break; } + default: VM_FAIL(ECS_ERR_TYPE); /* Boxed 不受支持(S-16) */ + } + } while (0); /* 内层 switch:break 语义隔离(线程化模式无外层 switch) */ + VM_NEXT(); +} +VM_CASE(OP_PutF) +{ + FETCH_OPS_ABC(); + ecs_obj *st = heap_obj(vm, (int32_t)R[b_].u32); + if (!st || R[b_].tag != ECS_STRUCT) VM_FAIL(ECS_ERR_TYPE); + const ecs_structdef *def = &vm->structs[st->h.sid]; + if (c_ >= def->nfields) VM_FAIL(ECS_ERR_SLOT); + const ecs_fielddef *f = &def->fields[c_]; + ecs_value *slots = (ecs_value *)OBJ_PAYLOAD(st); + if (f->kind == 0) + { + ecs_value v = R[a_]; + if (f->type == ECS_BOOL) v = make_bool(v.i32 != 0); + move_to(vm, &slots[f->slot_offset], v); + if (st->h.view_parent) sync_view(vm, st); + } + else if (f->kind == FIELD_KIND_NESTED) + { + ecs_obj *src = heap_obj(vm, (int32_t)R[a_].u32); + if (!src || R[a_].tag != ECS_STRUCT) VM_FAIL(ECS_ERR_TYPE); + /* 对齐校验:src 槽数 vs 字段嵌套类型槽数(与 GetF 取嵌套布局一致) */ + const ecs_structdef *nested_put = &vm->structs[f->ext]; + const ecs_structdef *src_def = &vm->structs[src->h.sid]; + if (src_def->nslots != nested_put->nslots) VM_FAIL(ECS_ERR_TYPE); + ecs_value *sslots = (ecs_value *)OBJ_PAYLOAD(src); + for (int32_t i = 0; i < src_def->nslots; i++) + { + release(vm, slots[f->slot_offset + i]); + slots[f->slot_offset + i] = sslots[i]; + retain(vm, slots[f->slot_offset + i]); } + if (st->h.view_parent) sync_view(vm, st); + } + else VM_FAIL(ECS_ERR_TYPE); + VM_NEXT(); +} +VM_CASE(OP_GetFI) +{ + FETCH_OPS_WEXT(); + ecs_obj *st = heap_obj(vm, (int32_t)R[b_].u32); + if (!st || R[b_].tag != ECS_STRUCT) VM_FAIL(ECS_ERR_TYPE); + const ecs_structdef *def = &vm->structs[st->h.sid]; + if (c_ >= def->nfields) VM_FAIL(ECS_ERR_SLOT); + const ecs_fielddef *f = &def->fields[c_]; + if (f->kind != FIELD_KIND_FIXED_ARRAY) VM_FAIL(ECS_ERR_TYPE); + int32_t idx = R[(int32_t)ext_].i32; + if (idx < 0 || idx >= f->ext) VM_FAIL(ECS_ERR_INDEX); + ecs_value item = ((ecs_value *)OBJ_PAYLOAD(st))[f->slot_offset + idx]; + if (f->type == ECS_BYTE) { item.tag = ECS_BYTE; item.i32 &= 0xFF; } + else if (f->type == ECS_BOOL) item = make_bool(item.i32 != 0); + move_to(vm, &R[a_], item); + VM_NEXT(); +} +VM_CASE(OP_PutFI) +{ + FETCH_OPS_WEXT(); + ecs_obj *st = heap_obj(vm, (int32_t)R[b_].u32); + if (!st || R[b_].tag != ECS_STRUCT) VM_FAIL(ECS_ERR_TYPE); + const ecs_structdef *def = &vm->structs[st->h.sid]; + if (c_ >= def->nfields) VM_FAIL(ECS_ERR_SLOT); + const ecs_fielddef *f = &def->fields[c_]; + if (f->kind != FIELD_KIND_FIXED_ARRAY) VM_FAIL(ECS_ERR_TYPE); + int32_t idx = R[(int32_t)ext_].i32; + if (idx < 0 || idx >= f->ext) VM_FAIL(ECS_ERR_INDEX); + ecs_value v = R[a_]; + if (f->type == ECS_BOOL) v = make_bool(v.i32 != 0); + ecs_value *slots = (ecs_value *)OBJ_PAYLOAD(st); + release(vm, slots[f->slot_offset + idx]); + slots[f->slot_offset + idx] = v; + retain(vm, v); + if (st->h.view_parent) sync_view(vm, st); + VM_NEXT(); +} + +/* ---- 域操作(宿主回调) ---- */ +VM_CASE(OP_WaitI) +{ + FETCH_OPS_WEXT(); + if (vm->host.wait_ms) vm->host.wait_ms(vm->host.ud, (int32_t)ext_); + if (vm->wait_yield || vm->cancel_flag) /* 取消优先;续跑点 = 本指令后继 */ + { + fr->ret_pc = pc; + fr->ret_idx = op_index + 1; + vm->steps = 0; + return vm->cancel_flag ? ECS_CANCELLED : ECS_WAITING; } + VM_NEXT(); } +VM_CASE(OP_WaitV) +{ + FETCH_OPS_ABC(); + if (vm->host.wait_ms) vm->host.wait_ms(vm->host.ud, R[a_].i32); + if (vm->wait_yield || vm->cancel_flag) + { + fr->ret_pc = pc; + fr->ret_idx = op_index + 1; + vm->steps = 0; + return vm->cancel_flag ? ECS_CANCELLED : ECS_WAITING; + } + VM_NEXT(); +} +VM_CASE(OP_KeyI) +{ + FETCH_OPS_WEXT(); + if (vm->host.key) vm->host.key(vm->host.ud, (uint8_t)a_, (int32_t)ext_); + VM_NEXT(); +} +VM_CASE(OP_KeyV) +{ + FETCH_OPS_ABC(); + if (vm->host.key) vm->host.key(vm->host.ud, (uint8_t)a_, R[b_].i32); + VM_NEXT(); +} +VM_CASE(OP_KeySt) +{ + FETCH_OPS_ABC(); + if (vm->host.key_state) vm->host.key_state(vm->host.ud, (uint8_t)a_, b_); + VM_NEXT(); +} +VM_CASE(OP_StickSet) +{ + FETCH_OPS_ABC(); + if (vm->host.stick_set) vm->host.stick_set(vm->host.ud, (uint8_t)a_, b_, c_); + VM_NEXT(); +} +VM_CASE(OP_StickP) +{ + FETCH_OPS_WEXT(); + if (vm->host.stick_click) + vm->host.stick_click(vm->host.ud, (uint8_t)a_, b_, c_, (int32_t)ext_); + VM_NEXT(); +} +VM_CASE(OP_StickPv) +{ + FETCH_OPS_WEXT(); + if (vm->host.stick_click) + vm->host.stick_click(vm->host.ud, (uint8_t)a_, (int32_t)(ext_ & 0xFF), (int32_t)((ext_ >> 16) & 0xFF), R[c_].i32); + VM_NEXT(); +} +VM_CASE(OP_Img) +{ + FETCH_OPS_ABX(); + /* S-21:图像标签能力缺失 → 目标槽 ← -1(与 C# EcxHost 缺省 ImgLabel 一致); + * strict_caps 宿主恢复响亮(IL)。 */ + if (vm->host.strict_caps) + VM_FAIL(ECS_ERR_IL); + store_fresh(vm, &R[a_], make_int(-1)); + VM_NEXT(); +} +VM_CASE(OP_Rand) /* S-12 */ +{ + FETCH_OPS_ABC(); + int32_t max = R[b_].i32; + if (max < 0) VM_FAIL(ECS_ERR_INDEX); + int32_t v = 0; + if (max != 0 && vm->host.rand) v = vm->host.rand(vm->host.ud, max); + store_fresh(vm, &R[a_], make_int(v)); + VM_NEXT(); +} + +/* ---- FOR 快速路径 / 比较跳转融合 ---- */ +VM_CASE(OP_ForStep) +{ + FETCH_OPS_WJ(); + /* FOR 快速路径:tmp=i+1 写 R[c];tmp>limit → 跳 exit(数据字);否则落入后继 */ + int32_t next = (int32_t)((uint32_t)R[a_].i32 + 1u); + store_fresh(vm, &R[c_], make_int(next)); + if (next > R[b_].i32) + pc = op_start + op_size + (uint32_t)rd_u32(codebuf + op_start + 4); /* 出口字延迟读 */ + VM_NEXT(); +} +VM_CASE(OP_CmpJ) /* 比较跳转融合(P2′):kind=typeBlock*6+op;数据字 = 跳转字节偏移 */ +{ + FETCH_OPS_WJ(); + int taken; + switch (kind_block(c_)) + { + case 0: { int32_t l = R[a_].i32, r = R[b_].i32; taken = cmp_kind6_i32(l, r, c_); } break; + case 1: { uint32_t l = (uint32_t)R[a_].i32, r = (uint32_t)R[b_].i32; taken = cmp_kind6_u32(l, r, c_); } break; + case 2: { double l = box_d(vm, R[a_]), r = box_d(vm, R[b_]); taken = cmp_kind6_f64(l, r, c_); } break; + default: { int64_t l = box_l(vm, R[a_]), r = box_l(vm, R[b_]); taken = cmp_kind6_i64(l, r, c_); } break; + } + if (taken) + pc = op_start + op_size + (uint32_t)rd_u32(codebuf + op_start + 4); + VM_NEXT(); +} + +#if !ECS_DIRECT_THREADED + default: + VM_FAIL(ECS_ERR_OPCODE); + } /* switch (opv) */ + } /* for ;; */ +#endif + +#undef VM_FAIL +#undef VM_NEXT +#undef VM_CASE +#if ECS_DIRECT_THREADED +#undef VM_FETCH_TH +#undef FETCH_OPS_ABC +#undef FETCH_OPS_ABX +#undef FETCH_OPS_SBXI +#undef FETCH_OPS_AJ +#undef FETCH_OPS_J +#undef FETCH_OPS_WJ +#undef FETCH_OPS_WEXT +#endif +} \ No newline at end of file diff --git a/src/EasyCon.Vm/native/ecs_vm.h b/src/EasyCon.Vm/native/ecs_vm.h index 33420447..2845615e 100644 --- a/src/EasyCon.Vm/native/ecs_vm.h +++ b/src/EasyCon.Vm/native/ecs_vm.h @@ -1,8 +1,12 @@ /* * ecs_vm.h — VM2 纯 C 精简虚拟机(ECS 字节码,docs/VM2.md) * - * C99,仅依赖 libc(malloc/free/realloc/memcpy/memset/snprintf/strtod/getenv)。 + * C99,仅依赖 libc(malloc/free/realloc/memcpy/memset/snprintf/strtod/getenv—— + * 仅用于加载期表分配与镜像读入;运行期零分配,见 docs/ZeroAllocVm.md P2/P3)。 * 嵌入式移植:把本文件与 ecs_vm.c 加入工程,或经宏重定向内存函数。 + * 容器格式:ECSC v2(docs/SingleStreamFormat.md §3);指令流 = v3 定长编码—— + * 4B(Iabc/ABx/AsBx/IsJ)或 8B(Ext/IabcJ = iABC + u32 数据字),尺寸由 ecs_op_words 给出, + * 跳转偏移单位 = 字节(目标 = 指令起始 + 本指令字节数 + delta)。 */ #ifndef ECS_VM_H #define ECS_VM_H @@ -14,6 +18,22 @@ extern "C" { #endif +/* ---- 容器 ABI(与 C# EcsSyscall.AbiRevision / ECSC manifest.abi_rev 对齐) ---- */ +#define ECS_ABI_REV 1 /* 镜像格式修订号(与 C# EcsSyscall.AbiRevision 对齐; + * 全新实现无历史分级,格式语义变化时直接改值) */ + +/* ---- 帧内寄存器布局(与 C# EcsRegisters 一一对应,双端手工镜像) ---- + * 帧模型 = Dalvik/Lua 式每函数栈窗口:帧长 = 函数寄存器高水位(funcdef.nslots ≤ 255, + * 指令域 u8 是唯一上限),调用时在共享值栈上开窗、返回时弹出。 */ +#define ECS_REG_MAX 255 /* 寄存器号上限(指令 A/B/C 域 u8) */ +#define ECS_REG_RET 0 /* R0 = 返回值固定落点(Call 的 C=0) */ +#define ECS_REG_ARG_BASE 1 /* R1 起 = NParams 个参数(Call 从 A 指定的出站窗深拷贝) */ +#define ECS_REG_ARG_COUNT (ECS_REG_MAX - ECS_REG_ARG_BASE) +/* 参数后按需保留出站/数组分块窗口,再分配临时值;位置由编译器编码进指令。 */ +#define ECSC_MAGIC 0x43534345u /* "ECSC" */ +#define ECSC_VERSION 2 /* v2 = 定长指令编码(v1 = varint 基流,互不兼容) */ +#define ECSC_KIND_IMAGE 1 + /* ---- 操作码(与 C# EcsOpcode / docs/VM2.md §4.2 数值与语义一致) ---- */ enum { OP_Nop = 0, OP_Halt, @@ -35,12 +55,16 @@ enum { OP_NewSt, OP_GetF, OP_PutF, OP_GetFI, OP_PutFI, OP_WaitI, OP_WaitV, OP_KeyI, OP_KeyV, OP_KeySt, OP_StickSet, OP_StickP, OP_StickPv, OP_Img, OP_Rand, + OP_ForStep, /* FOR 快速路径:R[A]==R[B] → 落入下一块(出循环);否则 R[A]+=1 跳 C 目标 */ + OP_CmpJ, /* 比较跳转融合(P2′):R[A] kind R[B] 成立 → 跳 Jump;C=kind=typeBlock*6+op + *(typeBlock:0=i32,1=u32,2=f64,3=i64;op:0=Eq,1=Neq,2=Lt,3=Le,4=Gt,5=Ge)*/ }; /* ---- 文件族 syscall 编号(uvm32 式编号 ABI,与 C# EcsSyscall 一致,docs/VM2.md §9.1) ---- * CallN 的 EXT 字旗标置位时,低 31 位 = 编号,直接引擎内建分发,不经原生名表。 * 文件实现仍转发 host->native(传规范名);caps 缺失行为与名表时代逐字一致。 */ #define ECS_SYSCALL_FLAG 0x80000000u +/* ECS_SYSCALL_AMIIBO = 16:v2.3 Amiibo 槽位选择(0–19,宿主判越界静默;S-13) */ enum { ECS_SYSCALL_FWRITE = 1, ECS_SYSCALL_FREAD, ECS_SYSCALL_FOPEN, ECS_SYSCALL_FCLOSE, ECS_SYSCALL_FEOF, @@ -49,29 +73,31 @@ enum { ECS_SYSCALL_TIME, ECS_SYSCALL_BEEP, ECS_SYSCALL_AMIIBO, ECS_SYSCALL_OCR_CONF, }; -/* ---- 镜像特征需求掩码(镜像头保留位 u16 @0x0A,与 C# EcsImageFeatures 一致,VM2.md §9.1) ---- - * 加载规则:host->feats 缺位 → 拒跑(IL → ECS_ERR_IL 既有码,其余 → ECS_ERR_FEAT)。 - * 旧镜像掩码 = 0,IL 由 flags.I 投影,行为兼容。 */ +/* ---- 镜像特征需求掩码(ECX1 头 feats,与 C# EcsImageFeatures 一致,VM2.md §9.1) ---- + * S-21 起为元信息:缺省不拒载,能力缺失按 ecs_cap_*_default 缺省值表降级; + * host->strict_caps 置位时恢复响亮校验(IL → ECS_ERR_IL,其余缺位 → ECS_ERR_FEAT)。 */ #define ECS_FEAT_IL 0x1u #define ECS_FEAT_CAPTURE 0x2u #define ECS_FEAT_FFI 0x4u #define ECS_FEAT_FILE 0x8u +#define ECS_FEAT_AUDIO 0x20u /* AUDIO_LOAD/SCORE/UNLOAD require a background-audio host. */ -/* 有 32 位 EXT 后随数据字的操作码集合——单一事实源,与 C# EcsFormat 表 / docs/VM2.md §4.1 - * 对齐(新增 EXT 指令双端各改一处表 + 执行/发射语义)。EXT 后随字是「数据」(Call/CallN - * 的目标 ID、Slice 的 end 槽、StickP/StickPv 的时长/坐标等),其数值可能恰好等于某个操作码: - * 线性扫描代码必须按本表步进跳过,否则会把数据误读为指令;执行侧读 EXT 前以本表自检。 */ -static inline int ecs_op_has_ext(uint32_t op) +/* 指令字数(1 = 4B,2 = 8B 带 u32 数据字)——单一事实源,与 C# EcsFormat.WordCount / + * docs/VM2.md §4.1 对齐(新增指令双端各改一处表 + 执行/发射语义)。数据字是「数据」: + * EXT 的目标 ID/槽位/时长/坐标,ForStep/CmpJ 的跳转偏移,WaitI/KeyI 的时长—— + * 数值可能恰好等于某个操作码,一切线性扫描必须按字数步进跳过(历史 F4 缺陷铁律)。 */ +static inline uint32_t ecs_op_words(uint32_t op) { switch (op) { case OP_Call: case OP_CallN: case OP_NewArrV: case OP_Slice: - case OP_GetFI: case OP_PutFI: case OP_StickP: case OP_StickPv: - return 1; + case OP_GetFI: case OP_PutFI: case OP_WaitI: case OP_KeyI: + case OP_StickP: case OP_StickPv: case OP_ForStep: case OP_CmpJ: + return 2; default: - return 0; + return 1; } } -#define ECS_OP_HAS_EXT(op) ecs_op_has_ext(op) +#define ECS_OP_WORDS(op) ecs_op_words(op) /* ---- 值标签(与 docs/VM2.md §3.1 / C# TaggedValue 对齐) ---- */ enum { @@ -80,14 +106,16 @@ enum { ECS_PTR = 10, ECS_STRUCT = 12, }; -/* 16 字节 tagged value:tag 在 0 偏移,载荷 8 字节对齐 */ +/* 8 字节标记值(值模型见 docs/VmBudget.md §值模型):INT 族(BOOL/BYTE/INT/UINT) + * 内联 i32 位模式;STRING/ARRAY/STRUCT 为堆句柄 u32(静态串 bit31 置位,S-20); + * 宽标量 DOUBLE/UINT64/PTR 统一装箱——值持对象池 8 字节标量块句柄,参与引用计数, + * 数组内宽元素仍为 8B 裸存(装箱只存在于值槽)。脚本可见语义与 16B 时代逐位一致。 */ typedef struct ecs_value { uint8_t tag; - uint8_t _pad[7]; + uint8_t _pad[3]; union { - int32_t i32; /* BOOL(0/1) / BYTE / INT / UINT(位模式共用) */ - int64_t i64; /* UINT64 / PTR / 堆句柄(低 32 位) */ - double f64; + int32_t i32; /* BOOL(0/1) / BYTE / INT / UINT(位模式共用) */ + uint32_t u32; /* 堆句柄 / 静态串索引(bit31 = pinned) */ }; } ecs_value; @@ -99,15 +127,22 @@ enum { ECS_ERR_NOSUCHNATIVE = 10, ECS_ERR_HOST = 11, ECS_ERR_OOM = 12, ECS_ERR_IL = 13, /* 镜像需要图像标签能力(FEAT_IL),本宿主不提供 */ ECS_ERR_FEAT = 14, /* 镜像特征需求超出宿主提供(FEAT_CAPTURE/FFI/FILE 缺位) */ + ECS_ERR_SECTION = 15, /* ECSC 段流非法:critical 段缺失/重复段/SEC_END 后有残余 */ + ECS_ERR_UNSUPPORTED = 16, /* 未知 critical 段/端序不符/逃生舱段存在/ABI 修订不符 */ + ECS_ERR_CRC = 17, /* SEC_CRC32 校验失败 */ + ECS_ERR_POOL = 18, /* 固定池耗尽:帧段满/对象块满/单对象超块(可预期的正常失败) */ + ECS_WAITING = 19, /* wait-yield 模式:WAIT 时长已交宿主,让出等 tick 后从续跑点恢复 */ }; -/* ---- 宿主接口(依赖注入;任何回调可为 NULL → 对应功能 no-op 或报错) ---- +/* ---- 宿主接口(依赖注入;回调缺失 → L2/L3 按 S-21 缺省值表降级,L1 域操作不降级) ---- * 分层(docs/VM2.md §1):L1 域操作回调(wait/key/stick/rand)+ L2 syscall 处理器(编号, - * 语义在宿主)+ L3 动态原生(按名)。特征掩码 feats = 本宿主提供的高级能力(ECS_FEAT_*), - * 加载期对镜像特征需求校验,缺位即拒跑。 */ + * 语义在宿主)+ L3 动态原生(按名)。特征掩码 feats = 本宿主提供的高级能力(ECS_FEAT_*): + * S-21 起为元信息,缺省不拒载;strict_caps 置位恢复响亮(加载期校验 + miss 即 + * ECS_ERR_NOSUCHNATIVE,图像标签执行期 ECS_ERR_IL)。 */ typedef struct ecs_host { void *ud; - uint32_t feats; /* 本宿主提供的特征位(ECS_FEAT_*),加载期校验用 */ + uint32_t feats; /* 本宿主提供的特征位(ECS_FEAT_*);strict_caps 时加载期校验用 */ + int strict_caps; /* S-21:0 = 缺省值降级(缺省);1 = 恢复响亮拒跑/报错 */ const char *const *args; /* ECS_SYSCALL_ARG */ int32_t nargs; const uint16_t *app_dir; /* ECS_SYSCALL_APP */ @@ -132,19 +167,65 @@ typedef struct ecs_vm ecs_vm; int32_t ecs_vm_arg_str(ecs_vm *vm, ecs_value v, const uint16_t **units); /* 分配 VM 堆字符串作为返回值(UTF-16 units;units=NULL 或 len<=0 → 空串) */ void ecs_vm_ret_str(ecs_vm *vm, ecs_value *ret, const uint16_t *units, int32_t len); +/* 宿主侧宽标量 ABI(值槽内为装箱句柄,宿主不可直读载荷): + * arg_wide 读取 PTR/UINT64(内联 tag → i32 符号扩展);DOUBLE 用 arg_double。 + * ret_wide 的 tag 仅限 ECS_PTR/ECS_UINT64;ret_double 返回 DOUBLE。 + * 返回 ECS_OK/ECS_ERR_TYPE/ECS_ERR_POOL;失败时 ret 为 VOID,错误也由 CallN 传播, + * 因此宿主忽略辅助函数返回码也不会把池耗尽伪装成合法的 0。 + * ret_* 创建出生引用,CallN 接管它;不能将 args 中借用的堆句柄直接赋给 ret。 */ +int64_t ecs_vm_arg_wide(ecs_vm *vm, ecs_value v); +int ecs_vm_ret_wide(ecs_vm *vm, ecs_value *ret, uint8_t tag, int64_t x); +double ecs_vm_arg_double(ecs_vm *vm, ecs_value v); +int ecs_vm_ret_double(ecs_vm *vm, ecs_value *ret, double x); +/* ---- 字节码来源抽象(uLua zIO 思路,随机访问式):RAM 缓冲 / 内存映射内部 flash / + * EEPROM(逐字节读)/ NVS(分块读)等位置统一经 read 回调供给;VM 信任字节码, + * 加载期仅校验 magic/版本/ABI 与结构边界(宿主内存安全),不做内容校验。 ---- */ +typedef struct ecs_io { + void *ud; + uint32_t size; /* 镜像总字节数 */ + /* 从 offset 读 len 字节到 out;返回实际读取字节数(< len = 读失败) */ + uint32_t (*read)(void *ud, uint32_t offset, uint8_t *out, uint32_t len); +} ecs_io; +/* 缓冲来源便捷构造:direct = 1 时零拷贝(可 XIP);0 时经 read 拉取(演示统一通路) */ +void ecs_io_mem(ecs_io *io, const void *buf, uint32_t len, int direct); + +/* 经 ecs_io 加载会把镜像整块拉入 VM 内部副本——定位为 PC/POSIX 仿真专用; + * XIP 平台(内部 flash / QSPI MMIO)必须走 ecs_vm_load 指针直传,镜像不占 RAM。 */ ecs_vm *ecs_vm_new(const ecs_host *host); -/* 加载并校验镜像;image 缓冲区须在 vm 生命周期内保持有效(不拷贝) */ +/* 加载镜像(RAM 缓冲便捷形式);image 缓冲区须在 vm 生命周期内保持有效(不拷贝,可 XIP)。 + * 加载仅校验头(magic/版本/ABI)与解析期结构边界;相信字节码内容(Lua 式最小验证面)。 */ int ecs_vm_load(ecs_vm *vm, const uint8_t *image, size_t len); -/* 运行:ECS_OK / ECS_YIELD(步数预算耗尽,可再次调用继续)/ 负面错误码 */ +/* 经 ecs_io 加载:镜像经 read 回调拉入 VM 内部缓冲(EEPROM/NVS 等位置的字节码解释执行); + * 内部缓冲由 VM 持有并在 ecs_vm_free 释放。 */ +int ecs_vm_load_io(ecs_vm *vm, const ecs_io *io); +/* 运行:ECS_OK / ECS_YIELD / ECS_WAITING / ECS_CANCELLED / 错误码。 + * 只有 YIELD/WAITING 可续跑;终止后释放 VM,新任务使用新 VM。 */ int ecs_vm_run(ecs_vm *vm); void ecs_vm_cancel(ecs_vm *vm); void ecs_vm_set_budget(ecs_vm *vm, int32_t steps_per_yield); -/* 最近一次错误的现场(函数索引 + 函数内指令字偏移) */ +/* wait-yield 模式(固件 tick 模型):置位后 WAIT 通知宿主时长即返回 ECS_WAITING + *(续跑点已写回,再次 ecs_vm_run 从 WAIT 后继指令恢复)。宿主必须在等待到期后续跑, + * 不得用立即重试的循环吸收 WAITING;取消可立即调用 run。缺省关 = 桌面阻塞语义不变。 */ +void ecs_vm_set_wait_yield(ecs_vm *vm, int enable); +/* 最近一次错误的现场(函数索引 + 函数内指令下标;与 C# EcxInterpreter.ErrorPc 同单位) */ void ecs_vm_error_location(const ecs_vm *vm, int32_t *out_func, int32_t *out_pc); void ecs_vm_free(ecs_vm *vm); +/* ---- 实例容量度量(容量门禁/预算自检用;不实例化 VM,纯编译期常数) ---- */ +typedef struct { + size_t instance_bytes; /* sizeof(ecs_vm) − sizeof(ecs_host):跨指针宽度可移植口径 */ + size_t host_bytes; + size_t arena_bytes; + size_t frame_seg_bytes; + size_t obj_seg_bytes; + uint32_t obj_block_bytes; + uint32_t obj_block_count; + uint32_t value_bytes; /* sizeof(ecs_value) = 8 */ +} ecs_vm_meminfo; +void ecs_vm_query_meminfo(ecs_vm_meminfo *out); + #ifdef __cplusplus } #endif -#endif /* ECS_VM_H */ +#endif /* ECS_VM_H */ \ No newline at end of file diff --git a/src/EasyCon.WinInput/EasyCon.WinInput.csproj b/src/EasyCon.WinInput/EasyCon.WinInput.csproj deleted file mode 100644 index 7746aa94..00000000 --- a/src/EasyCon.WinInput/EasyCon.WinInput.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0-windows - warnings - - - - - - - - diff --git a/src/EasyCon.WinInput/GamepadInputBinder.cs b/src/EasyCon.WinInput/GamepadInputBinder.cs deleted file mode 100644 index bf871c8b..00000000 --- a/src/EasyCon.WinInput/GamepadInputBinder.cs +++ /dev/null @@ -1,50 +0,0 @@ -using EasyCon.Core.Config; -using EasyCon.Core.Input; -using EasyDevice; -using GamepadApi; -using System.Runtime.Versioning; - -namespace EasyCon.WinInput; - -[SupportedOSPlatform("windows")] -public class GamepadInputBinder : IInputBinder -{ - private readonly GamepadManager _manager; - private readonly int _deviceIndex; - private readonly NintendoSwitch _ns; - private readonly GamepadMappingConfig _config; - private volatile bool _enabled; - - public GamepadInputBinder(GamepadManager manager, int deviceIndex, NintendoSwitch ns, GamepadMappingConfig config) - { - _manager = manager; - _deviceIndex = deviceIndex; - _ns = ns; - _config = config; - } - - public void Start() - { - _manager.StateChanged += OnStateChanged; - } - - public void Stop() - { - _manager.StateChanged -= OnStateChanged; - _ns.Reset(); - } - - public void SetEnabled(bool enabled) => _enabled = enabled; - - public void UpdateKeyMapping(KeyMappingConfig mapping) { } - - public void RegisterEscapeKey(Func keydown, Func keyup) { } - - private void OnStateChanged(GamepadDevice device, GamepadState state) - { - if (!_enabled) return; - if (device.Index != _deviceIndex) return; - var report = GamepadMapper.Map(state, _config); - _ns.ApplyReport(report); - } -} \ No newline at end of file diff --git a/src/EasyCon.WinInput/GamepadMapper.cs b/src/EasyCon.WinInput/GamepadMapper.cs deleted file mode 100644 index c0cc216f..00000000 --- a/src/EasyCon.WinInput/GamepadMapper.cs +++ /dev/null @@ -1,82 +0,0 @@ -using EasyCon.Core.Config; -using EasyDevice; -using GamepadApi; -using System.Numerics; - -namespace EasyCon.WinInput; - -public static class GamepadMapper -{ - public static SwitchReport Map(GamepadState state, GamepadMappingConfig config) - { - var report = new SwitchReport(); - - MapButtons(state.Buttons, config.IsSwitchXY, report); - MapTriggers(state.LeftTrigger, state.RightTrigger, config.TriggerThreshold, report); - MapHat(state.Buttons, report); - (report.LX, report.LY) = MapStick(state.LeftStick); - (report.RX, report.RY) = MapStick(state.RightStick); - - return report; - } - - static void MapButtons(GamepadButtons buttons, bool isSwitchXY, SwitchReport report) - { - if (isSwitchXY) - { - if (buttons.HasFlag(GamepadButtons.A)) report.Button |= (ushort)SwitchButton.B; - if (buttons.HasFlag(GamepadButtons.B)) report.Button |= (ushort)SwitchButton.A; - if (buttons.HasFlag(GamepadButtons.X)) report.Button |= (ushort)SwitchButton.Y; - if (buttons.HasFlag(GamepadButtons.Y)) report.Button |= (ushort)SwitchButton.X; - } - else - { - if (buttons.HasFlag(GamepadButtons.A)) report.Button |= (ushort)SwitchButton.A; - if (buttons.HasFlag(GamepadButtons.B)) report.Button |= (ushort)SwitchButton.B; - if (buttons.HasFlag(GamepadButtons.X)) report.Button |= (ushort)SwitchButton.X; - if (buttons.HasFlag(GamepadButtons.Y)) report.Button |= (ushort)SwitchButton.Y; - } - - if (buttons.HasFlag(GamepadButtons.LeftShoulder)) report.Button |= (ushort)SwitchButton.L; - if (buttons.HasFlag(GamepadButtons.RightShoulder)) report.Button |= (ushort)SwitchButton.R; - if (buttons.HasFlag(GamepadButtons.LeftThumb)) report.Button |= (ushort)SwitchButton.LCLICK; - if (buttons.HasFlag(GamepadButtons.RightThumb)) report.Button |= (ushort)SwitchButton.RCLICK; - if (buttons.HasFlag(GamepadButtons.Start)) report.Button |= (ushort)SwitchButton.PLUS; - if (buttons.HasFlag(GamepadButtons.Back)) report.Button |= (ushort)SwitchButton.MINUS; - } - - static void MapTriggers(float left, float right, float threshold, SwitchReport report) - { - if (left > threshold) report.Button |= (ushort)SwitchButton.ZL; - if (right > threshold) report.Button |= (ushort)SwitchButton.ZR; - } - - static void MapHat(GamepadButtons buttons, SwitchReport report) - { - var up = buttons.HasFlag(GamepadButtons.DPadUp); - var down = buttons.HasFlag(GamepadButtons.DPadDown); - var left = buttons.HasFlag(GamepadButtons.DPadLeft); - var right = buttons.HasFlag(GamepadButtons.DPadRight); - - report.HAT = (up, down, left, right) switch - { - (true, false, false, false) => (byte)SwitchHAT.TOP, - (false, true, false, false) => (byte)SwitchHAT.BOTTOM, - (false, false, true, false) => (byte)SwitchHAT.LEFT, - (false, false, false, true) => (byte)SwitchHAT.RIGHT, - (true, false, true, false) => (byte)SwitchHAT.TOP_LEFT, - (true, false, false, true) => (byte)SwitchHAT.TOP_RIGHT, - (false, true, true, false) => (byte)SwitchHAT.BOTTOM_LEFT, - (false, true, false, true) => (byte)SwitchHAT.BOTTOM_RIGHT, - _ => (byte)SwitchHAT.CENTER, - }; - } - - static (byte x, byte y) MapStick(Vector2 stick) - { - return ( - (byte)Math.Clamp((int)(stick.X * 127) + 128, 0, 255), - (byte)Math.Clamp((int)(-stick.Y * 127) + 128, 0, 255) - ); - } -} \ No newline at end of file diff --git a/src/EasyCon.WinInput/KeyBinder.cs b/src/EasyCon.WinInput/KeyBinder.cs deleted file mode 100644 index 0fc9d198..00000000 --- a/src/EasyCon.WinInput/KeyBinder.cs +++ /dev/null @@ -1,90 +0,0 @@ -using EasyCon.Core.Config; -using EasyCon.Core.Input; -using EasyDevice; -using System.Runtime.Versioning; - -namespace EasyCon.WinInput; - -[SupportedOSPlatform("windows")] -public class KeyBinder -{ - private bool _controllerEnabled; - - public bool ControllerEnabled - { - get => _controllerEnabled; - set => _controllerEnabled = value; - } - - public void RegisterAllKeys(KeyMappingConfig mapping, NintendoSwitch gamepad) - { - UnregisterAllKeys(); - var llk = LowLevelKeyboard.GetInstance(); - - RegisterKey(llk, mapping.A, ECKeyUtil.Button(SwitchButton.A), gamepad); - RegisterKey(llk, mapping.B, ECKeyUtil.Button(SwitchButton.B), gamepad); - RegisterKey(llk, mapping.X, ECKeyUtil.Button(SwitchButton.X), gamepad); - RegisterKey(llk, mapping.Y, ECKeyUtil.Button(SwitchButton.Y), gamepad); - RegisterKey(llk, mapping.L, ECKeyUtil.Button(SwitchButton.L), gamepad); - RegisterKey(llk, mapping.R, ECKeyUtil.Button(SwitchButton.R), gamepad); - RegisterKey(llk, mapping.ZL, ECKeyUtil.Button(SwitchButton.ZL), gamepad); - RegisterKey(llk, mapping.ZR, ECKeyUtil.Button(SwitchButton.ZR), gamepad); - RegisterKey(llk, mapping.Plus, ECKeyUtil.Button(SwitchButton.PLUS), gamepad); - RegisterKey(llk, mapping.Minus, ECKeyUtil.Button(SwitchButton.MINUS), gamepad); - RegisterKey(llk, mapping.Capture, ECKeyUtil.Button(SwitchButton.CAPTURE), gamepad); - RegisterKey(llk, mapping.Home, ECKeyUtil.Button(SwitchButton.HOME), gamepad); - RegisterKey(llk, mapping.LClick, ECKeyUtil.Button(SwitchButton.LCLICK), gamepad); - RegisterKey(llk, mapping.RClick, ECKeyUtil.Button(SwitchButton.RCLICK), gamepad); - - RegisterKey(llk, mapping.UpRight, ECKeyUtil.HAT(SwitchHAT.TOP_RIGHT), gamepad); - RegisterKey(llk, mapping.DownRight, ECKeyUtil.HAT(SwitchHAT.BOTTOM_RIGHT), gamepad); - RegisterKey(llk, mapping.UpLeft, ECKeyUtil.HAT(SwitchHAT.TOP_LEFT), gamepad); - RegisterKey(llk, mapping.DownLeft, ECKeyUtil.HAT(SwitchHAT.BOTTOM_LEFT), gamepad); - - RegisterKey(llk, mapping.Up, () => gamepad.HatDirection(DirectionKey.Up, true), () => gamepad.HatDirection(DirectionKey.Up, false)); - RegisterKey(llk, mapping.Down, () => gamepad.HatDirection(DirectionKey.Down, true), () => gamepad.HatDirection(DirectionKey.Down, false)); - RegisterKey(llk, mapping.Left, () => gamepad.HatDirection(DirectionKey.Left, true), () => gamepad.HatDirection(DirectionKey.Left, false)); - RegisterKey(llk, mapping.Right, () => gamepad.HatDirection(DirectionKey.Right, true), () => gamepad.HatDirection(DirectionKey.Right, false)); - - RegisterKey(llk, mapping.LSUp, () => gamepad.LeftDirection(DirectionKey.Up, true), () => gamepad.LeftDirection(DirectionKey.Up, false)); - RegisterKey(llk, mapping.LSDown, () => gamepad.LeftDirection(DirectionKey.Down, true), () => gamepad.LeftDirection(DirectionKey.Down, false)); - RegisterKey(llk, mapping.LSLeft, () => gamepad.LeftDirection(DirectionKey.Left, true), () => gamepad.LeftDirection(DirectionKey.Left, false)); - RegisterKey(llk, mapping.LSRight, () => gamepad.LeftDirection(DirectionKey.Right, true), () => gamepad.LeftDirection(DirectionKey.Right, false)); - RegisterKey(llk, mapping.RSUp, () => gamepad.RightDirection(DirectionKey.Up, true), () => gamepad.RightDirection(DirectionKey.Up, false)); - RegisterKey(llk, mapping.RSDown, () => gamepad.RightDirection(DirectionKey.Down, true), () => gamepad.RightDirection(DirectionKey.Down, false)); - RegisterKey(llk, mapping.RSLeft, () => gamepad.RightDirection(DirectionKey.Left, true), () => gamepad.RightDirection(DirectionKey.Left, false)); - RegisterKey(llk, mapping.RSRight, () => gamepad.RightDirection(DirectionKey.Right, true), () => gamepad.RightDirection(DirectionKey.Right, false)); - } - - private void RegisterKey(LowLevelKeyboard llk, int vkCode, ECKey nskey, NintendoSwitch gamepad) - { - RegisterKey(llk, vkCode, () => gamepad.Down(nskey), () => gamepad.Up(nskey)); - } - - private void RegisterKey(LowLevelKeyboard llk, int vkCode, Action keydownAction, Action keyupAction) - { - bool keydown() - { - if (!_controllerEnabled) return false; - keydownAction?.Invoke(); - return true; - } - bool keyup() - { - if (!_controllerEnabled) return false; - keyupAction?.Invoke(); - return true; - } - llk.RegisterKeyEvent(vkCode, keydown, keyup); - } - - public void UnregisterAllKeys() - { - LowLevelKeyboard.GetInstance().UnregisterKeyEventAll(); - } - - public void RegisterEscapeKey(Func keydown, Func keyup) - { - LowLevelKeyboard.GetInstance().RegisterKeyEvent(0x1B, keydown, keyup); - } -} \ No newline at end of file diff --git a/src/EasyCon.WinInput/KeyboardInputBinder.cs b/src/EasyCon.WinInput/KeyboardInputBinder.cs deleted file mode 100644 index 9bc52a98..00000000 --- a/src/EasyCon.WinInput/KeyboardInputBinder.cs +++ /dev/null @@ -1,32 +0,0 @@ -using EasyCon.Core.Config; -using EasyCon.Core.Input; -using EasyDevice; -using System.Runtime.Versioning; - -namespace EasyCon.WinInput; - -[SupportedOSPlatform("windows")] -public class KeyboardInputBinder : IInputBinder -{ - private readonly KeyBinder _keyBinder = new(); - private readonly NintendoSwitch _ns; - private KeyMappingConfig _mapping; - - public KeyboardInputBinder(NintendoSwitch ns, KeyMappingConfig mapping) - { - _ns = ns; - _mapping = mapping; - } - - public void Start() => _keyBinder.RegisterAllKeys(_mapping, _ns); - public void Stop() => _keyBinder.UnregisterAllKeys(); - public void SetEnabled(bool enabled) => _keyBinder.ControllerEnabled = enabled; - public void UpdateKeyMapping(KeyMappingConfig mapping) - { - _mapping = mapping; - _keyBinder.RegisterAllKeys(_mapping, _ns); - } - - public void RegisterEscapeKey(Func keydown, Func keyup) - => _keyBinder.RegisterEscapeKey(keydown, keyup); -} \ No newline at end of file diff --git a/src/EasyCon.WinInput/LowLevelKeyboard.cs b/src/EasyCon.WinInput/LowLevelKeyboard.cs deleted file mode 100644 index 551ec3e9..00000000 --- a/src/EasyCon.WinInput/LowLevelKeyboard.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Runtime.Versioning; - -namespace EasyCon.WinInput; - -[SupportedOSPlatform("windows")] -class LowLevelKeyboard -{ - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); - - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool UnhookWindowsHookEx(IntPtr hhk); - - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr GetModuleHandle(string lpModuleName); - - private const int WH_KEYBOARD_LL = 13; - private const int WM_KEYDOWN = 0x0100; - private const int WM_KEYUP = 0x0101; - private const int WM_SYSKEYDOWN = 0x0104; - private const int WM_SYSKEYUP = 0x0105; - - delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); - readonly LowLevelKeyboardProc _proc; - IntPtr _hookID = IntPtr.Zero; - readonly Dictionary _keyDownResult = []; - readonly Dictionary> _keyDownHandlers = []; - readonly Dictionary> _keyUpHandlers = []; - - static LowLevelKeyboard _instance = new(); - - public static LowLevelKeyboard GetInstance() - { - return _instance; - } - - LowLevelKeyboard() - { - _proc = HookCallback; - _hookID = SetHook(_proc); - } - - ~LowLevelKeyboard() - { - UnhookWindowsHookEx(_hookID); - } - - IntPtr SetHook(LowLevelKeyboardProc proc) - { - using var mainMod = Process.GetCurrentProcess().MainModule; - return SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(mainMod?.ModuleName ?? ""), 0); - } - - IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) - { - if (nCode >= 0 && (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN)) - { - if (KeyEvent(Marshal.ReadInt32(lParam), true)) - return (IntPtr)1; - } - else if (nCode >= 0 && (wParam == (IntPtr)WM_KEYUP || wParam == (IntPtr)WM_SYSKEYUP)) - { - if (KeyEvent(Marshal.ReadInt32(lParam), false)) - return (IntPtr)1; - } - return CallNextHookEx(_hookID, nCode, wParam, lParam); - } - - bool KeyEvent(int vkCode, bool keyDown) - { - var dict = keyDown ? _keyDownHandlers : _keyUpHandlers; - if (!dict.ContainsKey(vkCode)) - return false; - if (keyDown && _keyDownResult.ContainsKey(vkCode)) - return _keyDownResult[vkCode]; - var b = dict[vkCode]?.Invoke() == true; - if (keyDown) - _keyDownResult[vkCode] = b; - else - _keyDownResult.Remove(vkCode); - return b; - } - - public void RegisterKeyEvent(int vkCode, Func keydown, Func keyup) - { - _keyDownHandlers[vkCode] = keydown; - _keyUpHandlers[vkCode] = keyup; - } - - public void UnregisterKeyEvent(int vkCode) - { - _keyDownHandlers.Remove(vkCode); - _keyUpHandlers.Remove(vkCode); - } - - public void UnregisterKeyEventAll() - { - _keyDownHandlers.Clear(); - _keyUpHandlers.Clear(); - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/AgentOrchestrator.cs b/src/EasyCon2.Avalonia.Core/AiAgent/AgentOrchestrator.cs deleted file mode 100644 index 0704b7f7..00000000 --- a/src/EasyCon2.Avalonia.Core/AiAgent/AgentOrchestrator.cs +++ /dev/null @@ -1,618 +0,0 @@ -using EasyCon.Core.LLM; -using EasyCon.Core.LLM.Messages; -using EasyCon.Core.LLM.Models; -using EasyCon.Core.LLM.Skills; -using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.AiAgent.Tools; -using System.IO; -using System.Text; - -namespace EasyCon2.Avalonia.Core.AiAgent; - -/// -/// Agent 编排事件,ViewModel 据此更新 UI。 -/// -public abstract record AgentEvent -{ - /// 新一轮开始,携带当前对话前缀(用于 UI 占位)。 - public record RoundStart(string Prefix) : AgentEvent; - - /// 正文内容增量。 - public record ContentDelta(string Text) : AgentEvent; - - /// 思考/推理内容增量。 - public record ThinkingDelta(string Text) : AgentEvent; - - /// 工具调用开始执行。 - public record ToolExecuting(string Name) : AgentEvent; - - /// 工具调用完成,携带结果摘要。 - public record ToolCompleted(string Name, string Summary, string FullResult) : AgentEvent; - - /// Token 用量更新。 - public record UsageUpdated(int Prompt, int Completion, int Total) : AgentEvent; - - /// 错误信息。 - public record Error(string Message) : AgentEvent; - - /// 流式传输中断,正在重试。 - public record Retrying(int Attempt, int MaxAttempts, string Reason) : AgentEvent; - - /// 对话循环结束。 - public record Completed(string FinalContent) : AgentEvent; - - /// 调试信息(原始 SSE 数据、解析异常等)。 - public record DebugInfo(string Message) : AgentEvent; -} - -/// -/// AI Agent 核心编排器,管理 ReAct 循环、工具调度、prompt 组装。 -/// 与 UI 完全解耦,可独立测试。 -/// -public class AgentOrchestrator -{ - public const int MaxToolRounds = 50; - public const int MaxHistoryMessages = 40; - private const int ToolTimeoutSeconds = 30; - - private readonly ToolRegistry _tools; - private readonly PromptAssembler? _promptAssembler; - private int _frameImageIndex = -1; - private readonly ReflectionState _reflectionState = new(); - - /// - /// 反思状态管理器,追踪执行失败和重复结果,控制反思注入。 - /// - private class ReflectionState - { - private const int MaxReflections = 3; - private const int FailureThreshold = 2; - private const int SameResultThreshold = 2; - - private int _reflectionCount; - private int _consecutiveFailures; - private int _consecutiveSameResults; - private string _lastToolResultHash = string.Empty; - private bool _shouldTriggerReflection; - - /// 用户发送新消息时重置反思状态。 - public void Reset() - { - _reflectionCount = 0; - _consecutiveFailures = 0; - _consecutiveSameResults = 0; - _lastToolResultHash = string.Empty; - _shouldTriggerReflection = false; - } - - /// 追踪工具执行结果。 - public void TrackResult(string toolResult) - { - if (string.IsNullOrEmpty(toolResult)) - return; - - if (toolResult.Contains("[错误]") || toolResult.Contains("[工具执行异常]") || toolResult.Contains("[错误] 工具")) - { - _consecutiveFailures++; - _consecutiveSameResults = 0; // 失败不算相同结果 - if (_consecutiveFailures >= FailureThreshold) - _shouldTriggerReflection = true; - } - else - { - _consecutiveFailures = 0; // 成功重置失败计数 - var hash = toolResult.GetHashCode().ToString(); - if (hash == _lastToolResultHash) - { - _consecutiveSameResults++; - if (_consecutiveSameResults >= SameResultThreshold) - _shouldTriggerReflection = true; - } - else - { - _consecutiveSameResults = 0; - } - _lastToolResultHash = hash; - } - } - - /// 追踪助手回复中的反思标记。 - public void TrackReflectionMarker(string assistantContent) - { - if (!string.IsNullOrEmpty(assistantContent) && assistantContent.Contains("反思:")) - _reflectionCount++; - } - - /// 判断是否需要注入反思提示。 - public bool ShouldInjectReflection() - { - if (_reflectionCount >= MaxReflections) - return false; - - if (!_shouldTriggerReflection) - return false; - - _shouldTriggerReflection = false; - return true; - } - - /// 获取反思深度。 - public string GetReflectionDepth() - { - // 已反思过但仍在循环,或连续失败超过阈值 → 深度反思 - if (_reflectionCount > 0 || _consecutiveFailures > FailureThreshold) - return "deep"; - return "shallow"; - } - - /// 获取反思提示文本。 - public string GetReflectionPrompt() - { - return GetReflectionDepth() == "deep" - ? SystemPrompts.ReflectionDeep - : SystemPrompts.Reflection; - } - } - - public AgentOrchestrator(ToolRegistry tools) : this(tools, skillRegistry: null) { } - - /// - /// 创建带技能体系的编排器。传入的 为空时, - /// 回退到旧的硬编码 SystemPrompts 路径(向后兼容)。 - /// - public AgentOrchestrator(ToolRegistry tools, SkillRegistry? skillRegistry) - { - _tools = tools; - if (skillRegistry is { All.Count: > 0 }) - _promptAssembler = new PromptAssembler(skillRegistry); - } - - /// - /// 重置帧图片索引和反思状态(新对话时调用)。 - /// - public void ResetState() - { - _frameImageIndex = -1; - _reflectionState.Reset(); - } - - /// - /// 执行完整的 Agent 对话循环。 - /// - /// 对话历史(会被修改,追加新消息)。 - /// 模型 ID。 - /// API 供应商配置。 - /// 事件回调,ViewModel 据此更新 UI。 - /// 取消令牌。 - public async Task RunAsync( - List history, - string modelId, - ProviderConfig provider, - Action onEvent, - CancellationToken ct) - { - const int maxStreamRetries = 2; - var toolDefs = _tools.ToToolDefinitions(); - var hasTools = toolDefs.Count > 0; - var pendingReply = new StringBuilder(); - var pendingThinking = new StringBuilder(); - - // 重置反思状态(用户新消息) - _reflectionState.Reset(); - - // 订阅调试日志,转发到 UI - var debugHandler = new Action(msg => onEvent(new AgentEvent.DebugInfo(msg))); - OpenAIChatClient.DebugLog += debugHandler; - try - { - for (var round = 0; round < MaxToolRounds; round++) - { - var streamSuccess = false; - var accumulator = new ToolCallAccumulator(); - var hasContent = false; - - // RoundStart 在流式重试循环之外发射,确保即使重试 UI 也只创建一个 AssistantMessage - onEvent(new AgentEvent.RoundStart("")); - pendingReply.Clear(); - pendingThinking.Clear(); - - // ── 流式断线重连循环 ── - for (var streamRetry = 0; streamRetry <= maxStreamRetries; streamRetry++) - { - accumulator = new ToolCallAccumulator(); - - var client = ChatClientFactory.Create(provider); - var request = new ChatRequest - { - Model = modelId, - Messages = BuildMessages(history, round) - }; - if (hasTools) - { - request.Tools = toolDefs; - request.ToolChoice = ToolChoice.Auto; - } - - var retryableError = false; - hasContent = false; - - await foreach (var delta in client.SendStreamAsync(request, ct)) - { - switch (delta.Type) - { - case DeltaType.Thinking: - pendingThinking.Append(delta.Text); - onEvent(new AgentEvent.ThinkingDelta(delta.Text)); - break; - case DeltaType.Content: - pendingReply.Append(delta.Text); - hasContent = true; - onEvent(new AgentEvent.ContentDelta(delta.Text)); - break; - case DeltaType.ToolCall: - if (delta.ToolCallDelta is not null) - accumulator.Append(delta.ToolCallDelta); - break; - case DeltaType.Error: - if (delta.Retryable) - { - // 流式传输中断 → 标记为重试,不追加到回复 - retryableError = true; - } - else - { - pendingReply.Append($"[错误] {delta.Text}"); - onEvent(new AgentEvent.Error(delta.Text)); - } - break; - case DeltaType.Usage: - onEvent(new AgentEvent.UsageUpdated(delta.PromptTokens, delta.CompletionTokens, delta.TotalTokens)); - break; - } - - if (retryableError) break; - } - - // 可重试错误 → 丢弃本轮部分数据,等待后重发同一轮请求 - if (retryableError && streamRetry < maxStreamRetries) - { - onEvent(new AgentEvent.Retrying( - streamRetry + 1, maxStreamRetries, "流式传输中断,正在重新连接...")); - await Task.Delay(TimeSpan.FromSeconds(2 * (streamRetry + 1)), ct); - continue; - } - - // 可重试错误但重试已耗尽 - if (retryableError) - { - onEvent(new AgentEvent.Error("流式传输多次中断,无法恢复")); - history.Add(ChatMessage.Assistant(pendingReply.ToString())); - onEvent(new AgentEvent.Completed(pendingReply.ToString() + "\n[连接中断]")); - return; - } - - streamSuccess = true; - break; - } - - if (!streamSuccess) continue; - - // 没有工具调用或没有注册工具 → 本轮即最终回复 - var toolCalls = accumulator.Build(); - if (!hasTools || toolCalls.Count == 0) - { - if (hasContent) - { - history.Add(ChatMessage.Assistant(pendingReply.ToString())); - _reflectionState.TrackReflectionMarker(pendingReply.ToString()); - } - onEvent(new AgentEvent.Completed(pendingReply.ToString())); - return; - } - - // 将 assistant 的工具调用加入历史 - history.Add(ChatMessage.Assistant(toolCalls)); - - // 执行工具调用 - var results = await ExecuteToolCallsAsync(toolCalls, history, onEvent, ct); - - // 按顺序将工具结果加入历史,并处理多模态附加消息 - for (var i = 0; i < toolCalls.Count; i++) - { - history.Add(ChatMessage.Tool(toolCalls[i].Id, results[i].Content, toolCalls[i].Function.Name)); - _reflectionState.TrackResult(results[i].Content); - - // 处理附加的多模态消息(如 get_frame 的图片) - if (results[i].AttachedMessage is { } img) - { - if (_frameImageIndex >= 0 && _frameImageIndex < history.Count) - history[_frameImageIndex] = img; - else - { - _frameImageIndex = history.Count; - history.Add(img); - } - } - } - } - - // 达到最大轮次 - history.Add(ChatMessage.Assistant(pendingReply.ToString())); - onEvent(new AgentEvent.Completed(pendingReply.ToString() + "\n[已达到工具调用最大轮次]")); - } - finally - { - OpenAIChatClient.DebugLog -= debugHandler; - } - } - - /// - /// 批量执行工具调用(并行执行,保持结果顺序)。 - /// - private async Task> ExecuteToolCallsAsync( - List toolCalls, - List history, - Action onEvent, - CancellationToken ct) - { - if (toolCalls.Count == 1) - { - var result = await ExecuteToolCallAsync(toolCalls[0], history, onEvent, ct); - return [result]; - } - - var tasks = toolCalls.Select(tc => ExecuteToolCallAsync(tc, history, onEvent, ct)).ToArray(); - var results = await Task.WhenAll(tasks); - return results.ToList(); - } - - /// - /// 执行单个工具调用,返回完整 ToolResult(含可能的多模态附加消息)。 - /// - private async Task ExecuteToolCallAsync( - ToolCall toolCall, - List history, - Action onEvent, - CancellationToken ct) - { - var fn = toolCall.Function; - var tool = _tools.Get(fn.Name); - if (tool is null) - return ToolResult.Error($"[错误] 未知工具: {fn.Name}"); - - try - { - onEvent(new AgentEvent.ToolExecuting(fn.Name)); - - var args = fn.ParseArguments(); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(ToolTimeoutSeconds)); - - var result = await tool.ExecuteAsync(args, timeoutCts.Token); - var summary = Truncate(result.Content, 200); - onEvent(new AgentEvent.ToolCompleted(fn.Name, summary, result.Content)); - return result; - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - return ToolResult.Error($"[错误] 工具 {fn.Name} 执行超时({ToolTimeoutSeconds}秒),请调整参数后重试。"); - } - catch (Exception ex) - { - return ToolResult.Error($"[工具执行异常] {ex.Message}"); - } - } - - /// - /// 测试钩子:模拟连续工具失败以触发反思注入。走真实的 TrackResult 阈值路径。 - /// 仅供单元测试使用,生产代码不应调用。 - /// - internal void TestHook_SimulateReflectionTrigger() - { - // FailureThreshold = 2:连续两次失败结果即触发 _shouldTriggerReflection - _reflectionState.TrackResult("[错误] 模拟失败 1"); - _reflectionState.TrackResult("[错误] 模拟失败 2"); - } - - /// - /// 组装发送给模型的消息列表。 - /// 结构: - /// [0] system: 基础角色提示词(干净,不包裹) - /// [1..N] user: <system-reminder> 包裹的技能内容 / 反思 / 截断 / AGENTS.md 上下文 - /// 最后为对话历史。 - /// - internal List BuildMessages(List history, int currentRound = 0) - { - // 基础角色提示词(始终使用 system 角色,不包裹) - var identityPrompt = _promptAssembler is not null - ? _promptAssembler.GetIdentityPrompt() - : SystemPrompts.Identity; - var baseRolePrompt = _promptAssembler is not null - ? _promptAssembler.GetBaseRolePrompt() - : SystemPrompts.Default; - - // 技能内容(路径 A:PromptAssembler / 路径 B:Legacy BuildSystemPrompt) - var skillContent = _promptAssembler is not null - ? _promptAssembler.Build(history, currentRound) - : BuildSystemPrompt(history, currentRound); - - // 反思提示 - var reflectionPrompt = _reflectionState.ShouldInjectReflection() - ? _reflectionState.GetReflectionPrompt() - : null; - - // 滑动窗口截断 - List body; - string? truncationNotice = null; - if (history.Count <= MaxHistoryMessages) - { - body = history; - } - else - { - var aligned = SkipToSafeBoundary(history, history.Count - MaxHistoryMessages); - truncationNotice = $"[系统提示] 为控制上下文长度,前面的 {history.Count - aligned.Count} 条对话已被省略。"; - body = aligned; - } - - var messages = new List(); - - // ── [0] system: 身份标识(仅一句)── - messages.Add(ChatMessage.System(identityPrompt)); - - // ── [1] system: 基础角色提示词(干净,不包裹)── - messages.Add(ChatMessage.System(baseRolePrompt)); - - // ── [1] user: 技能内容 ── - if (!string.IsNullOrWhiteSpace(skillContent)) - messages.Add(ChatMessage.User($"\n{skillContent}\n")); - - // ── [2] user: 反思提示 ── - if (reflectionPrompt is not null) - messages.Add(ChatMessage.User($"\n{reflectionPrompt}\n")); - - // ── [3] user: 截断说明 ── - if (truncationNotice is not null) - messages.Add(ChatMessage.User($"\n{truncationNotice}\n")); - - // ── [4] user: AGENTS.md 上下文 ── - var agentsMdPath = FindAgentsMd(); - var currentDate = DateTime.Now.ToString("yyyy-MM-dd"); - messages.Add(ChatMessage.User($@" -As you answer the user's questions, you can use the following context: -Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written. - -Contents of {agentsMdPath ?? "AGENTS.md"} (user default instructions): - -# currentDate -Today's date is {currentDate}. - -IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task. -")); - - // ── [5..n] 对话历史 ── - messages.AddRange(body); - return messages; - } - - /// - /// 滑动窗口截断时,把截断点对齐到可作为对话起点的消息。 - /// 直接截断可能落在 tool 消息上,形成"孤儿 tool"(缺少前置的 - /// assistant.tool_calls),违反多数供应商的消息顺序约束。 - /// 这里向前跳过连续的 tool 消息,直到落在 user / assistant。 - /// - private static List SkipToSafeBoundary(List history, int skip) - { - var start = Math.Min(skip, history.Count); - while (start < history.Count && history[start].Role == "tool") - start++; - return history.Skip(start).ToList(); - } - - /// - /// 查找 AGENTS.md 文件路径。按以下优先级: - /// 1. 当前工作目录向上查找 - /// 2. 应用程序基目录向上查找 - /// 未找到返回 null。 - /// - private static string? FindAgentsMd() - { - var dir = Environment.CurrentDirectory; - var path = FindInParents(dir, "AGENTS.md"); - if (path is not null) return path; - - dir = AppContext.BaseDirectory; - return FindInParents(dir, "AGENTS.md"); - } - - /// - /// 从指定目录开始向上查找文件,直到文件系统根。 - /// - private static string? FindInParents(string startDir, string fileName) - { - var dir = startDir; - while (!string.IsNullOrEmpty(dir)) - { - var path = Path.Combine(dir, fileName); - if (File.Exists(path)) return path; - var parent = Path.GetDirectoryName(dir); - if (parent == dir) break; - dir = parent!; - } - return null; - } - - /// - /// [Legacy fallback] 旧的硬编码技能内容组装逻辑(不含基础角色提示词)。 - /// 仅当编排器未注入 SkillRegistry 时使用(向后兼容旧测试与旧调用方)。 - /// 基础角色提示词(SystemPrompts.Default)由 BuildMessages 以 system 角色单独发送。 - /// - /// 基于工具调用历史决定注入哪些 prompt 段落,关键词检测作为 fallback。 - /// - internal string BuildSystemPrompt(List history, int currentRound) - { - var sb = new StringBuilder(); - - // 扫描历史中实际的工具调用 - var usedTools = history - .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) - .SelectMany(m => m.ToolCalls!) - .Select(tc => tc.Function.Name) - .ToHashSet(StringComparer.Ordinal); - - var needsScriptSyntax = usedTools.Any(t => - t is "read_script" or "write_script" or "edit_script" - or "compile_script" or "format_script" or "grep_script"); - if (!needsScriptSyntax) - needsScriptSyntax = HasRecentUserKeyword(history, ["脚本", "script", "ecs", "语法", "代码", "编写"]); - - var needsVision = usedTools.Contains("get_frame"); - if (!needsVision) - needsVision = HasRecentUserKeyword(history, ["画面", "屏幕", "截图", "看看", "识别", "看到", "图像"]); - - var needsExecution = usedTools.Any(t => t is "run_script" or "stop_script"); - if (!needsExecution) - needsExecution = HasRecentUserKeyword(history, ["运行", "执行", "自动", "跑脚本"]); - - if (needsScriptSyntax) - sb.Append(SystemPrompts.ScriptSyntax); - - if (needsVision && needsExecution) - { - sb.Append(SystemPrompts.ReActLoop); - } - else - { - if (needsVision) - sb.Append(SystemPrompts.Vision); - if (needsExecution) - sb.Append(SystemPrompts.ScriptExecution); - } - - if (currentRound > 0) - { - var remaining = MaxToolRounds - currentRound; - sb.Append("\n\n[系统] 当前已使用 ") - .Append(currentRound).Append(" 轮工具调用,剩余 ").Append(remaining).Append(" 轮。"); - if (remaining <= 10) - sb.Append(" 轮次即将耗尽,请尽快完成目标并回复用户。"); - } - - return sb.ToString(); - } - - private static bool HasRecentUserKeyword(List history, string[] keywords) - { - return history - .Where(m => m.Role == "user") - .TakeLast(3) - .Select(m => m.Content?.ToString()?.ToLowerInvariant() ?? "") - .Any(msg => keywords.Any(k => msg.Contains(k))); - } - - private static string Truncate(string text, int max) - { - if (string.IsNullOrEmpty(text)) return ""; - return text.Length <= max ? text : text[..max] + "..."; - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/DefaultTools.cs b/src/EasyCon2.Avalonia.Core/AiAgent/Tools/DefaultTools.cs deleted file mode 100644 index b6fb0f47..00000000 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/DefaultTools.cs +++ /dev/null @@ -1,27 +0,0 @@ -using EasyCon2.Avalonia.Core.Services; - -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; - -/// -/// 注册默认 AI 工具集。 -/// 新增工具只需在此方法中添加一行 Register 调用。 -/// -public static class DefaultTools -{ - public static void RegisterAll(ToolRegistry registry, IToolCallService service) - { - registry.Register(new ReadScriptTool(service)); - registry.Register(new WriteScriptTool(service)); - registry.Register(new EditScriptTool(service)); - registry.Register(new GrepScriptTool(service)); - registry.Register(new CompileScriptTool(service)); - registry.Register(new FormatScriptTool(service)); - registry.Register(new GetDeviceStatusTool(service)); - registry.Register(new GetLogsTool(service)); - registry.Register(new GetProjectTreeTool(service)); - registry.Register(new RunScriptTool(service)); - registry.Register(new StopScriptTool(service)); - registry.Register(new GetFrameTool(service)); - registry.Register(new GetWeatherTool()); - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetFrameTool.cs b/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetFrameTool.cs deleted file mode 100644 index fb4cac78..00000000 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetFrameTool.cs +++ /dev/null @@ -1,43 +0,0 @@ -using EasyCon.Core.LLM.Messages; -using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; -using System.Text.Json; - -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; - -/// -/// get_frame 工具:获取当前视频帧并以图片形式注入对话上下文,让模型理解画面。 -/// 图片数据通过 ToolResult.AttachedMessage 返回,由编排层注入历史。 -/// -public class GetFrameTool : IAiTool -{ - private readonly IToolCallService _service; - - public GetFrameTool(IToolCallService service) => _service = service; - - public string Name => "get_frame"; - - public string Description => "截取当前视频画面并让 AI 理解图像内容。调用后 AI 会自动看到当前画面并进行分析。"; - - public JsonSchema Parameters => new() { Type = "object" }; - - public Task ExecuteAsync(Dictionary args, CancellationToken ct = default) - { - var status = _service.GetDeviceStatus(); - if (!status.IsCaptureConnected) - return Task.FromResult(ToolResult.Error("视频源未连接,请先连接视频源后再试。")); - - var base64 = _service.GetCurrentFrameBase64(); - if (base64 is null) - return Task.FromResult(ToolResult.Error("帧获取失败,请检查视频源连接状态。")); - - // 图片消息通过 ToolResult.AttachedMessage 返回,由编排层注入历史 - var imageMessage = ChatMessage.User( - [ - ContentPart.FromText("当前视频画面:"), - ContentPart.FromImageBase64("image/png", base64) - ]); - - return Task.FromResult(ToolResult.Ok("已获取当前画面,正在分析...", imageMessage)); - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetWeatherTool.cs b/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetWeatherTool.cs deleted file mode 100644 index dac703e0..00000000 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/GetWeatherTool.cs +++ /dev/null @@ -1,121 +0,0 @@ -using EasyCon.Core.LLM.Tools; -using System.Net.Http; -using System.Text.Json; - -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; - -/// -/// get_weather 工具:查询指定城市的当前天气信息。 -/// 使用 wttr.in 免费天气 API,无需 API Key。 -/// -public class GetWeatherTool : IAiTool -{ - private static readonly HttpClient _httpClient = new() - { - Timeout = TimeSpan.FromSeconds(10) - }; - - public string Name => "get_weather"; - - public string Description => "获取指定城市或地点的当前天气信息,包括温度、湿度、风速、天气描述等。"; - - public JsonSchema Parameters => new() - { - Type = "object", - Properties = new() - { - ["location"] = new JsonSchemaProperty - { - Type = "string", - Description = "城市名称或地点(如 '北京'、'Tokyo'、'New York')" - } - }, - Required = ["location"] - }; - - public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) - { - if (!args.TryGetValue("location", out var locationEl) || locationEl.ValueKind != JsonValueKind.String) - return ToolResult.Error("[错误] 缺少必需参数: location"); - - var location = locationEl.GetString(); - if (string.IsNullOrWhiteSpace(location)) - return ToolResult.Error("[错误] location 参数不能为空"); - - try - { - var encodedLocation = Uri.EscapeDataString(location); - var url = $"https://wttr.in/{encodedLocation}?format=j1"; - - var response = await _httpClient.GetStringAsync(url, ct); - var weatherData = JsonSerializer.Deserialize(response); - - if (weatherData?.current_condition is not { Length: > 0 }) - return ToolResult.Error($"[错误] 无法获取 {location} 的天气数据"); - - var current = weatherData.current_condition[0]; - var result = new - { - location, - temperature_C = current.temp_C, - temperature_F = current.temp_F, - feels_like_C = current.FeelsLikeC, - feels_like_F = current.FeelsLikeF, - humidity = current.humidity, - description = current.weatherDesc is { Length: > 0 } ? current.weatherDesc[0].value : "未知", - wind_speed_kmph = current.windspeedKmph, - wind_direction = current.winddir16Point, - visibility_km = current.visibility, - pressure_mb = current.pressure, - uv_index = current.uvIndex, - observation_time = current.observation_time - }; - - var json = JsonSerializer.Serialize(result, new JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }); - - return ToolResult.Ok($"🌤️ {location} 当前天气:\n{json}"); - } - catch (HttpRequestException ex) - { - return ToolResult.Error($"[错误] 天气 API 请求失败: {ex.Message}"); - } - catch (TaskCanceledException) - { - return ToolResult.Error($"[错误] 天气查询超时,请稍后重试"); - } - catch (Exception ex) - { - return ToolResult.Error($"[错误] 天气查询失败: {ex.Message}"); - } - } - - private class WeatherResponse - { - public CurrentCondition[]? current_condition { get; set; } - } - - private class CurrentCondition - { - public string? temp_C { get; set; } - public string? temp_F { get; set; } - public string? FeelsLikeC { get; set; } - public string? FeelsLikeF { get; set; } - public string? humidity { get; set; } - public string? windspeedKmph { get; set; } - public string? winddir16Point { get; set; } - public string? visibility { get; set; } - public string? pressure { get; set; } - public string? uvIndex { get; set; } - public string? observation_time { get; set; } - public WeatherDesc[]? weatherDesc { get; set; } - } - - private class WeatherDesc - { - public string? value { get; set; } - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/IAiTool.cs b/src/EasyCon2.Avalonia.Core/AiAgent/Tools/IAiTool.cs deleted file mode 100644 index fe05a8f9..00000000 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/IAiTool.cs +++ /dev/null @@ -1,32 +0,0 @@ -using EasyCon.Core.LLM.Tools; -using System.Text.Json; - -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; - -/// -/// 通用 AI 工具接口。每个工具负责声明自身 schema 并执行调用。 -/// -public interface IAiTool -{ - /// - /// 工具唯一名称,模型通过此名称调用。 - /// - string Name { get; } - - /// - /// 工具描述,供模型理解何时使用。 - /// - string Description { get; } - - /// - /// 参数 JSON Schema。 - /// - JsonSchema Parameters { get; } - - /// - /// 执行工具调用,返回结果(含状态信息,回传给模型)。 - /// - /// 已解析的参数字典,可能为空。 - /// 取消令牌。 - Task ExecuteAsync(Dictionary args, CancellationToken ct = default); -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/RunScriptTool.cs b/src/EasyCon2.Avalonia.Core/AiAgent/Tools/RunScriptTool.cs deleted file mode 100644 index 9ee203f2..00000000 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/RunScriptTool.cs +++ /dev/null @@ -1,34 +0,0 @@ -using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Services; -using System.Text.Json; - -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; - -/// -/// run_script 工具:编译并运行当前编辑区的脚本。 -/// -public class RunScriptTool : IAiTool -{ - private readonly IToolCallService _service; - - public RunScriptTool(IToolCallService service) => _service = service; - - public string Name => "run_script"; - - public string Description => "编译并运行当前编辑区的脚本。脚本在后台运行,不会阻塞对话。"; - - public JsonSchema Parameters => new() { Type = "object" }; - - public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) - { - var status = _service.GetDeviceStatus(); - - if (_service.IsScriptRunning) - return ToolResult.Retryable("脚本已在运行中。", "请先调用 stop_script 停止当前脚本,再重新运行。"); - - var ok = await _service.RunScriptAsync(); - return ok - ? ToolResult.Ok("脚本已启动运行。") - : ToolResult.Retryable("编译失败,无法运行。", "请调用 get_logs 查看错误信息,修复后重新编译运行。"); - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AlertConfig/AlertConfigViewModel.cs b/src/EasyCon2.Avalonia.Core/AlertConfig/AlertConfigViewModel.cs deleted file mode 100644 index 181b38dd..00000000 --- a/src/EasyCon2.Avalonia.Core/AlertConfig/AlertConfigViewModel.cs +++ /dev/null @@ -1,141 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using EasyCon.Core.Config; -using System.Collections.ObjectModel; -using AlertConfigType = EasyCon.Core.Config.AlertConfig; - -namespace EasyCon2.Avalonia.Core.AlertConfig; - -public partial class AlertConfigViewModel : ObservableObject -{ - private const int MaxVisibleItems = 5; - private List _allViewModels = []; - - [ObservableProperty] - private ObservableCollection visibleItems = []; - - [ObservableProperty] - private int hiddenCount; - - [ObservableProperty] - private bool hasHiddenItems; - - [ObservableProperty] - private bool canAdd; - - [ObservableProperty] - private string _errorMessage = ""; - - [ObservableProperty] - private bool _hasError; - - public Action? OnSaveCallback { get; set; } - - public void Load() - { - var config = ConfigManager.LoadAlert(); - _allViewModels = [.. config.alerts.Select(CreateViewModel)]; - RefreshVisibleItems(); - } - - public bool Save() - { - if (!Validate(out var error)) - { - ErrorMessage = error; - HasError = true; - return false; - } - - var config = new AlertConfigType - { - timeout = 10, - alerts = [.. _allViewModels.Select(vm => vm.ToAlertItem())] - }; - ConfigManager.SaveAlert(config); - OnSaveCallback?.Invoke(); - return true; - } - - private bool Validate(out string error) - { - for (var i = 0; i < _allViewModels.Count; i++) - { - var vm = _allViewModels[i]; - var label = _allViewModels.Count > 1 ? $"第 {i + 1} 个推送" : "推送"; - - if (string.IsNullOrWhiteSpace(vm.Name)) - { - error = $"{label}:名称不能为空"; - return false; - } - - if (string.IsNullOrWhiteSpace(vm.Url)) - { - error = $"{label}:URL 不能为空"; - return false; - } - } - - error = ""; - return true; - } - - [RelayCommand] - private void AddItem() - { - if (VisibleItems.Count >= MaxVisibleItems) return; - - var vm = CreateViewModel(new AlertItem { name = "" }); - _allViewModels.Add(vm); - VisibleItems.Add(vm); - UpdateHiddenState(); - ClearError(); - } - - private void OnItemDeleteRequested(AlertItemViewModel item) - { - var visIdx = VisibleItems.IndexOf(item); - if (visIdx < 0) return; - - _allViewModels.Remove(item); - VisibleItems.RemoveAt(visIdx); - - if (_allViewModels.Count > VisibleItems.Count) - { - var next = _allViewModels[VisibleItems.Count]; - if (!VisibleItems.Contains(next)) - VisibleItems.Add(next); - } - - UpdateHiddenState(); - ClearError(); - } - - private void ClearError() - { - ErrorMessage = ""; - HasError = false; - } - - private AlertItemViewModel CreateViewModel(AlertItem item) - { - var vm = new AlertItemViewModel(item); - vm.RequestDelete = OnItemDeleteRequested; - return vm; - } - - private void RefreshVisibleItems() - { - VisibleItems = new ObservableCollection( - _allViewModels.Take(MaxVisibleItems)); - UpdateHiddenState(); - } - - private void UpdateHiddenState() - { - HiddenCount = Math.Max(0, _allViewModels.Count - VisibleItems.Count); - HasHiddenItems = HiddenCount > 0; - CanAdd = VisibleItems.Count < MaxVisibleItems; - } -} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AlertConfig/AlertItemView.axaml b/src/EasyCon2.Avalonia.Core/AlertConfig/AlertItemView.axaml deleted file mode 100644 index ed06afb0..00000000 --- a/src/EasyCon2.Avalonia.Core/AlertConfig/AlertItemView.axaml +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - public void ReloadSkills() { + // 生成中技能表正被编排器后台线程读取,清空/重装会与之竞争(与 NewChat 同款守卫) + if (IsGenerating) + return; _skills.Clear(); try { @@ -149,13 +196,13 @@ public void ReloadSkills() var paths = SkillLoader.GetSearchPaths(projectDir).ToList(); SkillLoader.LoadToRegistry(_skills, paths); - Console.WriteLine( + _diagLog?.Invoke( $"[AiAgent] 技能加载完成: 共 {_skills.All.Count} 个技能, " + $"搜索路径: [{string.Join(", ", paths)}]"); } catch (Exception ex) { - Console.WriteLine( + _diagLog?.Invoke( $"[AiAgent] 技能加载失败: {ex.Message}"); } } @@ -164,12 +211,19 @@ partial void OnSelectedEntryChanged(ModelEntry? value) { if (_toolCallService is null) { - Console.WriteLine("工具服务为空!!"); + _diagLog?.Invoke("[AiAgent] 工具服务为空,无法重建编排器"); return; } - // 模型切换后重建编排器(注入技能体系) - _orchestrator = new AgentOrchestrator(_tools, _skills); + // 生成中重建编排器会与在跑的 RunAsync 并发写同一份历史,与 NewChat 同款守卫 + if (IsGenerating) + return; + + // 模型切换后重建编排器(注入技能体系 + 脚本项目目录,供 AGENTS.md 装配) + _orchestrator = new AgentOrchestrator(_tools, _skills, + clientFactory: null, + projectDirectoryProvider: () => _toolCallService?.GetProjectDirectory(), + confirmationHandler: RequestConfirmationAsync); } partial void OnIsOpenChanged(bool value) @@ -196,7 +250,7 @@ private async Task InitializeMcpAsync() catch (Exception ex) { // MCP 初始化失败不阻塞 Agent,仅记录日志。 - Dispatcher.UIThread.Post(() => Messages.Add(new ToolCallMessage { Name = "MCP", Summary = $"初始化失败: {ex.Message}", Success = false })); + _ui.Post(() => Messages.Add(new ToolCallMessage { Name = "MCP", Summary = $"初始化失败: {ex.Message}", Success = false })); } } @@ -216,7 +270,7 @@ private async Task RefreshMcpAsync() } catch (Exception ex) { - Dispatcher.UIThread.Post(() => Messages.Add(new ToolCallMessage { Name = "MCP", Summary = $"刷新失败: {ex.Message}", Success = false })); + _ui.Post(() => Messages.Add(new ToolCallMessage { Name = "MCP", Summary = $"刷新失败: {ex.Message}", Success = false })); } } @@ -239,11 +293,16 @@ private void Close() [RelayCommand] private void NewChat() { + // 生成中清空会与编排器后台线程的 _history/Messages 写入竞争,直接忽略 + if (IsGenerating) + return; + _history.Clear(); _orchestrator?.ResetState(); _currentAssistant = null; _pendingTools.Clear(); _totalTokensUsed = 0; + _sessionVersion++; Messages.Clear(); TokenUsage = "tokens: --"; SessionTitle = "AI Agent"; @@ -255,7 +314,19 @@ private async Task SendOrStopAsync() { if (IsGenerating) { - _cts?.CancelAsync(); + // 读入局部变量:首个调用的 finally 可能正在 Dispose 同一实例 + var cts = _cts; + if (cts is not null) + { + try + { + await cts.CancelAsync(); + } + catch (ObjectDisposedException) + { + // 首个调用刚好收尾完成,无需取消 + } + } return; } @@ -271,6 +342,25 @@ private async Task SendOrStopAsync() return; } + // 发送前 fail-fast 校验供应商配置:配置错误直接给出可读提示, + // 而不是发出注定失败的请求后静默(或抛出晦涩的英文异常) + if (!TryGetProviderConfig(SelectedEntry.ProviderKey, out var provider)) + { + Messages.Add(new ToolCallMessage + { + Name = "提示", + Summary = ValidateProvider(SelectedEntry.ProviderKey, null)!, + Success = false + }); + return; + } + + if (ValidateProvider(SelectedEntry.ProviderKey, provider) is { } validationError) + { + Messages.Add(new ToolCallMessage { Name = "提示", Summary = validationError, Success = false }); + return; + } + var message = InputText.Trim(); InputText = ""; IsGenerating = true; @@ -281,31 +371,38 @@ private async Task SendOrStopAsync() _cts = new CancellationTokenSource(); try { - _orchestrator ??= new AgentOrchestrator(_tools, _skills); + _orchestrator ??= new AgentOrchestrator(_tools, _skills, + clientFactory: null, + projectDirectoryProvider: () => _toolCallService?.GetProjectDirectory(), + confirmationHandler: RequestConfirmationAsync); - var provider = GetProviderConfig(SelectedEntry.ProviderKey); + // 视觉能力由编排器从 provider+modelId 对应的模型条目解析 await _orchestrator.RunAsync(_history, SelectedEntry.ModelId, provider, HandleAgentEvent, _cts.Token); - // 首次请求结束后异步生成对话标题(不阻塞主流程) + // 首次请求结束后异步生成对话标题(不阻塞主流程); + // 请求不绑会话取消令牌,以会话版本号防止旧标题写进新会话 if (!_hasGeneratedTitle) { _hasGeneratedTitle = true; var firstMessage = message; - var capturedProvider = provider; - _ = GenerateTitleAsync(firstMessage, capturedProvider, SelectedEntry.ModelId); + var session = _sessionVersion; + _ = GenerateTitleAsync(firstMessage, provider, SelectedEntry.ModelId, session); } } catch (OperationCanceledException) { var prev = _currentAssistant; if (prev is not null) - Dispatcher.UIThread.Post(() => prev.Content += "\n[已停止]"); + _ui.Post(() => prev.Content += "\n[已停止]"); } catch (Exception ex) { + _diagLog?.Invoke($"[AiAgent] 发送失败: {ex}"); var prev = _currentAssistant; if (prev is not null) - Dispatcher.UIThread.Post(() => prev.Content += $"\n[错误] {ex.Message}"); + _ui.Post(() => prev.Content += $"\n[错误] {ex.Message}"); + else + _ui.Post(() => Messages.Add(new ToolCallMessage { Name = "错误", Summary = ex.Message, Success = false })); } finally { @@ -313,7 +410,7 @@ private async Task SendOrStopAsync() _pendingThinking.Clear(); var prev = _currentAssistant; if (prev is not null) - Dispatcher.UIThread.Post(() => prev.IsStreaming = false); + _ui.Post(() => prev.IsStreaming = false); _currentAssistant = null; _pendingTools.Clear(); _cts?.Dispose(); @@ -323,6 +420,45 @@ private async Task SendOrStopAsync() } /// + /// 待确认工具的 TCS:事件到达 UI 前先挂起编排器线程。 + private TaskCompletionSource? _pendingConfirmation; + + /// 是否有等待人工确认的危险工具调用。 + public bool HasPendingConfirmation => PendingConfirmationTool is not null; + + /// + /// 编排器的危险工具确认回调:挂起直到用户通过 + /// 应答,或会话被取消/结束。 + /// + private Task RequestConfirmationAsync(ToolCall toolCall) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Interlocked.Exchange(ref _pendingConfirmation, tcs); + return tcs.Task; + } + + [RelayCommand] + private void ApproveConfirmation() => RespondToConfirmation(true); + + [RelayCommand] + private void RejectConfirmation() => RespondToConfirmation(false); + + /// 用户对危险工具确认框的应答入口(由视图层绑定调用)。 + public void RespondToConfirmation(bool approved) + { + var tcs = Interlocked.Exchange(ref _pendingConfirmation, null); + tcs?.TrySetResult(approved); + ClearPendingConfirmation(); + } + + private void ClearPendingConfirmation() + { + Interlocked.Exchange(ref _pendingConfirmation, null)?.TrySetResult(false); + PendingConfirmationTool = null; + PendingConfirmationReason = null; + OnPropertyChanged(nameof(HasPendingConfirmation)); + } + /// 处理编排器事件,更新 UI 状态。 /// 连续事件(ContentDelta / ThinkingDelta)经过节流,避免 UI 线程被高频更新淹没。 /// 离散事件(ToolExecuting / ToolCompleted 等)直接派发到 UI 线程。 @@ -340,14 +476,14 @@ private void HandleAgentEvent(AgentEvent evt) _pendingTools.Clear(); if (prev is not null) { - Dispatcher.UIThread.Post(() => + _ui.Post(() => { prev.IsStreaming = false; if (prev.Thinking is not null) prev.Thinking.IsExpanded = false; - }, DispatcherPriority.Background); + }); } - Dispatcher.UIThread.Post(() => Messages.Add(_currentAssistant), DispatcherPriority.Background); + _ui.Post(() => Messages.Add(_currentAssistant)); break; case AgentEvent.ContentDelta d: @@ -362,33 +498,46 @@ private void HandleAgentEvent(AgentEvent evt) case AgentEvent.ToolExecuting t: var toolMsg = new ToolCallMessage { Name = t.Name, Success = false, Summary = "执行中..." }; - _pendingTools[t.Name] = toolMsg; - Dispatcher.UIThread.Post(() => Messages.Add(toolMsg), DispatcherPriority.Background); + _pendingTools[t.Id] = toolMsg; + _ui.Post(() => Messages.Add(toolMsg)); break; case AgentEvent.ToolCompleted t: - if (_pendingTools.TryGetValue(t.Name, out var pending)) + if (_pendingTools.TryGetValue(t.Id, out var pending)) { var msg = pending; - Dispatcher.UIThread.Post(() => + _ui.Post(() => { msg.Success = true; msg.Summary = t.Summary; msg.FullResult = t.FullResult; - }, DispatcherPriority.Background); + }); } break; + case AgentEvent.ToolAwaitingConfirmation a: + _ui.Post(() => + { + PendingConfirmationTool = a.Name; + PendingConfirmationReason = a.Reason; + OnPropertyChanged(nameof(HasPendingConfirmation)); + }); + break; + case AgentEvent.UsageUpdated u: - Dispatcher.UIThread.Post(() => + _ui.Post(() => { _totalTokensUsed += u.Total; TokenUsage = $"本轮: {u.Total} tokens | 累计: {_totalTokensUsed}"; - }, DispatcherPriority.Background); + }); break; case AgentEvent.Retrying r: - Dispatcher.UIThread.Post(() => + // 编排器重发同一轮请求会丢弃失败尝试已收到的部分数据, + // 缓冲同步清空,防止重连后内容重复累积 + _pendingReply.Clear(); + _pendingThinking.Clear(); + _ui.Post(() => { Messages.Add(new ToolCallMessage { @@ -396,53 +545,82 @@ private void HandleAgentEvent(AgentEvent evt) Summary = $"第 {r.Attempt}/{r.MaxAttempts} 次重试: {r.Reason}", Success = false }); - }, DispatcherPriority.Background); + }); break; case AgentEvent.Error e: - if (_currentAssistant is not null) + _diagLog?.Invoke($"[AiAgent] {e.Message}"); + if (_currentAssistant is { } current) { - var current = _currentAssistant; - Dispatcher.UIThread.Post(() => current.Content += $"[错误] {e.Message}", DispatcherPriority.Background); + _ui.Post(() => current.Content += $"[错误] {e.Message}"); + } + else + { + // 无占位气泡时兜底为独立错误行,错误不允许静默丢弃 + _ui.Post(() => Messages.Add(new ToolCallMessage { Name = "错误", Summary = e.Message, Success = false })); } break; case AgentEvent.DebugInfo d: - Dispatcher.UIThread.Post(() => + // 原始 SSE 数据量大且仅调试面板可见(Release 版不渲染), + // 只把解析失败等关键诊断同步写入日志文件,保证可追溯 + if (!d.Message.StartsWith("[SSE] ", StringComparison.Ordinal)) + _diagLog?.Invoke($"[AiAgent] {d.Message}"); + _ui.Post(() => { _debugLogs.Add(d.Message); while (_debugLogs.Count > 50) _debugLogs.RemoveAt(0); OnPropertyChanged(nameof(DebugLogText)); OnPropertyChanged(nameof(HasDebugLogs)); - }, DispatcherPriority.Background); + }); break; - case AgentEvent.Completed: - _uiUpdateScheduled = false; - if (_currentAssistant is not null) - { - var current = _currentAssistant; - // 先直接设置内容(节流回调可能尚未执行,Background 优先级最低) - current.Content = _pendingReply.ToString().TrimStart('\n', '\r'); - if (_pendingThinking.Length > 0) - { - current.Thinking ??= new ThinkingBlock(); - current.Thinking.Text = _pendingThinking.ToString(); - } - - Dispatcher.UIThread.Post(() => - { - current.IsStreaming = false; - if (current.Thinking is not null) - current.Thinking.IsExpanded = false; - }, DispatcherPriority.Background); - } + case AgentEvent.Completed c: + HandleCompleted(c.FinalContent); _pendingTools.Clear(); + ClearPendingConfirmation(); break; } } + /// + /// 会话结束:以编排器的 FinalContent 为最终内容真源——它包含编排器追加的 + /// [错误]/[连接中断]/[已达到工具调用最大轮次] 等标记。此前用 VM 本地缓冲覆盖 + /// Content 会把这些文本抹掉,气泡随之隐藏,表现为"出错却毫无提示"。 + /// 空回复同样必须可见,不允许气泡静默消失。 + /// + private void HandleCompleted(string finalContent) + { + var trimmed = finalContent.TrimStart('\n', '\r'); + if (string.IsNullOrWhiteSpace(trimmed)) + trimmed = "[错误] 模型返回了空回复"; + + var completed = _currentAssistant; + if (completed is null) + { + // 无占位气泡时兜底为独立行(如首轮 RoundStart 之前就结束) + _ui.Post(() => Messages.Add(new ToolCallMessage { Name = "提示", Summary = trimmed, Success = false })); + return; + } + + // 内容与流式状态的写入统一封送到 UI 线程(事件可能来自编排器后台线程); + // Post 排在节流回调之后执行,最终以完整 FinalContent 覆盖部分内容 + _ui.Post(() => + { + completed.Content = trimmed; + if (_pendingThinking.Length > 0) + { + completed.Thinking ??= new ThinkingBlock(); + completed.Thinking.Text = _pendingThinking.ToString(); + } + + completed.IsStreaming = false; + if (completed.Thinking is not null) + completed.Thinking.IsExpanded = false; + }); + } + /// /// 节流式 UI 更新:将 StringBuilder 中的累积内容写入当前 AssistantMessage。 /// 用于 ContentDelta / ThinkingDelta 等高频连续事件。 @@ -451,7 +629,7 @@ private void ScheduleUiUpdate() { if (_uiUpdateScheduled) return; _uiUpdateScheduled = true; - Dispatcher.UIThread.Post(() => + _ui.Post(() => { _uiUpdateScheduled = false; if (_currentAssistant is not null) @@ -463,7 +641,7 @@ private void ScheduleUiUpdate() _currentAssistant.Thinking.Text = _pendingThinking.ToString(); } } - }, DispatcherPriority.Background); + }); } /// @@ -471,7 +649,7 @@ private void ScheduleUiUpdate() /// 解析 JSON 格式响应 {"title":"..."} 并更新 SessionTitle。 /// 失败时静默降级,不影响主对话流程。 /// - private async Task GenerateTitleAsync(string userMessage, ProviderConfig provider, string modelId) + private async Task GenerateTitleAsync(string userMessage, ProviderConfig provider, string modelId, int sessionVersion) { try { @@ -497,10 +675,10 @@ private async Task GenerateTitleAsync(string userMessage, ProviderConfig provide if (doc.RootElement.TryGetProperty("title", out var titleEl)) { var title = titleEl.GetString(); - if (!string.IsNullOrWhiteSpace(title)) + if (!string.IsNullOrWhiteSpace(title) && sessionVersion == _sessionVersion) { var trimmed = title.Trim().Trim('"', '\'', ',', '。'); - Dispatcher.UIThread.Post(() => SessionTitle = trimmed); + _ui.Post(() => SessionTitle = trimmed); } } } @@ -520,7 +698,12 @@ private void RefreshModels() foreach (var (providerKey, provider) in _cachedConfig.Models.Providers) { - if (provider.Api != "openai-completions") continue; + if (provider.Api != "openai-completions") + { + // 非 openai 协议的供应商无法接入,记录原因而不是从模型列表无声消失 + _diagLog?.Invoke($"[AiAgent] 供应商 \"{providerKey}\" 的 API 类型 \"{provider.Api}\" 不受支持,已从模型列表忽略"); + continue; + } foreach (var m in provider.Models) { @@ -538,9 +721,73 @@ private void RefreshModels() SelectedEntry = AllModels.Count > 0 ? AllModels[0] : null; } - private ProviderConfig GetProviderConfig(string providerKey) + private bool TryGetProviderConfig(string providerKey, out ProviderConfig provider) { _cachedConfig ??= ConfigManager.LoadModelsConfig(); - return _cachedConfig.Models.Providers[providerKey]; + // models.json 可能被外部修改而未触发刷新事件,缺失时返回 false 由调用方给出提示 + return _cachedConfig.Models.Providers.TryGetValue(providerKey, out provider!); + } + + /// 默认 models.json 模板中的占位 API Key,命中说明用户还没填真实 Key。 + private const string PlaceholderApiKey = "sk-REPLACE_WITH_YOUR_KEY"; + + /// + /// 发送前校验供应商配置,返回用户可读的错误提示;配置可用时返回 null。 + /// 纯函数,便于单元测试。 + /// + internal static string? ValidateProvider(string providerKey, ProviderConfig? provider) + { + if (provider is null) + return $"供应商 \"{providerKey}\" 已不在 models.json 中,请重新选择模型或检查配置文件。"; + + var label = string.IsNullOrWhiteSpace(provider.Name) ? providerKey : provider.Name; + + if (provider.Api != "openai-completions") + return $"供应商 \"{label}\" 的 API 类型 \"{provider.Api}\" 不受支持,当前仅支持 openai-completions。"; + + if (string.IsNullOrWhiteSpace(provider.BaseUrl)) + return $"供应商 \"{label}\" 未配置 BaseUrl。"; + + // 与 OpenAIChatClient 构造一致:拼接尾部斜杠后必须是绝对 http(s) 地址 + var baseUri = provider.BaseUrl.TrimEnd('/') + "/"; + if (!Uri.TryCreate(baseUri, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return $"供应商 \"{label}\" 的 BaseUrl \"{provider.BaseUrl}\" 不是有效的 http(s) 地址。"; + + if (string.IsNullOrWhiteSpace(provider.ApiKey)) + return $"供应商 \"{label}\" 未配置 API Key。"; + + if (provider.ApiKey.Trim() == PlaceholderApiKey) + return $"供应商 \"{label}\" 仍在使用默认占位 API Key,请在模型配置中填入真实 Key。"; + + return null; + } + + /// + /// 取消在途请求并退订静态事件。应用级单例(MainWindowViewModel)在主窗口 + /// 关闭时调用;设计时/测试等反复实例化的场景也必须调用, + /// 否则订阅会随静态事件永久累积。 + /// + public void Dispose() + { + // 关窗时不留后台 LLM 请求/编排任务继续跑完 + if (_cts is not null) + { + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + } + _cts.Dispose(); + _cts = null; + } + + ConfigManager.ModelsConfigChanged -= OnModelsConfigChanged; + ConfigManager.McpConfigChanged -= OnMcpConfigChanged; + + if (_mcpManager is not null) + _mcpManager.ToolsChanged -= OnMcpToolsChanged; } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/ChatMessageItem.cs b/src/EasyCon2.Avalonia/AiAgent/ChatMessageItem.cs similarity index 94% rename from src/EasyCon2.Avalonia.Core/AiAgent/ChatMessageItem.cs rename to src/EasyCon2.Avalonia/AiAgent/ChatMessageItem.cs index ec310b7e..b6569370 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/ChatMessageItem.cs +++ b/src/EasyCon2.Avalonia/AiAgent/ChatMessageItem.cs @@ -1,7 +1,10 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -namespace EasyCon2.Avalonia.Core.AiAgent; +using EasyCon.Core.LLM.Agent; +using EasyCon.Core.LLM.Agent.Tools; + +namespace EasyCon2.Avalonia.AiAgent; /// /// 对话消息条目基类,ItemsControl 通过 DataTemplate 按类型分发渲染。 diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpMetaTools.cs b/src/EasyCon2.Avalonia/AiAgent/Tools/McpMetaTools.cs similarity index 67% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpMetaTools.cs rename to src/EasyCon2.Avalonia/AiAgent/Tools/McpMetaTools.cs index 0796d4f2..d503af31 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpMetaTools.cs +++ b/src/EasyCon2.Avalonia/AiAgent/Tools/McpMetaTools.cs @@ -1,10 +1,12 @@ +using EasyCon.Core.LLM.Agent; +using EasyCon.Core.LLM.Agent.Tools; using EasyCon.Core.LLM.Messages; using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Mcp; +using EasyCon2.Avalonia.Mcp; using ModelContextProtocol.Protocol; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon2.Avalonia.AiAgent.Tools; /// /// list_mcp_resources:列出所有已连接 MCP 服务器提供的资源。 @@ -100,11 +102,7 @@ public async Task ExecuteAsync(Dictionary args, var serverFilter = args.TryGetValue("server", out var s) ? s.GetString() : null; - var conn = _manager.Connections - .FirstOrDefault(c => c.Status == McpConnectionStatus.Connected - && (serverFilter is null || c.ServerKey == serverFilter) - && c.Session is not null); - + var conn = FindConnectedConnection(serverFilter); if (conn is null) return ToolResult.Error($"未找到可读取资源的已连接服务器{(serverFilter is not null ? $" '{serverFilter}'" : "")}"); @@ -114,41 +112,57 @@ public async Task ExecuteAsync(Dictionary args, if (result.Contents is null || result.Contents.Count == 0) return ToolResult.Ok("资源内容为空。"); - var textParts = new List(); - ChatMessage? imageMessage = null; + var (text, imageMessage) = ExtractContents(result, uri); + return ShapeResult(text, imageMessage); + } + catch (Exception ex) + { + return ToolResult.Error($"[读取资源失败] {ex.Message}"); + } + } - foreach (var content in result.Contents) + /// 取第一个已连接、会话就绪且匹配 server 过滤的连接(谓词与原内联实现一致)。 + private McpServerConnection? FindConnectedConnection(string? serverFilter) => + _manager.Connections + .FirstOrDefault(c => c.Status == McpConnectionStatus.Connected + && (serverFilter is null || c.ServerKey == serverFilter) + && c.Session is not null); + + /// 拼接文本内容并提取首个二进制资源为图片消息(与原内联实现逐字一致)。 + private static (string Text, ChatMessage? ImageMessage) ExtractContents(ReadResourceResult result, string uri) + { + var textParts = new List(); + ChatMessage? imageMessage = null; + + foreach (var content in result.Contents) + { + if (content is TextResourceContents textContent) { - if (content is TextResourceContents textContent) - { - textParts.Add(textContent.Text ?? ""); - } - else if (content is BlobResourceContents blobContent) + textParts.Add(textContent.Text ?? ""); + } + else if (content is BlobResourceContents blobContent) + { + var blob = blobContent.Blob; + if (blob.Length > 0) { - var blob = blobContent.Blob; - if (blob.Length > 0) - { - var mime = blobContent.MimeType ?? "image/png"; - var base64 = Convert.ToBase64String(blob.Span); - imageMessage = ChatMessage.User([ - ContentPart.FromText($"[资源 {uri}]"), - ContentPart.FromImageBase64(mime, base64) - ]); - } + var mime = blobContent.MimeType ?? "image/png"; + var base64 = Convert.ToBase64String(blob.Span); + imageMessage = ChatMessage.User([ + ContentPart.FromText($"[资源 {uri}]"), + ContentPart.FromImageBase64(mime, base64) + ]); } } - - var text = string.Join("\n", textParts); - if (imageMessage is not null) - return ToolResult.Ok(string.IsNullOrWhiteSpace(text) ? "(资源为图片)" : text, imageMessage); - - return ToolResult.Ok(string.IsNullOrWhiteSpace(text) ? "(资源内容为空)" : text); - } - catch (Exception ex) - { - return ToolResult.Error($"[读取资源失败] {ex.Message}"); } + + return (string.Join("\n", textParts), imageMessage); } + + /// 图片消息优先返回,否则回退纯文本(空文本文案与原实现一致)。 + private static ToolResult ShapeResult(string text, ChatMessage? imageMessage) => + imageMessage is not null + ? ToolResult.Ok(string.IsNullOrWhiteSpace(text) ? "(资源为图片)" : text, imageMessage) + : ToolResult.Ok(string.IsNullOrWhiteSpace(text) ? "(资源内容为空)" : text); } /// @@ -175,14 +189,27 @@ public sealed class ListMcpPromptsTool : IAiTool public async Task ExecuteAsync(Dictionary args, CancellationToken ct = default) { var serverFilter = args.TryGetValue("server", out var s) ? s.GetString() : null; - var connections = _manager.Connections - .Where(c => c.Status == McpConnectionStatus.Connected) - .Where(c => serverFilter is null || c.ServerKey == serverFilter) - .ToList(); + var connections = SelectConnectedConnections(serverFilter); if (connections.Count == 0) return ToolResult.Ok("当前无已连接的 MCP 服务器。"); + var allPrompts = await CollectPromptsAsync(connections, ct); + + var json = JsonSerializer.Serialize(allPrompts, new JsonSerializerOptions { WriteIndented = true }); + return ToolResult.Ok($"发现 {allPrompts.Count} 个提示模板:\n{json}"); + } + + /// 取已连接且匹配 server 过滤的连接列表(谓词与原内联实现一致)。 + private List SelectConnectedConnections(string? serverFilter) => + _manager.Connections + .Where(c => c.Status == McpConnectionStatus.Connected) + .Where(c => serverFilter is null || c.ServerKey == serverFilter) + .ToList(); + + /// 逐服务器拉取 prompts;单服务器失败降级为该服务器的 error 条目(与原内联实现一致)。 + private async Task> CollectPromptsAsync(List connections, CancellationToken ct) + { var allPrompts = new List(); foreach (var conn in connections) @@ -209,8 +236,7 @@ public async Task ExecuteAsync(Dictionary args, } } - var json = JsonSerializer.Serialize(allPrompts, new JsonSerializerOptions { WriteIndented = true }); - return ToolResult.Ok($"发现 {allPrompts.Count} 个提示模板:\n{json}"); + return allPrompts; } } @@ -245,49 +271,61 @@ public async Task ExecuteAsync(Dictionary args, var serverFilter = args.TryGetValue("server", out var s) ? s.GetString() : null; - var conn = _manager.Connections - .FirstOrDefault(c => c.Status == McpConnectionStatus.Connected - && (serverFilter is null || c.ServerKey == serverFilter) - && c.Session is not null); - + var conn = FindConnectedConnection(serverFilter); if (conn is null) return ToolResult.Error($"未找到可用的已连接服务器{(serverFilter is not null ? $" '{serverFilter}'" : "")}"); - // 解析 arguments 参数 - IReadOnlyDictionary? promptArgs = null; - if (args.TryGetValue("arguments", out var argsElem) && argsElem.ValueKind == JsonValueKind.Object) - { - promptArgs = argsElem.EnumerateObject() - .ToDictionary(p => p.Name, p => (object?)p.Value.Deserialize()); - } + var promptArgs = ParsePromptArguments(args); try { var result = await conn.Session!.GetPromptAsync(promptName, promptArgs, ct); + return ToolResult.Ok(RenderPromptResult(result)); + } + catch (Exception ex) + { + return ToolResult.Error($"[获取提示模板失败] {ex.Message}"); + } + } + + /// 取第一个已连接、会话就绪且匹配 server 过滤的连接(谓词与原内联实现一致)。 + private McpServerConnection? FindConnectedConnection(string? serverFilter) => + _manager.Connections + .FirstOrDefault(c => c.Status == McpConnectionStatus.Connected + && (serverFilter is null || c.ServerKey == serverFilter) + && c.Session is not null); - var parts = new List(); - if (!string.IsNullOrWhiteSpace(result.Description)) - parts.Add($"[{result.Description}]"); + /// 解析 arguments 参数对象;缺省或非对象时返回 null(与原内联实现一致)。 + private static IReadOnlyDictionary? ParsePromptArguments(Dictionary args) + { + if (!args.TryGetValue("arguments", out var argsElem) || argsElem.ValueKind != JsonValueKind.Object) + return null; + + return argsElem.EnumerateObject() + .ToDictionary(p => p.Name, p => (object?)p.Value.Deserialize()); + } + + /// 把 GetPrompt 结果渲染为可读文本(格式与原内联实现逐字一致)。 + private static string RenderPromptResult(GetPromptResult result) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(result.Description)) + parts.Add($"[{result.Description}]"); - if (result.Messages is not null) + if (result.Messages is not null) + { + foreach (var msg in result.Messages) { - foreach (var msg in result.Messages) + var role = msg.Role.ToString().ToLowerInvariant(); + var text = msg.Content switch { - var role = msg.Role.ToString().ToLowerInvariant(); - var text = msg.Content switch - { - TextContentBlock tcb => tcb.Text, - _ => $"[{msg.Content?.GetType().Name ?? "unknown"}]" - }; - parts.Add($"{role}: {text}"); - } + TextContentBlock tcb => tcb.Text, + _ => $"[{msg.Content?.GetType().Name ?? "unknown"}]" + }; + parts.Add($"{role}: {text}"); } - - return ToolResult.Ok(string.Join("\n\n", parts)); - } - catch (Exception ex) - { - return ToolResult.Error($"[获取提示模板失败] {ex.Message}"); } + + return string.Join("\n\n", parts); } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpToolAdapter.cs b/src/EasyCon2.Avalonia/AiAgent/Tools/McpToolAdapter.cs similarity index 82% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpToolAdapter.cs rename to src/EasyCon2.Avalonia/AiAgent/Tools/McpToolAdapter.cs index eb7bb371..53df07ba 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpToolAdapter.cs +++ b/src/EasyCon2.Avalonia/AiAgent/Tools/McpToolAdapter.cs @@ -1,11 +1,13 @@ +using EasyCon.Core.LLM.Agent; +using EasyCon.Core.LLM.Agent.Tools; using EasyCon.Core.LLM.Messages; using EasyCon.Core.LLM.Tools; -using EasyCon2.Avalonia.Core.Mcp; +using EasyCon2.Avalonia.Mcp; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Text.Json; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon2.Avalonia.AiAgent.Tools; /// /// 将 MCP 服务器的单个工具适配为 IAiTool。 @@ -27,11 +29,22 @@ public McpToolAdapter(McpServerConnection connection, McpClientTool tool) _connection = connection; _tool = tool; - // 构造唯一名称:mcp__serverKey__toolName + // 构造唯一名称:mcp__serverKey__toolName。 + // 超长截断时追加 serverKey+toolName 的哈希后缀:纯截断会使两个长名工具 + // 碰撞为同一名字,经 ToolRegistry 覆盖语义后一个工具无声消失 var rawName = $"mcp__{connection.ServerKey}__{tool.Name}"; - _name = rawName.Length <= MaxToolNameLength - ? rawName - : rawName[..MaxToolNameLength]; // 截断兜底 + if (rawName.Length <= MaxToolNameLength) + { + _name = rawName; + } + else + { + var hash = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"{connection.ServerKey}__{tool.Name}")); + var suffix = Convert.ToHexString(hash, 0, 4); // 8 个十六进制字符 + var keep = MaxToolNameLength - 1 - suffix.Length; + _name = $"{rawName[..keep]}-{suffix}"; + } _description = string.IsNullOrWhiteSpace(tool.Description) ? $"MCP tool from {connection.ServerKey}" diff --git a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpTools.cs b/src/EasyCon2.Avalonia/AiAgent/Tools/McpTools.cs similarity index 90% rename from src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpTools.cs rename to src/EasyCon2.Avalonia/AiAgent/Tools/McpTools.cs index 1648ade6..0becdb79 100644 --- a/src/EasyCon2.Avalonia.Core/AiAgent/Tools/McpTools.cs +++ b/src/EasyCon2.Avalonia/AiAgent/Tools/McpTools.cs @@ -1,6 +1,8 @@ -using EasyCon2.Avalonia.Core.Mcp; +using EasyCon.Core.LLM.Agent; +using EasyCon.Core.LLM.Agent.Tools; +using EasyCon2.Avalonia.Mcp; -namespace EasyCon2.Avalonia.Core.AiAgent.Tools; +namespace EasyCon2.Avalonia.AiAgent.Tools; /// /// MCP 工具注册入口。 diff --git a/src/EasyCon2.Avalonia.Core/AlertConfig/AlertConfigControl.axaml b/src/EasyCon2.Avalonia/AlertConfig/AlertConfigControl.axaml similarity index 62% rename from src/EasyCon2.Avalonia.Core/AlertConfig/AlertConfigControl.axaml rename to src/EasyCon2.Avalonia/AlertConfig/AlertConfigControl.axaml index 221e490c..b5dd49a2 100644 --- a/src/EasyCon2.Avalonia.Core/AlertConfig/AlertConfigControl.axaml +++ b/src/EasyCon2.Avalonia/AlertConfig/AlertConfigControl.axaml @@ -1,13 +1,14 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +public sealed class AvaloniaUiDispatcher : IUiDispatcher +{ + public static AvaloniaUiDispatcher Instance { get; } = new(); + + public bool IsOnUiThread => Dispatcher.UIThread.CheckAccess(); + + public void Post(Action action) => Dispatcher.UIThread.Post(action); + + public Task InvokeAsync(Action action) => Dispatcher.UIThread.InvokeAsync(action).GetTask(); + + public Task InvokeAsync(Func action) => Dispatcher.UIThread.InvokeAsync(action); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/CaptureService.cs b/src/EasyCon2.Avalonia/Services/CaptureService.cs index 3059895b..2b84a7d6 100644 --- a/src/EasyCon2.Avalonia/Services/CaptureService.cs +++ b/src/EasyCon2.Avalonia/Services/CaptureService.cs @@ -1,131 +1,155 @@ -using Avalonia.Threading; +using Avalonia.Threading; using EasyCon.Capture; using EasyCon.Core; +using EasyCon.Core.Maa; using EasyCon.Core.Services; -using EasyCon2.Avalonia.Core.Services; using OpenCvSharp; namespace EasyCon2.Avalonia.Services; -public class CaptureService : ICaptureService +public class CaptureService : ICaptureService, IDisposable { private readonly ILogService _logService; - private readonly object _captureLock = new(); + private readonly FrameHub _frames; + private readonly bool _ownsFrames; private readonly System.Timers.Timer _monitorTimer = new(1000); - private readonly Dictionary _sourceIndexMap = new(); - private FrameProducer? _producer; + private readonly Dictionary _sourceIndexMap = new(); + private readonly object _sourceIndexLock = new(); - private readonly Size resol = new(1920, 1080); - public string CaptureType { get; set; } = "ANY"; - - public bool IsConnected + public CaptureService(ILogService logService, FrameHub? frames = null) { - get + _logService = logService; + _frames = frames ?? new FrameHub(); + _ownsFrames = frames == null; + _monitorTimer.Elapsed += (_, _) => { - lock (_captureLock) + if (_frames.CaptureConnected) return; + _monitorTimer.Stop(); + Dispatcher.UIThread.Post(() => { - return _producer?.IsOpened ?? false; - } - } + ConnectionLost?.Invoke(); + _logService.AddLog($"视频源不可用: {_frames.LastError ?? "已断开或等待有效帧超时"}"); + }); + }; } + public string CaptureType { get; set; } = "ANY"; + public FrameHub FrameHub => _frames; + public string? ActiveSource => _frames.Selection?.DisplayKey ?? (_frames.DeviceIndex is { } index ? $"视频源 {index}" : _frames.SourceId); + public FrameNormalization? Normalization { get; set; } + public string? ActiveBackend => _frames.Backend?.ToString(); + public CaptureSourceDescriptor? DescribeSelection(string? displayKey) + { + lock (_sourceIndexLock) + return displayKey != null && _sourceIndexMap.TryGetValue(displayKey, out CaptureSourceDescriptor? source) ? source : null; + } + public bool IsConnected => _frames.CaptureConnected; public event Action? ConnectionLost; #pragma warning disable CS0067 public event Action? ConnectionRestored; #pragma warning restore CS0067 + public event Action? ConnectionStateChanged; - public CaptureService(ILogService logService) + public string[] GetAvailableSources() { - _logService = logService; - - _monitorTimer.Elapsed += (s, e) => + IReadOnlyList sources; + try { sources = CaptureSourceCatalog.Enumerate(GetCaptureApi()); } + catch (Exception ex) { - lock (_captureLock) - { - if (_producer == null || !_producer.IsOpened) - { - _monitorTimer.Stop(); - Dispatcher.UIThread.Post(() => - { - ConnectionLost?.Invoke(); - _logService.AddLog("视频源已从外部断开"); - }); - } - } - }; + _logService.AddLog($"无法枚举所选采集后端: {ex.Message}"); + lock (_sourceIndexLock) _sourceIndexMap.Clear(); + return []; + } + lock (_sourceIndexLock) + { + _sourceIndexMap.Clear(); + foreach (CaptureSourceDescriptor source in sources) _sourceIndexMap[source.DisplayKey] = source; + } + return sources.Select(x => x.DisplayKey).ToArray(); } - public string[] GetAvailableSources() + public bool TryConnect(string sourceName) { - var sources = ECCore.GetCaptureSources().ToList(); - _sourceIndexMap.Clear(); - foreach (var (name, index) in sources) - _sourceIndexMap[name] = index; - return sources.Select(x => x.name).ToArray(); + CaptureSourceDescriptor? source; + lock (_sourceIndexLock) _sourceIndexMap.TryGetValue(sourceName, out source); + if (source == null || source.Backend != CaptureSourceCatalog.ResolveBackend(GetCaptureApi())) + { + _logService.AddLog($"视频来源不可用,请刷新并重新选择: {sourceName}"); + return false; + } + return Connect(() => _frames.Connect(source, Normalization)); } - public bool TryConnect(string sourceName) + public bool TryConnect(int deviceId) { - int deviceId = _sourceIndexMap.TryGetValue(sourceName, out var idx) ? idx : 0; - - lock (_captureLock) + CaptureSourceDescriptor? source; + lock (_sourceIndexLock) source = _sourceIndexMap.Values.SingleOrDefault(s => s.Index == deviceId && s.Backend == CaptureSourceCatalog.ResolveBackend(GetCaptureApi())); + if (source == null) { - _producer?.Dispose(); - var capture = new OpenCVCapture(); - if (!capture.Open(deviceId, (int)GetCaptureApi())) - { - capture.Dispose(); - return false; - } - - capture.SetResolution(resol.Width, resol.Height); - capture.SetProperties(); - capture.GetProperties(); - - _producer = new FrameProducer(capture); - _producer.Start(); + _logService.AddLog($"视频序号不属于当前后端的枚举,请刷新来源: {deviceId}"); + return false; } + return Connect(() => _frames.Connect(source, Normalization)); + } - _monitorTimer.Start(); - return true; + public IReadOnlyList GetSourceDescriptors() + { + GetAvailableSources(); + lock (_sourceIndexLock) return _sourceIndexMap.Values.ToArray(); } - public void Disconnect() + private VideoCaptureAPIs GetCaptureApi() => CaptureType switch + { + "DSHOW" => VideoCaptureAPIs.DSHOW, + "MSMF" => VideoCaptureAPIs.MSMF, + "DC1394" => VideoCaptureAPIs.DC1394, + "AVFOUNDATION" => VideoCaptureAPIs.AVFOUNDATION, + "V4L2" => VideoCaptureAPIs.V4L2, + _ => VideoCaptureAPIs.ANY, + }; + + private bool Connect(Func open) { _monitorTimer.Stop(); - lock (_captureLock) + try + { + return open(); + } + catch (Exception ex) { - _producer?.Dispose(); - _producer = null; + _logService.AddLog($"视频源连接失败: {ex.Message}"); + return false; + } + finally + { + if (IsConnected) _monitorTimer.Start(); + ConnectionStateChanged?.Invoke(); } } - /// - /// 获取最新一帧的租约。热路径无锁,仅对 _producer 引用做易失读取。 - /// 未连接或尚无帧时返回 null;调用者须持有租约直至不再使用 Mat。 - /// - public FrameLease? AcquireLatestFrame() + public Task DisconnectAsync() { - var producer = Volatile.Read(ref _producer); - return producer?.Store.AcquireLatest(); + _monitorTimer.Stop(); + return Task.Run(() => + { + bool stopped = _frames.Disconnect(); + ConnectionStateChanged?.Invoke(); + if (!stopped) throw new InvalidOperationException("视频源正在停止,等待旧 Read 退出"); + }); } + public FrameLease? AcquireLatestFrame() => _frames.AcquireLatest(); + public FrameStore? GetFrameStore() => _frames.CaptureConnected ? _frames.Store : null; + public void SetCaptureProperties(int width, int height) { - lock (_captureLock) - { - _producer?.SetProperties(width, height); - } + if (width != FrameNormalization.Width || height != FrameNormalization.Height) + throw new ArgumentException("统一帧源固定为 1920×1080;变换请配置 FrameNormalization"); } - private VideoCaptureAPIs GetCaptureApi() + public void Dispose() { - return CaptureType switch - { - "DSHOW" => VideoCaptureAPIs.DSHOW, - "MSMF" => VideoCaptureAPIs.MSMF, - "DC1394" => VideoCaptureAPIs.DC1394, - _ => VideoCaptureAPIs.ANY - }; + _monitorTimer.Dispose(); + if (_ownsFrames) _frames.Dispose(); } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/ConfigService.cs b/src/EasyCon2.Avalonia/Services/ConfigService.cs new file mode 100644 index 00000000..be885653 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/ConfigService.cs @@ -0,0 +1,116 @@ +using EasyCon.Core.Config; +using EasyCon.Core.Logging; +using EasyCon.Core.Services; +using System.Reflection; +using System.Text.Json; + +namespace EasyCon2.Avalonia.Services; + +public class ConfigService : IConfigService +{ + private readonly ILogService _logService; + private ConfigState _config; + private KeyMappingConfig _keyMapping; + + public ConfigState Config => _config; + public KeyMappingConfig KeyMapping => _keyMapping; + + public ConfigService(ILogService logService) + { + _logService = logService; + _config = LoadOrCreate(() => ConfigManager.LoadConfig()); + _keyMapping = LoadOrCreate(() => ConfigManager.LoadKeyMapping()); + } + + public void Save() + { + ConfigManager.SaveConfig(_config); + ConfigManager.SaveKeyMapping(_keyMapping); + } + + public void UpdateKeyMapping(KeyMappingConfig keyMapping) + { + _keyMapping = keyMapping; + Save(); + } + + public void UpdateConfig(Action update) + { + update(_config); + Save(); + } + + public void DispatchAlert(string message) + { + _ = Task.Run(async () => + { + try + { + using AlertDispatcher dispatcher = new(ConfigManager.LoadAlert()); + dispatcher.OnResult += (_, result) => _logService.Print(result, true); + await dispatcher.DispatchAsync(message); + } + catch (Exception e) + { + _logService.Print($"推送失败:{e.Message}", true); + } + }); + } + + public async Task CheckForUpdate() + { + try + { + var data = await _updateClient.GetStringAsync("https://gitee.com/api/v5/repos/EasyConNS/EasyCon/tags"); + var ver = JsonSerializer.Deserialize(data); + if (ver == null || ver.Length == 0) return null; + + // tag 名不保证是合法版本号(v 前缀/任意命名),逐条 TryParse 过滤, + // 单条坏 tag 不能让整个更新检查失效;取有效值中最大的 + Version? latest = null; + foreach (var info in ver) + { + if (TryParseTagVersion(info.name) is { } v && (latest == null || v > latest)) + latest = v; + } + if (latest == null) return null; + + var cur = Assembly.GetExecutingAssembly()?.GetName().Version ?? new Version(); + return latest > cur ? $"发现新版本{latest},快去群文件里看看吧" : "暂时没有发现新版本"; + } + catch (Exception ex) + { + // 网络故障与「无更新」同返回 null:调用方语义上等价(都保持静默), + // 但故障原因必须可归因——送诊断通道 + CoreLog.Info($"更新检查失败: {ex.Message}"); + return null; + } + } + + internal static Version? TryParseTagVersion(string? name) + { + if (string.IsNullOrEmpty(name)) return null; + var s = name.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? name[1..] : name; + return Version.TryParse(s, out var v) ? v : null; + } + + private static T LoadOrCreate(Func loader) where T : new() + { + // 只兜 loader 自身的构造异常;文件损坏路径由 ConfigManager.Load + // 的「备份 + ConfigErrorReported 上报」链路处理,此处再吞会绕过上报 + try { return loader(); } + catch (Exception ex) + { + CoreLog.Warn($"配置装载异常,改用默认值: {ex.Message}"); + return new T(); + } + } + + private record VerInfo + { + public string name { get; set; } = ""; + } + + /// 共享实例:每次 new HttpClient 会耗尽可用端口(TIME_WAIT 堆积)。 + private static readonly HttpClient _updateClient = new() { Timeout = TimeSpan.FromSeconds(5) }; +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/ControllerService.cs b/src/EasyCon2.Avalonia/Services/ControllerService.cs index 193d1e17..ee8afb92 100644 --- a/src/EasyCon2.Avalonia/Services/ControllerService.cs +++ b/src/EasyCon2.Avalonia/Services/ControllerService.cs @@ -1,9 +1,9 @@ using Avalonia.Controls; -using Avalonia.Media; +using EasyCon.Core.Config; using EasyCon.Core.Input; +using EasyCon.Core.Maa; using EasyCon.SDLInput; -using EasyCon2.Avalonia.Core.Services; -using EasyCon2.Avalonia.Core.VPad; +using EasyCon2.Avalonia.Services; using EasyDevice; using SDL; @@ -13,9 +13,8 @@ public sealed class ControllerService : IControllerService { private readonly SdlEventLoop _eventLoop; private readonly SdlGamepadDetector _detector; - private readonly NintendoSwitch _gamepad; - private readonly VPadService _vpadService; - private readonly ControllerAdapter _adapter; + private readonly IControllerSession _gamepad; + private readonly IVirtualPadService _vpadService; private bool _isConnected; private SdlKeyboardInputBinder? _keyboardBinder; private SdlGamepadInputBinder? _gamepadBinder; @@ -26,50 +25,58 @@ public sealed class ControllerService : IControllerService public void SetOwnerWindow(Window owner) => _vpadService.SetOwner(owner); - public ControllerService(NintendoSwitch gamepad, IScriptService scriptService) + /// + /// 由组合根构造后注入(其 ControllerAdapter 依赖 + /// IScriptService,装配留在 App 侧),本类不再反向引用 VPad 实现。 + /// + public ControllerService(IControllerSession gamepad, IVirtualPadService vpadService) { _gamepad = gamepad; - _adapter = new ControllerAdapter(scriptService); _eventLoop = new SdlEventLoop(); _detector = new SdlGamepadDetector(); - _vpadService = new VPadService(gamepad, _adapter); + _vpadService = vpadService; _eventLoop.GamepadDeviceEvent += ev => _detector.HandleDeviceEvent(ev); _detector.GamepadConnected += (_, _) => AvailableSourcesChanged?.Invoke(); _detector.GamepadDisconnected += (_) => AvailableSourcesChanged?.Invoke(); _vpadService.OverlayKeyEvent += OnOverlayKeyEvent; _vpadService.Exited += OnVpadExited; + ConfigManager.KeyMappingChanged += OnKeyMappingChanged; _eventLoop.Start(); _detector.OpenExisting(); } - public string[] GetAvailableSources() + /// 键盘控制源的稳定 id(下拉显示名仍是"键盘")。 + public const string KeyboardSourceId = "keyboard"; + private const string _gamepadIdPrefix = "gamepad:"; + + public IReadOnlyList GetAvailableSources() { - var sources = new List { "键盘" }; + var sources = new List { new("键盘", KeyboardSourceId) }; foreach (var (id, name) in _detector.ConnectedGamepads) - sources.Add($"手柄: {name} ({id})"); - return sources.ToArray(); + sources.Add(new ControlSourceInfo($"手柄: {name}", $"{_gamepadIdPrefix}{id}")); + return sources; } - public bool TryConnect(string sourceName) + public bool TryConnect(string sourceId) { + // 先清理上一连接的 binder 与事件订阅:任何路径重复进入 TryConnect + // (连接进行中二次点击、切换控制源)都不会泄漏旧 binder 或重复订阅 + CleanupBinder(); + IInputBinder binder; - if (sourceName == "键盘") + if (sourceId == KeyboardSourceId) { _keyboardBinder = new SdlKeyboardInputBinder(_eventLoop, _gamepad); - var mapping = SdlKeyMappingDefaults.Create(); + KeyMappingConfig mapping = KeyMappingStore.Instance.Current; _keyboardBinder.UpdateKeyMapping(mapping); binder = _keyboardBinder; } - else if (sourceName.StartsWith("手柄: ")) + else if (sourceId.StartsWith(_gamepadIdPrefix, StringComparison.Ordinal)) { - var parenStart = sourceName.LastIndexOf('('); - var parenEnd = sourceName.LastIndexOf(')'); - if (parenStart < 0 || parenEnd < 0) return false; - var idStr = sourceName.Substring(parenStart + 1, parenEnd - parenStart - 1); - if (!int.TryParse(idStr, out var gpId)) return false; + if (!int.TryParse(sourceId[_gamepadIdPrefix.Length..], out var gpId)) return false; if (!_detector.HasGamepad(gpId)) return false; _gamepadBinder = new SdlGamepadInputBinder(_eventLoop, _detector, _gamepad, gpId); @@ -131,11 +138,18 @@ private void OnVpadExited() public void Dispose() { + ConfigManager.KeyMappingChanged -= OnKeyMappingChanged; Disconnect(); _eventLoop.Stop(); _detector.Dispose(); } + private void OnKeyMappingChanged() + { + if (_keyboardBinder != null) + _keyboardBinder.UpdateKeyMapping(KeyMappingStore.Instance.Current); + } + private void OnOverlayKeyEvent(int scancode, bool down) { _keyboardBinder?.HandleKeyEvent(scancode, down); @@ -150,10 +164,4 @@ private void OnGamepadButtonEvent(SDL_GamepadButtonEvent ev) { _gamepadBinder?.Poll(); } - - private sealed class ControllerAdapter(IScriptService scriptService) : IControllerAdapter - { - public bool IsRunning() => scriptService.IsRunning; - public Color CurrentLight => Colors.White; - } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/DeviceService.cs b/src/EasyCon2.Avalonia/Services/DeviceService.cs index c7527432..21ba1d95 100644 --- a/src/EasyCon2.Avalonia/Services/DeviceService.cs +++ b/src/EasyCon2.Avalonia/Services/DeviceService.cs @@ -1,19 +1,21 @@ using Avalonia.Threading; using EasyCon.Core; +using EasyCon.Core.Maa; using EasyCon.Core.Services; using EasyDevice; -using IDeviceService = EasyCon2.Avalonia.Core.Services.IDeviceService; +using IDeviceService = EasyCon2.Avalonia.Services.IDeviceService; namespace EasyCon2.Avalonia.Services; -public class DeviceService : IDeviceService +public class DeviceService : IDeviceService, IDisposable { private readonly ILogService _logService; - private readonly NintendoSwitch _nintendoSwitch = new(); + private readonly IControllerSession _nintendoSwitch; private bool _isConnected; private bool _showDebugInfo; - public bool IsConnected => _isConnected; + public bool IsConnected => _nintendoSwitch.InputConnected; + public bool SupportsLegacyCommands => _nintendoSwitch.SupportsLegacyCommands; public bool ShowDebugInfo { get => _showDebugInfo; @@ -40,9 +42,13 @@ public bool ShowDebugInfo public event Action? ConnectionLost; - public DeviceService(ILogService logService) + /// 连接状态可能已变化(连接/断开尝试后触发,含 Flow 桥接的外部连接)。非 UI 线程。 + public event Action? ConnectionStateChanged; + + public DeviceService(ILogService logService, IControllerSession controller) { _logService = logService; + _nintendoSwitch = controller; _nintendoSwitch.StatusChanged += OnStatusChanged; } @@ -51,12 +57,19 @@ public DeviceService(ILogService logService) public bool TryConnect(string port) { var result = _nintendoSwitch.TryConnect(port); - if (result == NintendoSwitch.ConnectResult.Success) + if (result == ControllerConnectResult.Success) { _isConnected = true; + _logService.AddLog($"手柄协议: {_nintendoSwitch.FirmwareName} {_nintendoSwitch.FirmwareVersion}"); + if (_nintendoSwitch.ConsoleConnected == false) + _logService.AddLog("已连接手柄板,NS 尚未接管手柄;请检查 USB 连接或在 NS 的更改握法/顺序中配对。"); + ConnectionStateChanged?.Invoke(); return true; } - _logService.AddLog($"单片机连接失败: {result}"); + _isConnected = false; + _logService.AddLog($"单片机连接失败: {result} {_nintendoSwitch.LastConnectionError}"); + // 失败也广播:重连尝试可能已摘掉旧连接,订阅方按 IsConnected 同步 + ConnectionStateChanged?.Invoke(); return false; } @@ -64,9 +77,23 @@ public void Disconnect() { _nintendoSwitch.Disconnect(); _isConnected = false; + ConnectionStateChanged?.Invoke(); } - public NintendoSwitch GetDevice() => _nintendoSwitch; + /// 应用退出时调用:断开串口连接(Reset 复位手柄 + 丢弃排队报文)。 + public void Dispose() + { + try + { + _nintendoSwitch.Dispose(); + } + catch + { + // 退出路径尽力而为;串口句柄最终由进程退出兜底 + } + } + + public IControllerSession GetDevice() => _nintendoSwitch; public void Reset() => _nintendoSwitch.Reset(); @@ -84,34 +111,42 @@ public void Disconnect() public bool RemoteStart() { - if (!_isConnected) return false; + if (!RequireLegacyCommands()) return false; return _nintendoSwitch.RemoteStart(); } public bool RemoteStop() { - if (!_isConnected) return false; + if (!RequireLegacyCommands()) return false; return _nintendoSwitch.RemoteStop(); } public bool Flash(byte[] asmBytes) { - if (!_isConnected) return false; + if (!RequireLegacyCommands()) return false; return _nintendoSwitch.Flash(asmBytes); } public int GetVersion() { - if (!_isConnected) return -1; + if (!RequireLegacyCommands()) return -1; return _nintendoSwitch.GetVersion(); } public bool UnPair() { - if (!_isConnected) return false; + if (!RequireLegacyCommands()) return false; return _nintendoSwitch.UnPair(); } + private bool RequireLegacyCommands() + { + if (!IsConnected) return false; + if (SupportsLegacyCommands) return true; + _logService.AddLog("当前连接支持在线按键和电脑端脚本;不支持伊机控离线烧录、远程运行及解除配对。请使用运行脚本/停止脚本,配对在 NS 上完成。"); + return false; + } + private void OnNSLog(string message) { _logService.AddLog($"NS LOG >> {message}"); diff --git a/src/EasyCon2.Avalonia/Services/DialogService.cs b/src/EasyCon2.Avalonia/Services/DialogService.cs index e10d9478..48308818 100644 --- a/src/EasyCon2.Avalonia/Services/DialogService.cs +++ b/src/EasyCon2.Avalonia/Services/DialogService.cs @@ -1,27 +1,39 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; using EasyCon2.Avalonia.Controls; -using EasyCon2.Avalonia.Core.Services; +using EasyCon2.Avalonia.Models; +using EasyCon2.Avalonia.Scripting; +using EasyCon2.Avalonia.Services; namespace EasyCon2.Avalonia.Services; /// -/// IDialogService 实现 —— 封装文件对话框和颜色选择器。 +/// IDialogService 实现 —— 封装文件对话框、颜色选择器和脚本参数对话框, +/// 并负责 Core 层 DTO(FileDialogFilter/DialogColor)与 Avalonia 类型之间的转换。 /// public class DialogService : IDialogService { + /// + /// 对话框的 Owner,由 App 在初始化时注入。 + /// + public Window? Owner { get; set; } + + public async Task> OpenFilesAsync(string title, - IReadOnlyList? filters = null, + IReadOnlyList? filters = null, string? suggestedStartPath = null) { - var mainWindow = WindowService.MainWindow; + var mainWindow = Owner; if (mainWindow == null) return Array.Empty(); var options = new FilePickerOpenOptions { Title = title, AllowMultiple = false, - FileTypeFilter = filters + FileTypeFilter = filters?.Select(ToFileType).ToList() }; if (suggestedStartPath != null) @@ -36,17 +48,17 @@ public async Task> OpenFilesAsync(string title, public async Task SaveFileAsync(string title, string defaultExtension, - IReadOnlyList? filters = null, + IReadOnlyList? filters = null, string? suggestedFileName = null) { - var mainWindow = WindowService.MainWindow; + var mainWindow = Owner; if (mainWindow == null) return null; var file = await mainWindow.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = title, DefaultExtension = defaultExtension, - FileTypeChoices = filters, + FileTypeChoices = filters?.Select(ToFileType).ToList(), SuggestedFileName = suggestedFileName }); @@ -56,7 +68,7 @@ public async Task> OpenFilesAsync(string title, public async Task OpenFolderAsync(string title, string? suggestedStartPath = null) { - var mainWindow = WindowService.MainWindow; + var mainWindow = Owner; if (mainWindow == null) return null; var options = new FolderPickerOpenOptions @@ -75,12 +87,78 @@ public async Task> OpenFilesAsync(string title, return folders.Count > 0 ? folders[0].Path.LocalPath : null; } - public async Task PickColorAsync(Color current) + public async Task PickColorAsync(DialogColor current) + { + var owner = Owner; + if (owner == null) return null; + + var avaloniaColor = new Color(current.A, current.R, current.G, current.B); + var popup = new ColorPickerPopup(avaloniaColor); + var picked = await popup.ShowDialog(owner); + return picked == null ? null : new DialogColor(picked.Value.A, picked.Value.R, picked.Value.G, picked.Value.B); + } + + public async Task ShowScriptArgsDialogAsync() { - var owner = WindowService.MainWindow; + var owner = Owner; if (owner == null) return null; - var popup = new ColorPickerPopup(current); - return await popup.ShowDialog(owner); + var dialog = new ScriptArgsWindow(); + await dialog.ShowDialog(owner); + return dialog.Args; + } + + public Task ConfirmAsync(string title, string message) + => ShowButtonDialogAsync(title, message, withCancel: true); + + public Task ShowMessageAsync(string title, string message) + => ShowButtonDialogAsync(title, message, withCancel: false); + + private async Task ShowButtonDialogAsync(string title, string message, bool withCancel) + { + var owner = Owner; + if (owner == null) return false; + + var confirmed = false; + var okButton = new Button { Content = "确定", MinWidth = 88, Margin = withCancel ? new Thickness(0, 0, 12, 0) : default }; + var cancelButton = new Button { Content = "取消", MinWidth = 88 }; + + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 20, 0, 0) + }; + buttons.Children.Add(okButton); + if (withCancel) + buttons.Children.Add(cancelButton); + + var content = new StackPanel { Margin = new Thickness(24) }; + content.Children.Add(new TextBlock + { + Text = message, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 380 + }); + content.Children.Add(buttons); + + var dialog = new Window + { + Title = title, + SizeToContent = SizeToContent.WidthAndHeight, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false, + Content = content + }; + + okButton.Click += (_, _) => { confirmed = true; dialog.Close(); }; + cancelButton.Click += (_, _) => dialog.Close(); + + await dialog.ShowDialog(owner); + return confirmed; } + + private static FilePickerFileType ToFileType(FileDialogFilter filter) + => new(filter.Name) { Patterns = [.. filter.Patterns] }; } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/GiteeUpdateService.cs b/src/EasyCon2.Avalonia/Services/GiteeUpdateService.cs new file mode 100644 index 00000000..f25fa522 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/GiteeUpdateService.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace EasyCon2.Avalonia.Services; + +/// +/// Gitee 版本源更新检查。HTTP/JSON 解析/版本比较主体自 MainWindowViewModel.CheckUpdateAsync +/// 原样迁入,结果映射为 ;失败路径不抛异常(返回 +/// ),与原实现的 catch 行为对齐。 +/// +public sealed class GiteeUpdateService : IUpdateService +{ + private readonly HttpClient _http; + + /// 供测试注入自定义 ;null 时使用默认配置。 + public GiteeUpdateService(HttpMessageHandler? handler = null) + { + _http = new HttpClient(handler ?? new SocketsHttpHandler()) { Timeout = TimeSpan.FromSeconds(5) }; + } + + public async Task CheckAsync(CancellationToken ct = default) + { + try + { + var data = await _http.GetStringAsync("https://gitee.com/api/v5/repos/EasyConNS/EasyCon/tags?sort=updated&direction=desc&per_page=3", ct); + var tags = System.Text.Json.JsonSerializer.Deserialize(data); + if (tags == null || tags.Length == 0) + { + return new UpdateCheckResult(UpdateStatus.CheckFailed, null); + } + var cutoff = new DateTime(2026, 4, 27, 0, 0, 0, DateTimeKind.Utc); + var latest = tags.FirstOrDefault(t => t.commit != null && t.commit.date >= cutoff); + if (latest == null) + { + return new UpdateCheckResult(UpdateStatus.UpToDate, null); + } + // tag 名不保证是合法版本号(v 前缀/任意命名),解析失败按“已是最新”处理, + // 不能让单条坏 tag 使更新检查报“检查失败” + var curVer = Assembly.GetEntryAssembly()?.GetName().Version; + if (ConfigService.TryParseTagVersion(latest.name) is { } latestVer && latestVer > curVer) + return new UpdateCheckResult(UpdateStatus.UpdateAvailable, latestVer); + return new UpdateCheckResult(UpdateStatus.UpToDate, null); + } + catch + { + // 与原实现 catch 对齐:网络故障/解析失败一律归为 CheckFailed,不向调用方抛异常 + return new UpdateCheckResult(UpdateStatus.CheckFailed, null); + } + } + + private record CommitInfo + { + public string sha { get; set; } = ""; + public DateTime date { get; set; } + } + + private record VerInfo + { + public string name { get; set; } = ""; + public CommitInfo? commit { get; set; } + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/ICaptureService.cs b/src/EasyCon2.Avalonia/Services/ICaptureService.cs new file mode 100644 index 00000000..85b35c26 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/ICaptureService.cs @@ -0,0 +1,38 @@ +using EasyCon.Capture; +using EasyCon.Core.Maa; + +namespace EasyCon2.Avalonia.Services; + +public interface ICaptureService +{ + FrameHub? FrameHub => null; + string? ActiveSource => null; + string? ActiveBackend => null; + FrameNormalization? Normalization { get => null; set { } } + CaptureSourceDescriptor? DescribeSelection(string? displayKey) => null; + bool IsConnected { get; } + string CaptureType { get; set; } + event Action? ConnectionLost; + event Action? ConnectionRestored; + /// + /// 连接状态可能已变化(任意来源的连接/断开成功后触发,含 Flow/agent 经设备桥的外部连接)。 + /// 可能在非 UI 线程触发;订阅方以 为唯一事实源做同步。 + /// + event Action? ConnectionStateChanged; + string[] GetAvailableSources(); + bool TryConnect(string sourceName); + /// + /// 异步断开:采集循环可能阻塞在读取上(Dispose 最长等待数秒), + /// UI 侧应优先使用本方法避免冻结界面。 + /// + Task DisconnectAsync(); + /// + /// 获取最新一帧的租约。未连接或尚无帧时返回 null。 + /// 调用者须持有租约直至不再使用其中的 Mat(含其 ROI 视图)。 + /// + FrameLease? AcquireLatestFrame(); + /// + /// 设置采集参数(分辨率等)。 + /// + void SetCaptureProperties(int width, int height); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IControllerService.cs b/src/EasyCon2.Avalonia/Services/IControllerService.cs index 3d64b5ad..4cad4ead 100644 --- a/src/EasyCon2.Avalonia/Services/IControllerService.cs +++ b/src/EasyCon2.Avalonia/Services/IControllerService.cs @@ -2,11 +2,17 @@ namespace EasyCon2.Avalonia.Services; +/// +/// 控制源条目。 用于下拉显示, +/// 用于连接(结构化传递,避免从显示文本反解析设备 id)。 +/// +public sealed record ControlSourceInfo(string DisplayName, string SourceId); + public interface IControllerService : IDisposable { bool IsConnected { get; } - string[] GetAvailableSources(); - bool TryConnect(string sourceName); + IReadOnlyList GetAvailableSources(); + bool TryConnect(string sourceId); void Disconnect(); void SetOwnerWindow(Window owner); event Action? AvailableSourcesChanged; diff --git a/src/EasyCon2.Avalonia.Core/Services/IDeviceService.cs b/src/EasyCon2.Avalonia/Services/IDeviceService.cs similarity index 52% rename from src/EasyCon2.Avalonia.Core/Services/IDeviceService.cs rename to src/EasyCon2.Avalonia/Services/IDeviceService.cs index 7518a725..ddebfe98 100644 --- a/src/EasyCon2.Avalonia.Core/Services/IDeviceService.cs +++ b/src/EasyCon2.Avalonia/Services/IDeviceService.cs @@ -1,17 +1,25 @@ +using EasyCon.Core.Maa; using EasyDevice; -namespace EasyCon2.Avalonia.Core.Services; +namespace EasyCon2.Avalonia.Services; public interface IDeviceService { bool IsConnected { get; } + string? ActiveEndpoint => GetDevice()?.InputEndpoint; + bool SupportsLegacyCommands => true; bool ShowDebugInfo { get; set; } event Action? ConnectionLost; + /// + /// 连接状态可能已变化(任意来源的连接/断开尝试后触发,含 Flow/agent 经设备桥的外部连接)。 + /// 可能在非 UI 线程触发;订阅方以 为唯一事实源做同步。 + /// + event Action? ConnectionStateChanged; string[] GetAvailablePorts(); bool TryConnect(string port); string? AutoConnect(); void Disconnect(); - NintendoSwitch GetDevice(); + IControllerSession GetDevice(); /// /// 释放所有按键并清空待发送队列,脚本终止时调用。 diff --git a/src/EasyCon2.Avalonia/Services/IDialogService.cs b/src/EasyCon2.Avalonia/Services/IDialogService.cs new file mode 100644 index 00000000..10b19fb2 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IDialogService.cs @@ -0,0 +1,34 @@ +using EasyCon2.Avalonia.Models; +namespace EasyCon2.Avalonia.Services; + +/// +/// 对话框服务抽象。签名只依赖本程序集的 DTO,不暴露具体 UI 框架类型; +/// 由 GUI 宿主实现并负责与框架对话框之间的转换。 +/// +public interface IDialogService +{ + // 文件/文件夹对话框 + Task> OpenFilesAsync(string title, + IReadOnlyList? filters = null, + string? suggestedStartPath = null); + + Task SaveFileAsync(string title, + string defaultExtension, + IReadOnlyList? filters = null, + string? suggestedFileName = null); + + Task OpenFolderAsync(string title, + string? suggestedStartPath = null); + + // 颜色选择器 + Task PickColorAsync(DialogColor current); + + // 脚本参数对话框:取消/关闭返回 null + Task ShowScriptArgsDialogAsync(); + + /// 通用确认对话框(确定/取消)。返回 true 表示用户点了确定。 + Task ConfirmAsync(string title, string message); + + /// 通用提示对话框(仅"确定")。 + Task ShowMessageAsync(string title, string message); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IFileStorage.cs b/src/EasyCon2.Avalonia/Services/IFileStorage.cs new file mode 100644 index 00000000..453826fb --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IFileStorage.cs @@ -0,0 +1,20 @@ +using System.Text; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 文件存储端口:整文件字节/文本读写的最小 System.IO 面。 +/// 成员与对应 System.IO API 语义逐一对应,不做增强;生产用 ,测试可注入替身。 +/// 目录/存在性/枚举等能力复用 ;注意与 EasyCon.Core/Capabilities/IFileSystem.cs(句柄语义)无任何关系。 +/// +public interface IFileStorage +{ + /// 对应 File.ReadAllBytes(path)。 + byte[] ReadAllBytes(string path); + + /// 对应 File.ReadAllText(path)。 + string ReadAllText(string path); + + /// 对应 File.WriteAllText(path, contents, encoding)。 + void WriteAllText(string path, string contents, Encoding encoding); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IFileSystem.cs b/src/EasyCon2.Avalonia/Services/IFileSystem.cs new file mode 100644 index 00000000..6e72e9e6 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IFileSystem.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 文件系统端口:FileTreeViewModel 所需的最小 System.IO 面。 +/// 成员与对应 System.IO API 语义逐一对应,不做增强;生产用 ,测试可注入替身。 +/// 注意与 EasyCon.Core/Capabilities/IFileSystem.cs(句柄语义)无任何关系,仅同名。 +/// +public interface IFileSystem +{ + bool DirectoryExists(string path); + + bool FileExists(string path); + + void CreateDirectory(string path); + + /// 递归删除目录(对应 Directory.Delete(path, recursive: true))。 + void DeleteDirectory(string path); + + void DeleteFile(string path); + + /// 对应 Directory.GetDirectories(path),返回直接子目录全路径。 + IReadOnlyList GetDirectories(string path); + + /// 对应 Directory.GetFiles(path),返回直接子文件全路径。 + IReadOnlyList GetFiles(string path); + + /// 取最后写入时间(UTC);不存在或读取失败返回 null。 + DateTime? GetLastWriteTimeUtc(string path); + + /// 目录移动(对应 Directory.Move)。 + void Move(string sourcePath, string destinationPath); + + /// 文件移动(对应 File.Move)。 + void MoveFile(string sourcePath, string destinationPath, bool overwrite = false); + + void CopyFile(string source, string destination, bool overwrite); + + /// 递归拷贝整个目录(源实现自 FileTreeViewModel 原样搬入)。 + void CopyDirectory(string source, string target); + + void WriteAllText(string path, string contents, Encoding encoding); + + string GetFileName(string path); + + string? GetDirectoryName(string path); + + string PathCombine(params string[] parts); + + /// 取扩展名(保留原大小写,对应 Path.GetExtension)。 + string GetFileExtension(string path); + + /// 路径相等判断:Windows 用 OrdinalIgnoreCase,其余用 Ordinal。 + bool ArePathsEqual(string left, string right); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IImageProcessor.cs b/src/EasyCon2.Avalonia/Services/IImageProcessor.cs new file mode 100644 index 00000000..6d6476fb --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IImageProcessor.cs @@ -0,0 +1,31 @@ +using EasyCon2.Avalonia.Models; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 与 UI 框架无关的图像处理接口,PNG 字节进出。 +/// ViewModel 通过本接口完成裁剪/加载等像素操作,避免持有 +/// RenderTargetBitmap 之类的渲染对象;GUI 宿主提供实现。 +/// +public interface IImageProcessor +{ + /// 解码图像字节并裁剪指定区域,重新编码为 PNG 返回;失败返回 null。 + byte[]? Crop(byte[] sourceBytes, int x, int y, int width, int height); + + /// 从文件加载图像并编码为 PNG 字节;失败返回 null。 + byte[]? LoadAsPng(string filePath); + + /// 从文件解码为 RGBA 非预乘像素(供逐像素读取);失败返回 null。 + PixelImage? LoadPixels(string filePath); + + /// + /// 按 把图片缩放进 × 的画布,未覆盖处透明。 + /// 只在 时生效(任意角度旋转、缩放、取景位置)。 + /// 为最近邻采样(像素画放大不出现灰色过渡),否则为平滑的 Mitchell 三次插值。 + /// 尺寸已一致且无取景调整时原样返回,不做重采样。 + /// + PixelImage FitTo(PixelImage source, int width, int height, ImageFit fit, CoverTransform? cover = null, bool nearest = false); + + /// 把像素按原尺寸编码为 PNG 写入文件;失败返回 false。 + bool SavePng(PixelImage image, string filePath); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IScriptDocumentService.cs b/src/EasyCon2.Avalonia/Services/IScriptDocumentService.cs new file mode 100644 index 00000000..873c4ff7 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IScriptDocumentService.cs @@ -0,0 +1,66 @@ +using EasyCon2.Avalonia.Markup; +using System.ComponentModel; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 脚本文档状态机:路径/文本/脏标记/保存中四件套 + 打开/新建/关闭的语义变更 + 落盘。 +/// 从 MainWindowViewModel 抽出(Phase 3);文件对话框、文件树同步与编辑器初始化编排仍留在 VM。 +/// 所有 setter 在返回前同步 raise PropertyChanged(无 Dispatcher、无异步)。 +/// +public interface IScriptDocumentService : INotifyPropertyChanged +{ + /// 当前脚本路径。哨兵值见 。 + string CurrentPath { get; set; } + + /// 编辑区文本。setter 内处理脏标记(未抑制时置脏,语义同原 OnEditorTextChanged)。 + string Text { get; set; } + + /// 当前脚本内容是否有未保存的修改。 + bool IsModified { get; set; } + + /// 保存闸门:保存流程进行中为 true。 + bool IsSaving { get; } + + /// 是否已选中真实脚本路径(非哨兵、非空白),语义同原 HasSelectedScriptPath 三条件。 + bool HasSelectedPath { get; } + + /// 保存时是否需要先弹对话框(未选中真实路径),语义同原 SaveScriptCoreAsync 的 needsSaveDialog 判定。 + bool NeedsSaveDialog { get; } + + /// + /// 落盘(原 SaveEditorText):UTF-8 无 BOM 写盘;失败记日志并返回 false,且 IsModified 不变(防"假成功"丢数据)。 + /// + bool WriteToFile(string path); + + /// 程序化设置文本(打开/新建/关闭脚本)且不触发修改标记(原 SetEditorTextWithoutDirtyTracking)。 + void SetTextSilently(string text); + + /// + /// 从路径打开:设路径 → 静默载文本 → 清脏(顺序照抄原 OpenScriptFromPath 状态段)。 + /// + void OpenFromPath(string path, string fileText); + + /// 新建未命名脚本:置 Untitled 哨兵、清空文本、清脏(顺序照抄原 NewScript 状态行)。 + void NewUntitled(); + + /// 关闭脚本:清空文本、清脏、置 NoScript 哨兵(顺序照抄原 CloseScript 状态行)。 + void Close(); + + /// 进入保存流程。幂等:已在保存中则返回 false 且不重复通知。 + bool TryBeginSave(); + + /// 退出保存流程(幂等,未在保存中时无操作)。 + void EndSave(); +} + +/// +/// 路径哨兵值:与原 MainWindowViewModel 的 NoScriptPathText/UntitledScriptText 同键同机制, +/// L10n 运行时取值,类型初始化时求值一次。 +/// +public static class ScriptPathSentinels +{ + public static readonly string NoScriptPath = L10n.T("Text.Status.NoScriptSelected"); + + public static readonly string UntitledScript = L10n.T("Text.Status.UntitledScript"); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/Services/IScriptService.cs b/src/EasyCon2.Avalonia/Services/IScriptService.cs similarity index 63% rename from src/EasyCon2.Avalonia.Core/Services/IScriptService.cs rename to src/EasyCon2.Avalonia/Services/IScriptService.cs index d23195b3..fd295fc9 100644 --- a/src/EasyCon2.Avalonia.Core/Services/IScriptService.cs +++ b/src/EasyCon2.Avalonia/Services/IScriptService.cs @@ -1,4 +1,4 @@ -namespace EasyCon2.Avalonia.Core.Services; +namespace EasyCon2.Avalonia.Services; public interface IScriptService { @@ -8,9 +8,10 @@ public interface IScriptService event Action IsRunningChanged; Task CompileAsync(string scriptText, string? fileName); string GetFormattedCode(); - Task BuildAsync(bool autoRun); + /// 按 Distributable 编译档导出 ECX;stripDebug 控制是否省略调试段。 + Task BuildAsync(bool stripDebug); void Run(string scriptPath, string[]? args = null); - void RunFromContent(string content, string[]? args = null); + void RunFromContent(string content, string[]? args = null, string? fileName = null); void Stop(); /// diff --git a/src/EasyCon2.Avalonia/Services/IToolCallService.cs b/src/EasyCon2.Avalonia/Services/IToolCallService.cs new file mode 100644 index 00000000..47d8322a --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IToolCallService.cs @@ -0,0 +1,15 @@ +using EasyCon.Core.LLM.Agent.Tools; + +namespace EasyCon2.Avalonia.Services; + +// ── 组合视图 ─────────────────────────────── +// 设计约定(runtime knows capabilities, not integrations): +// 每个工具只依赖它需要的那一个窄端口;窄端口本身已是 Core 词汇 +// (见 EasyCon.Core.LLM.Agent.Tools.Ports),工具随词汇一起住在 Core。 +// 本接口只是 GUI 内部的组合视图:依赖 GUI 宿主状态的实现(ToolCallService) +// 把全部窄端口聚合为单一注入点,方便宿主一次装配;Core 不可见此类型。 + +/// GUI agent 工具服务的组合视图(全部窄端口)。工具代码请依赖窄端口,勿依赖本接口。 +public interface IToolCallService : IScriptEditorPort, IScriptRunPort, IWorkspaceInfoPort, IObservabilityPort, IDeviceRuntimePort +{ +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IUiDispatcher.cs b/src/EasyCon2.Avalonia/Services/IUiDispatcher.cs new file mode 100644 index 00000000..5f784c07 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IUiDispatcher.cs @@ -0,0 +1,41 @@ +namespace EasyCon2.Avalonia.Services; + +/// +/// UI 线程调度抽象。Core 层的 VM/服务通过本接口把工作派发到 UI 线程, +/// 而不直接引用具体 UI 框架的 Dispatcher;GUI 宿主启动时注入实现, +/// 测试可注入 。 +/// +public interface IUiDispatcher +{ + /// 当前是否已在 UI 线程上。 + bool IsOnUiThread { get; } + + /// + /// 把操作调度到 UI 线程异步执行:不阻塞调用线程,也不等待其完成。 + /// + void Post(Action action); + + /// 在 UI 线程执行操作并等待完成。 + Task InvokeAsync(Action action); + + /// 在 UI 线程执行异步操作并等待完成。 + Task InvokeAsync(Func action); +} + +/// 测试/设计器回退实现:所有调用在当前线程内联执行。 +public sealed class SynchronousUiDispatcher : IUiDispatcher +{ + public static readonly SynchronousUiDispatcher Instance = new(); + + public bool IsOnUiThread => true; + + public void Post(Action action) => action(); + + public Task InvokeAsync(Action action) + { + action(); + return Task.CompletedTask; + } + + public Task InvokeAsync(Func action) => action(); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IUpdateService.cs b/src/EasyCon2.Avalonia/Services/IUpdateService.cs new file mode 100644 index 00000000..5821ddca --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IUpdateService.cs @@ -0,0 +1,11 @@ +namespace EasyCon2.Avalonia.Services; + +public enum UpdateStatus { UpToDate, UpdateAvailable, CheckFailed } + +public sealed record UpdateCheckResult(UpdateStatus Status, Version? LatestVersion); + +/// 版本更新检查(外部版本源 HTTP 查询)。与 UI 完全解耦。 +public interface IUpdateService +{ + Task CheckAsync(CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IUserSettingsService.cs b/src/EasyCon2.Avalonia/Services/IUserSettingsService.cs new file mode 100644 index 00000000..a44442b5 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IUserSettingsService.cs @@ -0,0 +1,29 @@ +using EasyCon.Core.Config; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 用户配置(ConfigState)的加载、防抖保存与关窗冲刷。 +/// 读/写盘策略(含 500ms 防抖、UI 线程回写)集中于此; +/// VM 只负责 ConfigState ↔ 自身属性的映射(apply/collect 两个方向)。 +/// +public interface IUserSettingsService +{ + /// 最近一次加载成功的配置。Load() 之前为默认值。 + ConfigState Current { get; } + + /// 加载期间为 true;期间的 ScheduleSave 全部忽略(防 apply 触发回存风暴)。 + bool IsLoading { get; } + + /// 读盘 + 在 IsLoading 保护下执行 apply。读盘失败回退默认值并记日志(语义同原 catch 块)。 + void Load(Action apply); + + /// 立即执行 collect 到 Current 并写盘(写盘异常记日志,不上抛)。 + void Save(Action collect); + + /// 500ms 防抖后在 UI 线程执行 collect + 写盘;IsLoading 期间直接忽略。 + void ScheduleSave(Action collect); + + /// 取消挂起的防抖并立即 Save(关窗前调用,防丢最后一次更改)。 + void Flush(Action collect); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IVirtualPadService.cs b/src/EasyCon2.Avalonia/Services/IVirtualPadService.cs new file mode 100644 index 00000000..bfda03a4 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IVirtualPadService.cs @@ -0,0 +1,23 @@ +using Avalonia.Controls; +using EasyCon.Core.Config; +using EasyCon.Core.Input; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 虚拟手柄覆盖层服务抽象(实现在 ), +/// 让上层(Services/Shell)只依赖端口而不依赖 VPad 实现类型。 +/// +public interface IVirtualPadService +{ + bool IsActive { get; } + event Action? Exited; + event Action? OverlayKeyEvent; + void SetOwner(Window owner); + void Show(); + void Exit(); + void Deactivate(); + void SwitchInput(IInputBinder binder); + void UpdateKeyMapping(KeyMappingConfig mapping); + void RegisterEscapeKey(Func keydown, Func keyup); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/IWindowService.cs b/src/EasyCon2.Avalonia/Services/IWindowService.cs new file mode 100644 index 00000000..5722e6c2 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/IWindowService.cs @@ -0,0 +1,34 @@ +using Avalonia.Controls; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 子窗口管理服务抽象:按键注册表化的单例窗口管理器。 +/// 窗口的构造(View/ViewModel 装配)由组合根在启动时经 注册, +/// 本接口只暴露通用的单例展示语义,使 Services 层零 feature 依赖。 +/// +public interface IWindowService +{ + /// 子窗口 Owner(组合根注入)。 + Window? Owner { get; set; } + + /// 注册单实例窗口工厂(组合根启动时调用)。 + void Register(string key, Func factory); + + /// 单例展示:已存在且可见则激活(最小化→还原→Activate)返回 false;否则创建并 Show 返回 true。 + bool Show(string key); +} + +/// 子窗口注册表的标准键。 +public static class WindowKeys +{ + public const string EspConfig = "esp-config"; + public const string AlertConfig = "alert-config"; + public const string ModelsConfig = "models-config"; + public const string McpConfig = "mcp-config"; + public const string KeyMapping = "key-mapping"; + public const string ScriptSyntax = "script-syntax"; + public const string AudioLabels = "audio-labels"; + public const string AudioMatchTest = "audio-match-test"; + public const string DrawingBoard = "drawing-board"; +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/LocalFileStorage.cs b/src/EasyCon2.Avalonia/Services/LocalFileStorage.cs new file mode 100644 index 00000000..f3311195 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/LocalFileStorage.cs @@ -0,0 +1,15 @@ +using System.Text; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 的默认实现:纯 System.IO 转发,语义逐一对应,不做增强。 +/// +public sealed class LocalFileStorage : IFileStorage +{ + public byte[] ReadAllBytes(string path) => File.ReadAllBytes(path); + + public string ReadAllText(string path) => File.ReadAllText(path); + + public void WriteAllText(string path, string contents, Encoding encoding) => File.WriteAllText(path, contents, encoding); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/LocalFileSystem.cs b/src/EasyCon2.Avalonia/Services/LocalFileSystem.cs new file mode 100644 index 00000000..30e2d679 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/LocalFileSystem.cs @@ -0,0 +1,82 @@ +using System.Text; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 的默认实现:纯 System.IO 转发,语义逐一对应,不做增强。 +/// +public sealed class LocalFileSystem : IFileSystem +{ + public static LocalFileSystem Instance { get; } = new(); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public bool FileExists(string path) => File.Exists(path); + + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + + public void DeleteDirectory(string path) => Directory.Delete(path, recursive: true); + + public void DeleteFile(string path) => File.Delete(path); + + public IReadOnlyList GetDirectories(string path) => Directory.GetDirectories(path); + + public IReadOnlyList GetFiles(string path) => Directory.GetFiles(path); + + public DateTime? GetLastWriteTimeUtc(string path) + { + try + { + FileInfo fileInfo = new(path); + fileInfo.Refresh(); + return fileInfo.Exists ? fileInfo.LastWriteTimeUtc : null; + } + catch + { + return null; + } + } + + public void Move(string sourcePath, string destinationPath) => Directory.Move(sourcePath, destinationPath); + + public void MoveFile(string sourcePath, string destinationPath, bool overwrite = false) + => File.Move(sourcePath, destinationPath, overwrite); + + public void CopyFile(string source, string destination, bool overwrite) => File.Copy(source, destination, overwrite); + + public void CopyDirectory(string sourceDirectory, string targetDirectory) + { + Directory.CreateDirectory(targetDirectory); + + foreach (var file in Directory.GetFiles(sourceDirectory)) + { + var targetFile = Path.Combine(targetDirectory, Path.GetFileName(file)); + File.Copy(file, targetFile); + } + + foreach (var directory in Directory.GetDirectories(sourceDirectory)) + { + var targetChildDirectory = Path.Combine(targetDirectory, Path.GetFileName(directory)); + CopyDirectory(directory, targetChildDirectory); + } + } + + public void WriteAllText(string path, string contents, Encoding encoding) => File.WriteAllText(path, contents, encoding); + + public string GetFileName(string path) => Path.GetFileName(path); + + public string? GetDirectoryName(string path) => Path.GetDirectoryName(path); + + public string PathCombine(params string[] parts) => Path.Combine(parts); + + public string GetFileExtension(string path) => Path.GetExtension(path); + + public bool ArePathsEqual(string left, string right) + { + // 自 FileTreeViewModel 原有的 PathComparer 逻辑原样搬入 + StringComparer comparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + return comparer.Equals(left, right); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/Services/LogService.cs b/src/EasyCon2.Avalonia/Services/LogService.cs similarity index 80% rename from src/EasyCon2.Avalonia.Core/Services/LogService.cs rename to src/EasyCon2.Avalonia/Services/LogService.cs index 36f3dfa0..3893b2d3 100644 --- a/src/EasyCon2.Avalonia.Core/Services/LogService.cs +++ b/src/EasyCon2.Avalonia/Services/LogService.cs @@ -1,9 +1,9 @@ -using Avalonia.Threading; using EasyCon.Core.Services; +using EasyCon2.Avalonia.Services; using EasyScript; using Serilog; -namespace EasyCon2.Avalonia.Core.Services; +namespace EasyCon2.Avalonia.Services; public class LogService : ILogService, IDisposable { @@ -12,21 +12,32 @@ public class LogService : ILogService, IDisposable private readonly object _lock = new(); private readonly string _logDirectory; private readonly ILogger _fileLogger; + private readonly IUiDispatcher _ui; public event Action? LogAppended; - public LogService() + public LogService(IUiDispatcher? uiDispatcher = null) { + _ui = uiDispatcher ?? SynchronousUiDispatcher.Instance; _flushTimer = new Timer(Flush, null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); + // 安装在 Program Files 等只读目录时创建 logs/ 会失败,回退到 %TEMP% 保证可启动 _logDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs"); - Directory.CreateDirectory(_logDirectory); + try + { + Directory.CreateDirectory(_logDirectory); + } + catch + { + _logDirectory = Path.Combine(Path.GetTempPath(), "EasyCon", "logs"); + Directory.CreateDirectory(_logDirectory); + } _fileLogger = new LoggerConfiguration() .WriteTo.File( Path.Combine(_logDirectory, "easycon-.log"), rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true, fileSizeLimitBytes: 10 * 1024 * 1024, - retainedFileCountLimit: null, + retainedFileCountLimit: 30, shared: false, buffered: true, flushToDiskInterval: TimeSpan.FromSeconds(2), @@ -74,7 +85,7 @@ public void Dispose() public void Clear() { lock (_lock) { _entries.Clear(); } - Dispatcher.UIThread.Post(() => LogAppended?.Invoke(null, null)); + _ui.Post(() => LogAppended?.Invoke(null, null)); } private void Flush(object? state) @@ -93,7 +104,7 @@ private void Flush(object? state) if (batch.Length > MaxUiBatch) batch = batch[^MaxUiBatch..]; - Dispatcher.UIThread.Post(() => + _ui.Post(() => { foreach (var (text, color) in batch) LogAppended?.Invoke(text, color); diff --git a/src/EasyCon2.Avalonia/Services/MockControllerService.cs b/src/EasyCon2.Avalonia/Services/MockControllerService.cs index 27584051..f1f60926 100644 --- a/src/EasyCon2.Avalonia/Services/MockControllerService.cs +++ b/src/EasyCon2.Avalonia/Services/MockControllerService.cs @@ -8,9 +8,10 @@ public sealed class MockControllerService : IControllerService public event Action? AvailableSourcesChanged; public event Action? Disconnected; - public string[] GetAvailableSources() => ["键盘"]; + public IReadOnlyList GetAvailableSources() + => [new ControlSourceInfo("键盘", ControllerService.KeyboardSourceId)]; - public bool TryConnect(string sourceName) => false; + public bool TryConnect(string sourceId) => false; public void Disconnect() { } diff --git a/src/EasyCon2.Avalonia/Services/ScriptDocumentService.cs b/src/EasyCon2.Avalonia/Services/ScriptDocumentService.cs new file mode 100644 index 00000000..f7cb89b7 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/ScriptDocumentService.cs @@ -0,0 +1,163 @@ +using System.ComponentModel; +using System.Text; +using ILogService = EasyCon.Core.Services.ILogService; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 默认实现:纯状态机,不依赖任何 UI 类型、不依赖 IDialogService。 +/// 脏标记判定、哨兵比较、落盘编码(UTF-8 无 BOM)与失败日志语义均照抄原 MainWindowViewModel。 +/// 所有 setter 在返回前同步 raise PropertyChanged,且与 ObservableProperty 生成代码一致:值未变化时不触发任何事件/副作用。 +/// +public sealed class ScriptDocumentService : IScriptDocumentService +{ + private readonly ILogService _logService; + private string _currentPath = ScriptPathSentinels.NoScriptPath; + private string _text = string.Empty; + private bool _isModified; + private bool _isSaving; + + /// 程序化设置文本(打开/新建/关闭脚本)时抑制修改标记。 + private bool _suppressDirtyTracking; + + public ScriptDocumentService(ILogService logService) + { + _logService = logService; + } + + /// + public string CurrentPath + { + get => _currentPath; + set + { + if (EqualityComparer.Default.Equals(_currentPath, value)) + return; + _currentPath = value; + RaiseChanged(nameof(CurrentPath)); + } + } + + /// + public string Text + { + get => _text; + set + { + if (EqualityComparer.Default.Equals(_text, value)) + return; + _text = value; + // 原 OnEditorTextChanged:非抑制时置脏(值未变时不重复通知,同 ObservableProperty 生成 setter 的相等护栏); + // 无论是否抑制,文本变化本身都要通知(命令重估由订阅方完成)。 + if (!_suppressDirtyTracking && !_isModified) + { + _isModified = true; + RaiseChanged(nameof(IsModified)); + } + RaiseChanged(nameof(Text)); + } + } + + /// + public bool IsModified + { + get => _isModified; + set + { + if (_isModified == value) + return; + _isModified = value; + RaiseChanged(nameof(IsModified)); + } + } + + /// + public bool IsSaving => _isSaving; + + /// + public bool HasSelectedPath => + !string.IsNullOrWhiteSpace(_currentPath) + && _currentPath != ScriptPathSentinels.NoScriptPath + && _currentPath != ScriptPathSentinels.UntitledScript; + + /// + public bool NeedsSaveDialog => !HasSelectedPath; + + /// + public bool WriteToFile(string path) + { + try + { + File.WriteAllText(path, _text, new UTF8Encoding(false)); + return true; + } + catch (Exception ex) + { + // 保存失败必须显式暴露:保持 IsModified,避免"假成功"导致数据丢失 + _logService.AddLog($"保存脚本失败({path}): {ex.Message}"); + return false; + } + } + + /// + public void SetTextSilently(string text) + { + _suppressDirtyTracking = true; + try + { + Text = text; + } + finally + { + _suppressDirtyTracking = false; + } + } + + /// + public void OpenFromPath(string path, string fileText) + { + CurrentPath = path; + SetTextSilently(fileText); + IsModified = false; + } + + /// + public void NewUntitled() + { + SetTextSilently(string.Empty); + IsModified = false; + CurrentPath = ScriptPathSentinels.UntitledScript; + } + + /// + public void Close() + { + SetTextSilently(string.Empty); + IsModified = false; + CurrentPath = ScriptPathSentinels.NoScriptPath; + } + + /// + public bool TryBeginSave() + { + if (_isSaving) + return false; + _isSaving = true; + RaiseChanged(nameof(IsSaving)); + return true; + } + + /// + public void EndSave() + { + if (!_isSaving) + return; + _isSaving = false; + RaiseChanged(nameof(IsSaving)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void RaiseChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia.Core/Services/ScriptRequirements.cs b/src/EasyCon2.Avalonia/Services/ScriptRequirements.cs similarity index 95% rename from src/EasyCon2.Avalonia.Core/Services/ScriptRequirements.cs rename to src/EasyCon2.Avalonia/Services/ScriptRequirements.cs index 435e1d61..0fc88091 100644 --- a/src/EasyCon2.Avalonia.Core/Services/ScriptRequirements.cs +++ b/src/EasyCon2.Avalonia/Services/ScriptRequirements.cs @@ -1,4 +1,4 @@ -namespace EasyCon2.Avalonia.Core.Services; +namespace EasyCon2.Avalonia.Services; /// /// 脚本运行需求检查结果。 diff --git a/src/EasyCon2.Avalonia/Services/ScriptService.cs b/src/EasyCon2.Avalonia/Services/ScriptService.cs new file mode 100644 index 00000000..7e935711 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/ScriptService.cs @@ -0,0 +1,393 @@ +using EasyCon.Capture; +using EasyCon.Capture.Audio; +using EasyCon.Core; +using EasyCon.Core.Capabilities; +using EasyCon.Core.Config; +using EasyCon.Core.Hosting; +using EasyCon.Core.Maa; +using EasyCon.Core.Script; +using EasyCon.Core.Services; +using EasyCon.Script; +using EasyScript; +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +namespace EasyCon2.Avalonia.Services; + +public class ScriptService : IScriptService +{ + private readonly IDeviceService _deviceService; + private readonly ICaptureService _captureService; + private readonly ILogService _logService; + private readonly IAlertService? _alertService; + private readonly IAudioCaptureSource? _audioSource; + private readonly IScriptEngine _engine = new EasyScriptEngine(); + private readonly HostRunCoordinator _runs; + private readonly object _runGate = new(); + private IScriptSession? _session; + private CancellationTokenSource? _cts; + private string? _compiledSource; + private string? _compiledSourcePath; + private ImmutableHashSet _compiledExternalLabels = ImmutableHashSet.Empty; + + public bool IsRunning { get; private set; } + public bool HasKeyAction => _session?.Info.KeyAction ?? false; + public bool HighResolutionTiming { get; set; } + + /// + /// 运行状态变化。注意:可能在调用线程(启动确认前)或脚本工作线程(结束时)触发, + /// 订阅方必须自行封送到 UI 线程后再操作 UI。 + /// + public event Action IsRunningChanged; + + /// + /// 获取脚本运行需求(需先编译)。 + /// + public ScriptRequirements GetRequirements() + { + return new ScriptRequirements( + HasKeyAction: HasKeyAction, + NeedImageRecognition: ScriptHardwareRequirements.RequiresLiveCapture(_session?.Info), + DeviceConnected: _deviceService.IsConnected, + CaptureConnected: _captureService.IsConnected + ); + } + + public ScriptService(IDeviceService deviceService, ICaptureService captureService, ILogService logService, + IAlertService? alertService = null, IAudioCaptureSource? audioSource = null) + { + _deviceService = deviceService; + _captureService = captureService; + _logService = logService; + _alertService = alertService; + _audioSource = audioSource; + _runs = new HostRunCoordinator(deviceService.GetDevice(), captureService.FrameHub); + } + + public async Task CompileAsync(string scriptText, string? fileName) + { + _logService.AddLog("开始编译..."); + + try + { + // 编译大脚本可达数秒,放线程池执行,避免冻结调用方所在的 UI 线程 + var compiled = await Task.Run(() => CompileContent(scriptText, fileName)); + _session = compiled.Session; + if (_session == null) + { + ClearCompiledSnapshot(); + } + else + { + _compiledSource = scriptText; + _compiledSourcePath = string.IsNullOrWhiteSpace(fileName) + ? null + : Path.GetFullPath(fileName); + _compiledExternalLabels = compiled.Labels == null + ? ImmutableHashSet.Empty + : compiled.Labels.Selected.Select(label => label.Name).ToImmutableHashSet(StringComparer.Ordinal); + } + return _session != null; + } + catch (Exception ex) + { + _session = null; + ClearCompiledSnapshot(); + _logService.AddLog($"编译异常: {ex.Message}"); + return false; + } + } + + void ClearCompiledSnapshot() + { + _compiledSource = null; + _compiledSourcePath = null; + _compiledExternalLabels = ImmutableHashSet.Empty; + } + + public string GetFormattedCode() + { + var formattedCode = (_session?.Info.FormatCode() ?? "").Trim(); + formattedCode = Regex.Replace(formattedCode, ",(?! )", ", "); + return formattedCode; + } + + public async Task BuildAsync(bool stripDebug) + { + try + { + string? source = _compiledSource; + string? sourcePath = _compiledSourcePath; + ImmutableHashSet externalLabels = _compiledExternalLabels; + if (source == null) + { + _logService.AddLog("ECX 导出失败: 没有可导出的已编译脚本"); + return []; + } + + EcxExportResult export = await Task.Run(() => EcxExportService.Export( + source, sourcePath, externalLabels, stripDebug)); + foreach (Diagnostic diagnostic in export.Diagnostics.Where(d => d.IsError)) + { + string location = FormatDiagnosticLocation(diagnostic, sourcePath); + _logService.AddLog($"ECX 导出失败: {location}: {diagnostic.Message}"); + } + + if (!export.Success) + { + if (!export.Diagnostics.Any(d => d.IsError)) + _logService.AddLog("ECX 导出失败: 编译未生成可分发镜像"); + return []; + } + + _logService.AddLog($"ECX 导出完成: {export.Bytes!.Length} 字节"); + return export.Bytes; + } + catch (Exception ex) + { + _logService.AddLog($"ECX 导出失败: {ex.Message}"); + return []; + } + } + + public void Run(string scriptPath, string[]? args = null) + { + _logService.AddLog($"开始运行脚本: {Path.GetFileName(scriptPath)}"); + ExecuteScript(() => + { + string fullPath = Path.GetFullPath(scriptPath); + LabelDiscovery labels = LoadScriptLabels(fullPath); + IScriptSession? session = CompileCore( + () => _engine.LoadFile(fullPath, Options([.. labels.Selected.Select(label => label.Name)])), fullPath); + return (session, labels); + }, args, scriptPath); + } + + public void RunFromContent(string content, string[]? args = null, string? fileName = null) + { + _logService.AddLog("===开始运行脚本==="); + ExecuteScript(() => CompileContent(content, fileName), args, fileName); + } + + public void Stop() + { + lock (_runGate) + { + _cts?.Cancel(); + } + + // 取消由运行令牌直接到达 Maa pulse;完成清理由统一协调器等待写出。 + } + + // ── 私有方法 ──────────────────────────────── + + // 编译档位与能力装配都走组合根(EasyCon.Core.Hosting): + // 宿主不再手写 CompileOptions / CapabilitySet 字面量,避免与 CLI 漂移。 + static ScriptHostOptions Options(ImmutableHashSet extVars) => new() + { + Compile = ScriptCompileProfiles.Interactive(extVars), + }; + + /// 编译并落诊断日志;出错返回 null。 + IScriptSession? CompileCore(Func compile, string? sourcePath = null) + { + var session = compile(); + ImmutableArray diag = session.Info.Diagnostics; + if (diag.HasErrors()) + { + var errors = diag.Where(d => d.IsError).DistinctStable(); + foreach (Diagnostic error in errors) + { + string location = FormatDiagnosticLocation(error, sourcePath); + _logService.AddLog($"{location}: {error.Message}"); + } + return null; + } + + if (session.Info.Image == null) + { + _logService.AddLog("编译错误: 编译未生成可运行镜像"); + return null; + } + + _logService.AddLog("编译完成"); + return session; + } + + (IScriptSession? Session, LabelDiscovery? Labels) CompileContent(string content, string? fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + IScriptSession? inMemorySession = CompileCore(() => _engine.FromSource(content, Options([]))); + return (inMemorySession, null); + } + + string fullPath = Path.GetFullPath(fileName); + LabelDiscovery labels = LoadScriptLabels(fullPath); + IScriptSession? session = CompileCore( + () => _engine.FromSource(content, fullPath, Options([.. labels.Selected.Select(label => label.Name)])), fullPath); + return (session, labels); + } + + static LabelDiscovery LoadScriptLabels(string scriptPath) + { + string scriptBasePath = Path.GetDirectoryName(Path.GetFullPath(scriptPath)) ?? ""; + return LegacyLabelCatalog.FromParents(scriptBasePath, AppPaths.DataDir); + } + + static string FormatDiagnosticLocation(Diagnostic diagnostic, string? sourcePath) + { + int line = diagnostic.Location.StartLine + 1; + if (string.IsNullOrWhiteSpace(diagnostic.FileName)) + return $"行 {line}"; + + string fileName = diagnostic.FileName; + if (!string.IsNullOrWhiteSpace(sourcePath)) + { + string baseDirectory = Path.GetDirectoryName(Path.GetFullPath(sourcePath)) ?? ""; + if (Path.IsPathRooted(fileName)) + fileName = Path.GetRelativePath(baseDirectory, Path.GetFullPath(fileName)); + } + return $"{fileName.Replace('\\', '/')}:{line}"; + } + + /// + /// 脚本执行主流程:编译 → 检查需求 → 连接设备 → 能力装配 → 运行。 + /// + /// 编译回调,返回 (会话(null=编译失败), 标签匹配委托) + private void ExecuteScript(Func<(IScriptSession? Session, LabelDiscovery? Labels)> compile, string[]? args, string? sourcePath = null) + { + CancellationToken token; + lock (_runGate) + { + if (IsRunning) + { + _logService.AddLog("脚本已在运行中,忽略本次启动请求"); + return; + } + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + token = _cts.Token; + IsRunning = true; + } + + IsRunningChanged?.Invoke(true); + + // 不把 token 传给 Task.Run:token 预先取消时任务不会调度,委托体(含 finally + // 的状态复位)整体不执行,IsRunning 将永久卡死。取消只经 session.Run(token) 生效。 + Task.Run(() => + { + try + { + var (session, labels) = compile(); + _session = session; + if (session == null) + return; + + token.ThrowIfCancellationRequested(); + + LabelGeneration? generation = null; + IReadOnlyList requiredLabels = ScriptHardwareRequirements.ReferencedLabels(session.Info); + if (requiredLabels.Count != 0) + { + string project = sourcePath == null ? AppPaths.DataDir : Path.GetDirectoryName(Path.GetFullPath(sourcePath))!; + string? ocrPath = Environment.GetEnvironmentVariable("EASYCON_MAA_OCR_PROFILE"); + LabelConversionResult conversion = MaaLabelConverter.Build(labels!, MaaLabelConverter.CacheRoot(project), LegacyCompatibilityProfile.LegacyGui, + ocrProfile: string.IsNullOrWhiteSpace(ocrPath) ? null : MaaOcrProfile.Load(ocrPath)); + foreach (LabelDiagnostic diagnostic in conversion.Report.Diagnostics) + _logService.AddLog($"标签 {diagnostic.Code}: {diagnostic.Label}: {diagnostic.Message}"); + generation = conversion.Generation ?? throw new InvalidDataException("标签资源转换未发布"); + generation.Preflight(requiredLabels); + } + + // 运行所需能力必须来自本次编译结果;CompileAsync 可能并发更新公开的编辑器会话。 + bool hasKeyAction = session.Info.KeyAction; + bool needImageRecognition = ScriptHardwareRequirements.RequiresLiveCapture(session.Info); + ScriptRequirements requirements = new( + HasKeyAction: hasKeyAction, + NeedImageRecognition: needImageRecognition, + DeviceConnected: _deviceService.IsConnected, + CaptureConnected: _captureService.IsConnected); + if (!requirements.CanRun) + { + var reasons = requirements.GetBlockReasons(); + foreach (var reason in reasons) + _logService.AddLog($"❌ {reason}"); + return; + } + + // 尝试自动连接单片机 + if (hasKeyAction && !_deviceService.IsConnected) + { + _logService.AddLog("脚本需要单片机,尝试自动连接..."); + var port = _deviceService.AutoConnect(); + if (port == null) + { + _logService.AddLog("错误: 自动连接单片机失败"); + return; + } + _logService.AddLog($"自动连接成功: {port}"); + } + + if (needImageRecognition) _captureService.SetCaptureProperties(1920, 1080); + _runs.Run("ecs", sourcePath, hasKeyAction, token, scope => + { + var frameDelegate = FrameDelegateFactory.CreateFrame(() => scope.AcquireFrame(_captureService.AcquireLatestFrame)); + using MaaVisionService? vision = generation == null ? null : new(generation, + () => scope.AcquireFrame(_captureService.AcquireLatestFrame), scope.Token); + + // 能力装配:唯一装配点(ScriptHostAssembler)。宿主只提供原料(pad/帧/标签委托/日志), + // OCR、推理、宿主环境、文件能力的默认值与释放统一由租约承担。 + // args 经 HostEnvironment 进入 VM —— 此前 ExecuteScript 的 args 形参被静默丢弃,ARG() 恒为空。 + using CapabilityLease lease = ScriptHostAssembler.Assemble(new ScriptHostContext + { + PadInput = hasKeyAction ? scope.CreatePad(HighResolutionTiming ? DelayType.HighResolution : DelayType.LowCPU) : null, + Console = new ConsoleIoAdapter(_logService, _alertService), + Frame = frameDelegate, + Roi = MatExtensions.CropBase64, + VisionService = vision, + CancellationToken = scope.Token, + Args = args, + AppDir = AppDomain.CurrentDomain.BaseDirectory, + AudioSource = _audioSource, + ScriptDirectory = string.IsNullOrWhiteSpace(sourcePath) ? null : Path.GetDirectoryName(Path.GetFullPath(sourcePath)), + }); + + if (hasKeyAction && lease.Capabilities.Input == null) + throw new InvalidOperationException("脚本包含按键操作,但运行能力中未装配输入设备"); + + session.Run(scope.Token, lease.Capabilities); + }, requiresCapture: needImageRecognition, compatibilityProfile: nameof(LegacyCompatibilityProfile.LegacyGui), resourceGeneration: generation?.Id); + _logService.AddLog("脚本运行完成"); + } + catch (OperationCanceledException) + { + _logService.AddLog("脚本已终止"); + } + catch (ScriptException ex) + { + _logService.AddLog($"运行出错: {ex.Message} (行{ex.Address})"); + } + catch (InvalidOperationException ex) + { + _logService.AddLog($"运行错误: {ex.Message}"); + } + catch (Exception ex) + { + _logService.AddLog($"意外错误: {ex.Message}"); + } + finally + { + // OCR/推理原生资源的释放已由 CapabilityLease 在 try 作用域内完成, + // 此处只收尾设备状态与运行标志。 + + lock (_runGate) + { + IsRunning = false; + } + IsRunningChanged?.Invoke(false); + } + }); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/SkiaImageProcessor.cs b/src/EasyCon2.Avalonia/Services/SkiaImageProcessor.cs new file mode 100644 index 00000000..fbf2190a --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/SkiaImageProcessor.cs @@ -0,0 +1,179 @@ +using EasyCon2.Avalonia.Models; +using EasyCon2.Avalonia.Services; +using SkiaSharp; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 的 SkiaSharp 实现(与 Avalonia 控件无耦合, +/// 编码路径与 headless 渲染测试一致,稳定不崩)。 +/// +public sealed class SkiaImageProcessor : IImageProcessor +{ + public byte[]? Crop(byte[] sourceBytes, int x, int y, int width, int height) + { + if (sourceBytes == null || sourceBytes.Length == 0) + return null; + if (width <= 0 || height <= 0) + return null; + + try + { + using var source = SKBitmap.Decode(sourceBytes); + if (source == null) + return null; + + x = Math.Clamp(x, 0, source.Width - 1); + y = Math.Clamp(y, 0, source.Height - 1); + width = Math.Clamp(width, 1, source.Width - x); + height = Math.Clamp(height, 1, source.Height - y); + + using var image = SKImage.FromBitmap(source); + using var subset = image.Subset(new SKRectI(x, y, x + width, y + height)); + if (subset == null) + return null; + + using var data = subset.Encode(SKEncodedImageFormat.Png, 100); + return data?.ToArray(); + } + catch + { + return null; + } + } + + public byte[]? LoadAsPng(string filePath) + { + try + { + if (!File.Exists(filePath)) + return null; + + using var source = SKBitmap.Decode(filePath); + if (source == null) + return null; + + using var image = SKImage.FromBitmap(source); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + return data?.ToArray(); + } + catch + { + return null; + } + } + + public PixelImage? LoadPixels(string filePath) + { + try + { + if (!File.Exists(filePath)) + return null; + + using SKCodec? codec = SKCodec.Create(filePath); + if (codec == null) + return null; + + // 非预乘:半透明像素的 RGB 与 System.Drawing.Bitmap.GetPixel 读到的一致 + SKImageInfo info = new(codec.Info.Width, codec.Info.Height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + using SKBitmap bitmap = new(info); + SKCodecResult result = codec.GetPixels(info, bitmap.GetPixels()); + if (result != SKCodecResult.Success && result != SKCodecResult.IncompleteInput) + return null; + + return new PixelImage(info.Width, info.Height, bitmap.Bytes); + } + catch + { + return null; + } + } + + public PixelImage FitTo(PixelImage source, int width, int height, ImageFit fit, CoverTransform? cover = null, bool nearest = false) + { + CoverTransform transform = fit == ImageFit.Cover ? cover ?? CoverTransform.Identity : CoverTransform.Identity; + if (source.Width == width && source.Height == height && transform.IsIdentity) + return source; + + SKImageInfo sourceInfo = new(source.Width, source.Height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + using SKImage image = SKImage.FromPixelCopy(sourceInfo, source.Rgba); + // 光栅 Surface 只支持预乘;读回时再转成非预乘,与 LoadPixels 的约定一致 + using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)); + surface.Canvas.Clear(SKColors.Transparent); + SKSamplingOptions sampling = nearest ? new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None) : new SKSamplingOptions(SKCubicResampler.Mitchell); + if (fit == ImageFit.Cover) + DrawCover(surface.Canvas, image, source.Width, source.Height, width, height, transform, sampling); + else + surface.Canvas.DrawImage(image, Destination(source.Width, source.Height, width, height, fit), sampling); + + SKImageInfo targetInfo = new(width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + byte[] rgba = new byte[width * height * 4]; + using SKImage snapshot = surface.Snapshot(); + unsafe + { + fixed (byte* pixels = rgba) + snapshot.ReadPixels(targetInfo, (IntPtr)pixels, width * 4, 0, 0); + } + return new PixelImage(width, height, rgba); + } + + /// + /// 等比裁切:以画布中心为原点旋转、缩放后绘制。基础缩放取"旋转后仍能铺满画布"的最小值(0° 时即普通等比裁切), + /// 再乘以 ;取景位置在图片超出画布的余量内移动。 + /// 有旋转时余量按外接矩形计算,平移到边缘可能露出角落的空白(透明,按白色处理)。 + /// + private static void DrawCover(SKCanvas canvas, SKImage image, int sourceWidth, int sourceHeight, int width, int height, + CoverTransform transform, SKSamplingOptions sampling) + { + double radians = transform.AngleDegrees * Math.PI / 180; + double cos = Math.Abs(Math.Cos(radians)); + double sin = Math.Abs(Math.Sin(radians)); + double scale = Math.Max((width * cos + height * sin) / sourceWidth, (width * sin + height * cos) / sourceHeight) + * Math.Max(1, transform.Zoom); + double boundsWidth = scale * (sourceWidth * cos + sourceHeight * sin); + double boundsHeight = scale * (sourceWidth * sin + sourceHeight * cos); + // 取景位置向右(PanX > 0)意味着露出图片右侧,图片整体左移 + double dx = -Math.Clamp(transform.PanX, -1, 1) * Math.Max(0, (boundsWidth - width) / 2); + double dy = -Math.Clamp(transform.PanY, -1, 1) * Math.Max(0, (boundsHeight - height) / 2); + + canvas.Save(); + canvas.Translate((float)(width / 2.0 + dx), (float)(height / 2.0 + dy)); + canvas.RotateDegrees((float)transform.AngleDegrees); + canvas.Scale((float)scale); + canvas.DrawImage(image, new SKRect(-sourceWidth / 2f, -sourceHeight / 2f, sourceWidth / 2f, sourceHeight / 2f), sampling); + canvas.Restore(); + } + + private static SKRect Destination(int sourceWidth, int sourceHeight, int width, int height, ImageFit fit) + { + if (fit == ImageFit.Stretch) + return new SKRect(0, 0, width, height); + float scaleX = (float)width / sourceWidth; + float scaleY = (float)height / sourceHeight; + float scale = fit == ImageFit.Contain ? Math.Min(scaleX, scaleY) : Math.Max(scaleX, scaleY); + float drawWidth = sourceWidth * scale; + float drawHeight = sourceHeight * scale; + float left = (width - drawWidth) / 2; + float top = (height - drawHeight) / 2; + return new SKRect(left, top, left + drawWidth, top + drawHeight); + } + + public bool SavePng(PixelImage image, string filePath) + { + try + { + SKImageInfo info = new(image.Width, image.Height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + using SKImage encoded = SKImage.FromPixelCopy(info, image.Rgba); + using SKData? data = encoded.Encode(SKEncodedImageFormat.Png, 100); + if (data == null) + return false; + using FileStream stream = File.Create(filePath); + data.SaveTo(stream); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/ThemeKeys.cs b/src/EasyCon2.Avalonia/Services/ThemeKeys.cs new file mode 100644 index 00000000..4325fc59 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/ThemeKeys.cs @@ -0,0 +1,39 @@ +namespace EasyCon2.Avalonia.Services; + +/// +/// 主题持久化键。config.json 存稳定 key(不再把中文显示名当协议写入配置), +/// 读取时把 key 与历史中文名都映射回当前显示名。 +/// +public static class ThemeKeys +{ + // 配色方案 + public const string SchemeLight = "light"; + public const string SchemeWarm = "warm"; + public const string SchemeDark = "dark"; + + // 外观样式 + public const string StyleClassic = "classic"; + public const string StyleCard = "card"; + + /// 显示名 → 稳定 key(保存时用)。 + public static string Persist(string displayName) => displayName switch + { + ThemeManager.whiteGraySchemeName => SchemeLight, + ThemeManager.WarmToneSchemeName => SchemeWarm, + ThemeManager.DarkModeSchemeName => SchemeDark, + ThemeManager.ClassicStyleName => StyleClassic, + ThemeManager.RoundedStyleName => StyleCard, + _ => displayName + }; + + /// 稳定 key / 旧中文名 → 当前显示名(加载时用)。 + public static string Restore(string stored) => stored switch + { + SchemeLight => ThemeManager.whiteGraySchemeName, + SchemeWarm => ThemeManager.WarmToneSchemeName, + SchemeDark => ThemeManager.DarkModeSchemeName, + StyleClassic => ThemeManager.ClassicStyleName, + StyleCard => ThemeManager.RoundedStyleName, + _ => stored + }; +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/ThemeManager.cs b/src/EasyCon2.Avalonia/Services/ThemeManager.cs index daaab3da..34ea4677 100644 --- a/src/EasyCon2.Avalonia/Services/ThemeManager.cs +++ b/src/EasyCon2.Avalonia/Services/ThemeManager.cs @@ -134,6 +134,7 @@ public void ApplyColorScheme(string colorSchemeName, bool followSystemThemeVaria SetFluentAccentResources(palette); SetDirectionalShadowResources(SelectedThemeStyleName == ClassicStyleName, palette.DirectionalShadow); SetComboBoxGlyphResources(colorSchemeName, palette); + SetMappingResources(colorSchemeName, palette); DarkModeChanged?.Invoke(IsDarkMode); } @@ -188,6 +189,7 @@ public void ApplyThemeStyle(string themeStyleName) SetResource("WorkbenchSettingsCardBrush", new SolidColorBrush(settingsCardColor)); SetResource("WorkbenchStatusDisconnectedBackgroundColor", statusDisconnectedBackground); SetResource("WorkbenchStatusDisconnectedBrush", new SolidColorBrush(statusDisconnectedBackground)); + SetMappingResources(SelectedColorSchemeName, palette); } SetDirectionalShadowResources(themeStyleName == ClassicStyleName, palette?.DirectionalShadow ?? Colors.Transparent); @@ -281,6 +283,37 @@ private static LinearGradientBrush CreateDirectionalShadowBrush(Color shadowColo }; } + private static void SetMappingResources(string colorSchemeName, WorkbenchColorScheme palette) + { + bool isDark = colorSchemeName == DarkModeSchemeName; + Color background = isDark ? Color.FromRgb(0x10, 0x10, 0x11) : palette.Window; + Color accent = Color.Parse("#FF5C7FA0"); + Color title = palette.Text; + Color muted = palette.MutedText; + Color highlight = isDark ? Color.FromRgb(0xFF, 0xFF, 0xFF) : palette.Text; + // 冲突色:略微鲜艳但不刺眼,三套配色统一 + Color conflict = Color.Parse("#FFE5484D"); + Color unmapped = isDark + ? Color.FromRgb(0x6E, 0x73, 0x7C) + : colorSchemeName == whiteGraySchemeName + ? Color.FromRgb(0xA9, 0xB0, 0xBA) + : Color.FromRgb(0xB4, 0xAD, 0xA2); + + SetMappingResource("MappingBackground", background); + SetMappingResource("MappingAccent", accent); + SetMappingResource("MappingTitle", title); + SetMappingResource("MappingMuted", muted); + SetMappingResource("MappingUnmapped", unmapped); + SetMappingResource("MappingHighlight", highlight); + SetMappingResource("MappingConflict", conflict); + } + + private static void SetMappingResource(string key, Color color) + { + SetResource(key + "Color", color); + SetResource(key + "Brush", new SolidColorBrush(color)); + } + private static void SetComboBoxGlyphResources(string colorSchemeName, WorkbenchColorScheme palette) { var glyphColor = colorSchemeName == DarkModeSchemeName diff --git a/src/EasyCon2.Avalonia/Services/ToolCallService.cs b/src/EasyCon2.Avalonia/Services/ToolCallService.cs index 931104b7..1d0177cf 100644 --- a/src/EasyCon2.Avalonia/Services/ToolCallService.cs +++ b/src/EasyCon2.Avalonia/Services/ToolCallService.cs @@ -1,7 +1,14 @@ -using Avalonia.Threading; +using Avalonia.Threading; using EasyCon.Capture; -using EasyCon2.Avalonia.Core.Services; -using OpenCvSharp; +using EasyCon.Core; +using EasyCon.Core.Capabilities; +using EasyCon.Core.Hosting; +using EasyCon.Core.LLM.Agent.Tools; +using EasyCon.Core.Maa; +using EasyCon2.Avalonia.Services; +using EasyDevice; +using EasyScript; +using System.Collections.Concurrent; using System.Text; namespace EasyCon2.Avalonia.Services; @@ -14,27 +21,35 @@ public class ToolCallService : IToolCallService { private readonly IScriptService _scriptService; private readonly ICaptureService _captureService; - private readonly Queue _logBuffer; + private readonly IDeviceService _deviceService; + private readonly ConcurrentQueue _logBuffer; private readonly Func _getProjectDirectoryPath; private readonly Func _getEditorText; private readonly Action _setEditorText; private readonly Func _getScriptPath; private readonly Func _hasScriptPath; private readonly Func _getDeviceStatus; + private readonly Func? _getDevice; + private CapabilityLease? _ocrLease; public ToolCallService( IScriptService scriptService, ICaptureService captureService, - Queue logBuffer, + IDeviceService deviceService, + ConcurrentQueue logBuffer, Func getProjectDirectoryPath, Func getEditorText, Action setEditorText, Func getScriptPath, Func hasScriptPath, - Func getDeviceStatus) + Func getDeviceStatus, + Func? getDevice = null) { + _getDevice = getDevice; + _scriptService = scriptService; _captureService = captureService; + _deviceService = deviceService; _logBuffer = logBuffer; _getProjectDirectoryPath = getProjectDirectoryPath; _getEditorText = getEditorText; @@ -50,6 +65,8 @@ public ToolCallService( public void WriteScriptContent(string content, bool append) { + // 读-改-写整体在 UI 线程内完成,避免与用户编辑交错导致丢更新; + // 调用方(编排器线程池线程)阻塞等待 UI 线程执行,UI 本身不阻塞。 Dispatcher.UIThread.Invoke(() => { if (append && !string.IsNullOrEmpty(_getEditorText())) @@ -61,23 +78,26 @@ public void WriteScriptContent(string content, bool append) public int EditScriptContent(string oldString, string newString, int count) { - var current = _getEditorText() ?? string.Empty; if (string.IsNullOrEmpty(oldString)) return -1; - if (!current.Contains(oldString, StringComparison.Ordinal)) - return -1; + return Dispatcher.UIThread.Invoke(() => + { + var current = _getEditorText() ?? string.Empty; + if (!current.Contains(oldString, StringComparison.Ordinal)) + return -1; - var replaced = 0; - var result = count <= 0 - ? current.Replace(oldString, newString, StringComparison.Ordinal) - : ReplaceLimited(current, oldString, newString, count, out replaced); + var replaced = 0; + var result = count <= 0 + ? current.Replace(oldString, newString, StringComparison.Ordinal) + : ReplaceLimited(current, oldString, newString, count, out replaced); - if (count <= 0) - replaced = current.Split([oldString], StringSplitOptions.None).Length - 1; + if (count <= 0) + replaced = current.Split([oldString], StringSplitOptions.None).Length - 1; - Dispatcher.UIThread.Invoke(() => _setEditorText(result)); - return replaced; + _setEditorText(result); + return replaced; + }); } private static string ReplaceLimited(string source, string oldStr, string newStr, int maxCount, out int replaced) @@ -139,53 +159,86 @@ public async Task FormatScriptAsync() return null; var sb = new StringBuilder(); - BuildTreeMd(sb, projectDir, 0, false); + var state = new TreeBuildState(); + BuildTreeMd(sb, projectDir, 0, false, state); return sb.ToString(); } - /// - public string? GetProjectDirectory() + /// 目录树构建限制:超限即截断并注明,防止大目录输出挤占上下文。 + private const int MaxTreeDepth = 6; + private const int MaxTreeEntries = 500; + private static readonly HashSet SkippedDirectories = new(StringComparer.OrdinalIgnoreCase) + { "bin", "obj", "node_modules" }; + + private sealed class TreeBuildState { - var projectDir = _getProjectDirectoryPath(); - return !string.IsNullOrEmpty(projectDir) && Directory.Exists(projectDir) - ? projectDir - : null; + public int Entries; + public bool Truncated; } - private static void BuildTreeMd(StringBuilder sb, string path, int depth, bool isInLib) + private static void BuildTreeMd(StringBuilder sb, string path, int depth, bool isInLib, TreeBuildState state) { + if (state.Truncated) + return; + + if (depth > MaxTreeDepth) + { + sb.Append("[更深目录已省略]\n"); + return; + } + var indent = new string(' ', depth * 2); - var entries = Directory.GetFileSystemEntries(path) - .Where(e => !Path.GetFileName(e).StartsWith('.')) - .OrderBy(e => !Directory.Exists(e)) - .ThenBy(e => Path.GetFileName(e), StringComparer.OrdinalIgnoreCase) - .ToArray(); + IEnumerable entries; + try + { + entries = Directory.GetFileSystemEntries(path) + .Where(e => !Path.GetFileName(e).StartsWith('.')) + .OrderBy(e => !Directory.Exists(e)) + .ThenBy(e => Path.GetFileName(e), StringComparer.OrdinalIgnoreCase); + } + catch (Exception) + { + return; // 无权限/已删除的子目录直接跳过 + } var name = Path.GetFileName(path); var currentIsLib = isInLib || string.Equals(name, "lib", StringComparison.OrdinalIgnoreCase); foreach (var entry in entries) { + if (++state.Entries > MaxTreeEntries) + { + sb.Append("[项目树已截断:条目超过 ").Append(MaxTreeEntries).Append("]\n"); + state.Truncated = true; + return; + } + var entryName = Path.GetFileName(entry); var isDir = Directory.Exists(entry); + if (isDir && SkippedDirectories.Contains(entryName)) + continue; - if (isDir) - { - sb.Append(indent).Append("- ").Append(entryName).Append("/\n"); - BuildTreeMd(sb, entry, depth + 1, currentIsLib); - } - else - { - var tag = ""; - var ext = Path.GetExtension(entry).ToUpperInvariant(); - if (ext is ".IL" or ".ILX") tag = " `标签`"; - else if (currentIsLib && ext is ".ECS" or ".TXT") tag = " `库`"; + var tag = ""; + var ext = Path.GetExtension(entry).ToUpperInvariant(); + if (ext is ".IL" or ".ILX") tag = " `标签`"; + else if (currentIsLib && ext is ".ECS" or ".TXT") tag = " `库`"; - sb.Append(indent).Append("- ").Append(entryName).Append(tag).Append('\n'); - } + sb.Append(indent).Append("- ").Append(entryName).Append(tag).Append(isDir ? "/" : "").Append('\n'); + + if (isDir) + BuildTreeMd(sb, entry, depth + 1, currentIsLib, state); } } + /// + public string? GetProjectDirectory() + { + var projectDir = _getProjectDirectoryPath(); + return !string.IsNullOrEmpty(projectDir) && Directory.Exists(projectDir) + ? projectDir + : null; + } + // ── 脚本执行 ────────────────────────────── public async Task RunScriptAsync() @@ -197,10 +250,7 @@ public async Task RunScriptAsync() if (!await _scriptService.CompileAsync(text, path)) return false; - if (path is not null) - _scriptService.Run(path); - else - _scriptService.RunFromContent(text); + _scriptService.RunFromContent(text, fileName: path); return true; } @@ -208,17 +258,44 @@ public async Task RunScriptAsync() public bool IsScriptRunning => _scriptService.IsRunning; - // ── 视觉 ──────────────────────────────── + // ── 视觉 / 原子输入 ───────────────────── + // 能力供给制:本类只负责"按连接状态惰性提供 Core 能力实例", + // 动作语义(按键/摇杆/取帧/OCR)由工具直接在能力端口上实现。 + + /// 手柄输入能力。设备未连接返回 null;每次现建(设备重连后旧适配器失效,不缓存)。 + public IPadInput? GetPadInput() => + _deviceService.IsConnected ? new PadInputAdapter(CreatePad()) : null; - public string? GetCurrentFrameBase64() + private ICGamePad CreatePad() => new GamePadAdapter(_deviceService.GetDevice(), highResolution: false); + + public ICaptureSource? GetCaptureSource() { - using var lease = _captureService.AcquireLatestFrame(); - if (lease == null || lease.Mat.Empty()) return null; - var mat = lease.Mat; + if (!_captureService.IsConnected) + return null; + _captureSource ??= new DelegateCaptureSource( + FrameDelegateFactory.CreateFrame(() => _captureService.AcquireLatestFrame())); + return _captureSource; + } - using var resized = mat.Resize(0.5); - var bytes = resized.ToBytes(".png"); - return Convert.ToBase64String(bytes); + private ICaptureSource? _captureSource; + + public IOcrService? GetOcrService() => AcquireOcr(); + + /// + /// 惰性装配仅感知用途的能力租约(OCR 走 ScriptHostAssembler 默认值,Pad 为空)。 + /// 装配一次复用,避免每次工具调用重复初始化 OCR 引擎。 + /// + private IOcrService? AcquireOcr() + { + if (_ocrLease is null) + { + _ocrLease = ScriptHostAssembler.Assemble(new ScriptHostContext + { + Frame = FrameDelegateFactory.CreateFrame(() => _captureService.AcquireLatestFrame()), + AppDir = AppDomain.CurrentDomain.BaseDirectory, + }); + } + return _ocrLease.Capabilities.Ocr; } // ── 日志 ──────────────────────────────── @@ -228,9 +305,11 @@ public string GetRecentLogs(int maxLines) if (_logBuffer.Count == 0) return "(暂无日志)"; - var lines = _logBuffer.Count <= maxLines - ? _logBuffer.ToArray() - : _logBuffer.Skip(_logBuffer.Count - maxLines).ToArray(); + const int maxLineChars = 500; + var lines = (_logBuffer.Count <= maxLines + ? _logBuffer.ToArray() + : _logBuffer.Skip(_logBuffer.Count - maxLines).ToArray()) + .Select(l => l.Length <= maxLineChars ? l : l[..maxLineChars] + "…"); return string.Join(Environment.NewLine, lines); } diff --git a/src/EasyCon2.Avalonia/Services/UiPreloader.cs b/src/EasyCon2.Avalonia/Services/UiPreloader.cs new file mode 100644 index 00000000..80b136f8 --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/UiPreloader.cs @@ -0,0 +1,35 @@ +using Avalonia.Threading; +using EasyCon2.Avalonia.Controls; +using System.Threading.Tasks; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 启动期 UI 预热 —— 把「首次打开按键映射窗口」才会付出的解析成本提前到启动阶段。 +/// +public static class UiPreloader +{ + /// + /// 预热按键映射窗口所需的资源: + /// ① Icons.json(约 135KB)解析放后台线程,不占用启动; + /// ② 控制器 SVG 的 Skia 建图放 UI 线程,但排在窗口首帧之后(Background 优先级),不影响启动观感。 + /// + public static void Warmup() + { + // Lazy 工厂在 ExecutionAndPublication 模式下会缓存异常:预热失败后每次取图标 + // 都会重抛。这里吞掉异常,让首次真实使用时自然重试。 + Task.Run(() => + { + try + { + IconRegistry.Preload(); + } + catch + { + // 预热失败不致命,留给首次使用时重试 + } + }); + + Dispatcher.UIThread.Post(ControllerSvgCache.Warmup, DispatcherPriority.Background); + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/UserSettingsService.cs b/src/EasyCon2.Avalonia/Services/UserSettingsService.cs new file mode 100644 index 00000000..e0609efb --- /dev/null +++ b/src/EasyCon2.Avalonia/Services/UserSettingsService.cs @@ -0,0 +1,152 @@ +using Avalonia.Threading; +using EasyCon.Capture; +using EasyCon.Core.Config; +using EasyCon.Core.Services; + +namespace EasyCon2.Avalonia.Services; + +/// +/// 默认实现:ConfigManager 读写盘 + 500ms 防抖。 +/// 防抖线程模型照抄原 MainWindowViewModel.ScheduleSaveUserSettings(Task.Run + Task.Delay + +/// Dispatcher.UIThread.Post 回 UI 线程写盘),未改走 IUiDispatcher,以保持测试时序不变。 +/// +public sealed class UserSettingsService : IUserSettingsService +{ + private readonly Func _load; + private readonly Action _save; + private readonly ILogService? _log; + private CancellationTokenSource? _saveDefer; + + public UserSettingsService(Func? load = null, Action? save = null, ILogService? log = null) + { + _load = load ?? ConfigManager.LoadConfig; + _save = save ?? ConfigManager.SaveConfig; + _log = log; + } + + /// + public ConfigState Current { get; private set; } = new(); + + /// + public bool IsLoading { get; private set; } + + /// + public void Load(Action apply) + { + IsLoading = true; + try + { + Current = SafeLoad(); + apply(Current); + } + finally + { + IsLoading = false; + } + } + + /// 读盘失败回退默认值 + 默认主题并记日志(语义照抄原 LoadUserSettings 的 catch 块)。 + private ConfigState SafeLoad() + { + try + { + ConfigState state = _load(); + new HostProfiles + { + Controller = state.ControllerProfile, + Capture = state.CaptureProfile ?? new CaptureProfile(Backend: state.CaptureType), + }.Validate(); + return state; + } + catch (Exception ex) + { + ThemeManager.Instance.ApplyColorScheme(ThemeManager.whiteGraySchemeName); + ThemeManager.Instance.ApplyThemeStyle(ThemeManager.ClassicStyleName); + _log?.AddLog($"读取用户配置失败,已使用默认设置: {ex.Message}"); + return new ConfigState(); + } + } + + /// + public void Save(Action collect) + { + if (IsLoading) + return; + + collect(Current); + + try + { + _save(Current); + } + catch (Exception ex) + { + _log?.AddLog($"保存用户配置失败: {ex.Message}"); + } + } + + /// + public void ScheduleSave(Action collect) + { + if (IsLoading) + return; + + _saveDefer?.Cancel(); + _saveDefer?.Dispose(); + _saveDefer = new CancellationTokenSource(); + var token = _saveDefer.Token; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(500, token); + } + catch (OperationCanceledException) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + if (!token.IsCancellationRequested) + Save(collect); + }); + }); + } + + /// + public void Flush(Action collect) + { + _saveDefer?.Cancel(); + _saveDefer?.Dispose(); + _saveDefer = null; + Save(collect); + } + + internal static string NormalizeCaptureType(string? captureType) + => CaptureSourceCatalog.NormalizeBackendName(captureType); + + internal static string NormalizeColorSchemeName(string? colorSchemeName, bool legacyDarkMode) + { + return colorSchemeName switch + { + ThemeManager.whiteGraySchemeName or "工业灰" => ThemeManager.whiteGraySchemeName, + ThemeManager.WarmToneSchemeName or "暖色调" => ThemeManager.WarmToneSchemeName, + ThemeManager.DarkModeSchemeName => ThemeManager.DarkModeSchemeName, + _ => legacyDarkMode ? ThemeManager.DarkModeSchemeName : ThemeManager.whiteGraySchemeName + }; + } + + internal static string NormalizeThemeStyleName(string? themeStyleName, string colorSchemeName) + { + return themeStyleName switch + { + ThemeManager.ClassicStyleName => ThemeManager.ClassicStyleName, + ThemeManager.RoundedStyleName => ThemeManager.RoundedStyleName, + _ => colorSchemeName == ThemeManager.whiteGraySchemeName + ? ThemeManager.ClassicStyleName + : ThemeManager.RoundedStyleName + }; + } +} \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Services/WindowService.cs b/src/EasyCon2.Avalonia/Services/WindowService.cs index b2db83bb..9b4848f8 100644 --- a/src/EasyCon2.Avalonia/Services/WindowService.cs +++ b/src/EasyCon2.Avalonia/Services/WindowService.cs @@ -1,105 +1,80 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Media; -using EasyCon2.Avalonia.Core.Services; -using EasyCon2.Avalonia.ViewModels; -using EasyCon2.Avalonia.Views; +using EasyCon2.Avalonia.Services; using ILogService = EasyCon.Core.Services.ILogService; using Resources = EasyCon2.UI.Common.Properties.Resources; namespace EasyCon2.Avalonia.Services; /// -/// IWindowService 实现 —— 负责所有子窗口的创建与生命周期管理。 +/// IWindowService 实现 —— 按键注册表化的单例窗口管理器:各 feature 窗口的 +/// 构造工厂由组合根(App)在启动时注册,本类只负责通用的单例生命周期 +/// (已存在 → 激活,关闭 → 移除键),从而保持 Services 层零 feature 反向依赖。 /// public class WindowService : IWindowService { /// - /// 应用主窗口引用,由 App.axaml.cs 在初始化时注入。 - /// 所有子窗口将此作为 Owner,确保 Z-order 和模态行为正确。 + /// 子窗口的 Owner,由 App 在初始化时注入,确保 Z-order 和模态行为正确。 /// - public static Window? MainWindow { get; set; } + public Window? Owner { get; set; } - private readonly IDeviceService _deviceService; private readonly ILogService _logService; - private readonly IDialogService _dialogService; - private Window? _espConfigWindow; + private readonly Dictionary> _factories = new(StringComparer.Ordinal); + private readonly Dictionary _open = new(StringComparer.Ordinal); - public WindowService(IDeviceService deviceService, ILogService logService, IDialogService dialogService) + public WindowService(ILogService logService) { - _deviceService = deviceService; _logService = logService; - _dialogService = dialogService; } - public void ShowESPConfigWindow() + public void Register(string key, Func factory) { - if (_espConfigWindow != null) - { - if (_espConfigWindow.WindowState == WindowState.Minimized) - _espConfigWindow.WindowState = WindowState.Normal; - _espConfigWindow.Activate(); - return; - } - - try - { - var vm = new ESPConfigViewModel(_deviceService, _logService, _dialogService); - _espConfigWindow = new ESPConfigWindow { DataContext = vm }; - _espConfigWindow.Closed += (_, _) => _espConfigWindow = null; - _espConfigWindow.Show(MainWindow); - } - catch (Exception ex) - { - _logService.AddLog($"打开手柄设置失败: {ex.Message}\n{ex.StackTrace}"); - } + // 同键不同工厂说明组合根装配冲突,启动期快速失败 + if (_factories.TryGetValue(key, out var existing) && existing.Method != factory.Method) + throw new InvalidOperationException($"窗口键重复注册: {key}"); + _factories[key] = factory; } - public void ShowAlertConfigWindow() + public bool Show(string key) { - try - { - new AlertConfigWindow().Show(MainWindow); - } - catch (Exception ex) + if (!_factories.TryGetValue(key, out var create)) { - _logService.AddLog($"打开推送配置失败: {ex.Message}"); + _logService.AddLog($"窗口未注册: {key}"); + return false; } - } - public void ShowModelsConfigWindow() - { - try + if (_open.TryGetValue(key, out var existing) && existing.IsVisible) { - new ModelsConfigWindow().Show(MainWindow); + if (existing.WindowState == WindowState.Minimized) + existing.WindowState = WindowState.Normal; + existing.Activate(); + return false; } - catch (Exception ex) - { - _logService.AddLog($"打开模型配置失败: {ex.Message}"); - } - } - public void ShowMcpConfigWindow() - { try { - new McpConfigWindow().Show(MainWindow); + var window = create(); + _open[key] = window; + window.Closed += (_, _) => _open.Remove(key); + // Show(Window owner) 不接受 null owner(生产环境由 App 先注入 Owner) + if (Owner is not null) + window.Show(Owner); + else + window.Show(); + return true; } catch (Exception ex) { - _logService.AddLog($"打开 MCP 配置失败: {ex.Message}"); + _logService.AddLog($"打开窗口({key})失败: {ex.Message}\n{ex.StackTrace}"); + return false; } } - public void ShowKeyMappingWindow() - { - if (MainWindow == null) return; - var vm = new ViewModels.KeyMappingViewModel(); - var keyMappingWindow = new KeyMappingWindow { DataContext = vm }; - keyMappingWindow.ShowDialog(MainWindow); - } - - public void ShowScriptSyntaxWindow() + /// + /// 构造脚本语法帮助窗口(纯 Avalonia 构造,无 feature 依赖,保留在本类)。 + /// + public static Window BuildScriptSyntaxWindow() { var textBox = new TextBox { @@ -114,7 +89,7 @@ public void ShowScriptSyntaxWindow() ScrollViewer.SetVerticalScrollBarVisibility(textBox, ScrollBarVisibility.Auto); ScrollViewer.SetHorizontalScrollBarVisibility(textBox, ScrollBarVisibility.Disabled); - var window = new Window + return new Window { Title = "脚本语法", Width = 820, @@ -123,6 +98,5 @@ public void ShowScriptSyntaxWindow() MinHeight = 360, Content = textBox }; - window.Show(MainWindow); } } \ No newline at end of file diff --git a/src/EasyCon2.Avalonia/Views/AboutWindow.axaml b/src/EasyCon2.Avalonia/Shell/AboutWindow.axaml similarity index 94% rename from src/EasyCon2.Avalonia/Views/AboutWindow.axaml rename to src/EasyCon2.Avalonia/Shell/AboutWindow.axaml index 9d316131..bc69214e 100644 --- a/src/EasyCon2.Avalonia/Views/AboutWindow.axaml +++ b/src/EasyCon2.Avalonia/Shell/AboutWindow.axaml @@ -1,6 +1,6 @@ + Text="{loc:Translate Text.Feature.RemoteRun}"/> - - - - - - - - - - - - - - - -