diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 3536ffe9..a8919797 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -1,5 +1,79 @@ # GalNet Agent Knowledge +- [模态背景静态标题](lessons/L-20261029-10-disabled-title-foreground.md):禁用背景容器会改变标题继承的前景画刷;静态标题显式绑定 TextPrimary,弹窗验证须挂载真实主页并检查 Color/Opacity,不能只截空背景。 + +- 2026-10-29 CI 发布门禁:release.yml 的 validate-source 在 tag push 后先 fetch master,Test-ReleaseRef.ps1 校验规范版本 tag、checkout HEAD 和 origin/master 祖先归属;build 依赖门禁,publish 只允许 tag push 且 build 成功。主分支普通 push 只跑 CI,手动运行只生成候选。Test-ReleaseGuards.ps1 的 17 项用例覆盖真实临时 Git 图和模拟七 ZIP 上传;Push 在创建 draft 前检查七 ZIP。见 docs/manual/release-packaging.md。 + +- 2026-10-29 内置文档封装:新 galpak v3 固定 pak/game.pak 入口,含 content v3、installation 索引、settings、Gallery、characters、layout 六文档;localization.pak 每文化一个平铺文本条目。使用独立 document GPAK catalog,不加入资源覆盖。新安装 I18n/ 仅外置覆盖;作者与 content v2 旧安装仍用 I18n/External。导出/安装保留 I18n、ResourcePatches、StoryPatches/ 空目录,ZIP 显式保存;兼容 v1/v2 galpak 和 content v2 读取。只读 CLI game inspect 供脚本读取 packed/legacy 描述;Sample profile 身份从 game.pak 读取 projectId。见 docs/spec/file-formats.md、docs/spec/i18n.md、features/F-20261029-02-packed-game-documents。 +- [损坏内容与启动探测](lessons/L-20261029-08-invalid-data-startup-fallback.md):InvalidDataException 不派生 IOException;profile 身份探测显式回退,正式内容加载仍报错。 + +- [自包含平台程序集](lessons/L-20261029-07-self-contained-platform-assemblies.md):自包含 lib 混放 framework 与业务 DLL;契约依赖收集按 runtimepack deps 识别平台归属,不能按 corelib 同目录排除 Core/Builtins。 + +- 2026-10-29 桌面发行布局:应用根只留入口 EXE 和目录;SDK apphost 直接指向 lib/.dll,运行时与原生依赖都在 lib,清单/说明/许可在 docs。Sample 默认加载同级 pak,用户 profile 按内容 projectId 的摘要定位;玩家关闭普通参数,EnableLaunchArguments 默认跟随 EnableGameDebugging,开发发行加 -SampleFlavor Developer。galpak 统一 pak/ 外层、内部路径相对内容根;安装显式目标不追加第二层 pak。见 docs/manual/release-packaging.md、docs/spec/file-formats.md 与 features/F-20261029-01-desktop-package-layout。 +- [原生退出确认](lessons/L-20261029-06-native-exit-confirmation.md):WM_CLOSE 只打开确认遮罩;测试须在自有 HWND 确认退出,再验证零退出码和落盘,不能用强杀替代。 + +- [预览效果像素边界](lessons/L-20261029-04-bounded-scene-capture.md):外层缩图不限制 CPU 效果纹理;逻辑尺寸与像素尺寸分开,正式截图仍保持完整分辨率。 +- [存档缩略图通知](lessons/L-20261029-05-save-thumbnail-notifications.md):整表一次 Reset,保存单槽 Replace,其他行与 Bitmap 复用;异步 I/O 不会消除重复解码卡顿。 + +- [PowerShell 宿主 API 兼容](lessons/L-20261029-03-powershell-runtime-api-compatibility.md):Windows PowerShell 5.1 使用 .NET Framework;现代 System.IO.Path API 不能假定存在,需在 powershell.exe 与 pwsh 分别验证。 + +- [窗口装饰与玩家配色](lessons/L-20261029-02-window-decoration-theme-resources.md):Ursa 2.1 关闭按钮在独立 WindowDrawnDecorations;窗口共享调色板资源,完整 Fluent 样式保留在播放器根。Headless 不生成平台装饰,资源断言不能替代原生截图。 + +- [持久字段与保存基线](lessons/L-20261029-01-content-baseline-field-completeness.md):新增字段必须进入手写相等比较;仅改 Group.storyPackage 应 dirty,局部保存其他配置不能抹掉它。 + +- 2026-10-28 内容打包:项目唯一 Graph,graph/group 为保留 ID 资源;GPAK v3、content v2、save v5。本体闭包先行,各剧情包只减本体;默认 128 MiB 未压缩软阈值(用户后续调大),整片缓冲不保证内存上限。资源矩阵定义在 settings 的 galnet.resourceOverrides,矩阵修改经项目事务即时持久化且撤销/重做落盘,独立导出不 Save All,运行 priority/hash 降序显式映射、会话冻结。图集旧档按覆盖来源重新解析选择意图。文本/人物整体在本体,存档保守要求准备时全部剧情包;Gallery 补丁延期。见 docs/manual/content-packaging.md、features/F-20261028-01-content-packaging/summary.md。 + +- [可选参数与二进制 API](lessons/L-20261028-04-optional-parameter-binary-api.md):新增可选参数仍改变 CLR 签名;公开契约保留旧签名转发,并用旧 consumer 装载当前 Core 验证。 + +- [内部快照内容身份](lessons/L-20261028-03-internal-snapshot-content-identity.md):新增存档身份须覆盖调试 replay、缓存和精简工厂;用非空构建身份验证,不放宽外部恢复门禁。 + +- [PAK 索引与 payload 验证](lessons/L-20261028-02-pak-index-payload-verification.md):MetadataIndex 不校验普通 payload;独立覆盖包准备需逐项验证,当前文件 hash 排序不能代替已知校验和。 + +- [原生发布载荷](lessons/L-20261028-01-native-publish-payload.md):RID/托管符号开关不能保证 native NuGet 内容自动裁掉多余架构与 PDB;在 manifest 前过滤最终应用载荷,并验收所需 x64 native 文件仍在。 + +- 本地 CI 入口为 `scripts/ci/Invoke-CI.ps1`(默认 All,可 Coverage),Actions 调用同一脚本;SDK 精确固定为 global.json 版本。`scripts/release/Release.ps1` 生成并验收 5 个聚合 NuGet 与 Windows x64 R2R 的 Editor、纯/整合 Sample、Contracts 四 ZIP;包归属见 packaging/packages.json,操作见 docs/manual/release-packaging.md。桌面 Sample AOT 尚未通过严格链接,不能从 Headless 自检推导支持;F-20261027-04 的 summary 保留未完成项。 + +- [Solution fixture 配置](lessons/L-20261027-11-solution-fixture-configuration.md):solution 外引用工程可能回到 Debug,测试却读取 Release;契约 fixture 已加入 GalNet.slnx,验证构建日志和实际输出配置。 +- [源生成 init 默认值](lessons/L-20261027-12-generated-init-defaults.md):音轨/Bus 精简 JSON 会丢失 init 属性初始化值;显式 JsonConstructor 可选参数保留容量/增益默认值,不放宽校验或改成可变 DTO。 + +- [开放 shader 参数](lessons/L-20261027-10-open-shader-parameters.md):已知 shader 的额外参数是合法、不解释的 JSON,保留且不 warning;声明字段仍校验,未知 shader 与封闭契约仍诊断。 + +- [属性逻辑路径与 wire 边界](lessons/L-20261027-06-property-wire-boundaries.md):JSON Pointer 不能穿过 JSON 字符串;引用识别、比较、复制与回写须同时迁移类型化路径。JsonArray 递归修改返回原节点时不能再次挂载该实例。 + +- [数值模板与作者草稿](lessons/L-20261027-05-numeric-template-drafts.md):NumericUpDown 的模板/Value 初始化不能成为作者输入;借用主题时由字段上下文拥有原始文字与提交,TextBox.PropertyChanged 同步捕获,验证挂载无草稿及输入后立即 Enter。 + +- 2026-10-27 Group Problems 按作者历史/相关依赖自动检查内存工作版,代次阻止旧结果发布,合法待提交不计条目错误。Editor numeric 默认保留文字的上下框,显式 numericupdown/numerictext 与 slider+精确输入;long/ulong/decimal 保留文本。前导小数点仅在类型化数字编辑入口规范化,JSON 源码仍严格。规范见 docs/spec/editor-groups.md、control.md;证据见 features/F-20261023-01-editor-uiux/follow-up-group-diagnostics-numeric-20261027.md。 + +- 2026-10-27 音频创作:类型 ComboBox/目录参数表单,apply 隐藏自动身份,stop/update 选择剧情位置之前的直接挂载实例;显式 all 与参数 patch 更新已实现。复用 Scene 作者语义、Graph/前缀分析和项目查询,AudioEffect 域按 scope/target/instance 限定,AudioEffects/AudioComplete/AudioRevision 独立于视觉候选。见 docs/spec/editor-scene-targets.md、docs/manual/audio-mixing.md、features/F-20261027-01-audio-mixer/authoring-summary.md;既有 Timeline Flyout 测试保留。 + +- [构建完成后再启动 no-build 测试](lessons/L-20261027-04-build-before-no-build-tests.md):session_id 表示仍在运行;同一输出根的构建/测试重叠会锁定 DLL 并可能验证旧产物,必须确认 build exit_code=0 后再启动测试。 + +- [Timeline 停止与事件收尾](lessons/L-20261027-03-timeline-stop-event-cleanup.md):完成轨道不等于执行末帧事件;停止需等待完整收尾,循环初始化/迭代/结束清理分别处理,初始化期间的停止也必须生效。 + +- 2026-10-27 音频 PCM:实例拥有 gain/淡入淡出/结束处理,Track 管理顺序/随机和循环队列,Bus 实际汇总后执行共享效果。Media.Audio 为跨平台混音/DSP,Media.Miniaudio 为 Windows x64 设备/解码;LibVLC 仅保留视频/Gallery。默认项目 section v2(audioBuses/busId),音频状态 tracks/seffects v2、channels v1;SEffect scope/targetId 与真实滤波/混响见 docs/manual/audio-mixing.md。实现和验收限制见 features/F-20261027-01-audio-mixer/summary.md。 + +- [分数延迟环绕边界](lessons/L-20261027-02-fractional-audio-delay-wrap.md):float 负分数位置加长度后可能舍入到长度本身;使用 double 并复核上边界,默认参数与非默认参数均验证。 + +- 2026-10-27 默认视觉库:模板部署为项目 shader 资产,defaultVisualPrograms 仅用于创作填充;预设保存显式 program/blurProgram GUID 并编译为 timeline.play。CPU/GPU 层链注入当前 layerZ,texture 参数租约归效果实例。project visuals install 显式补入且不覆盖已有绑定/占用路径。见 docs/manual/default-visuals.md 和 features/F-20261026-03-default-visual-library;[采样像素与视觉 QA](lessons/L-20261027-01-shader-sampling-visual-qa.md)。 + +- 2026-10-26 Sample 扩展边界:默认会话通过 IAvaloniaPlayerSettings/IPlayerStartup 消费项目偏好;默认页面 VM 可扩展、DI 使用 TryAdd。AttachPlayer/AttachRun 接入项目借用绑定并优先异步释放。外部剧情变量走 Engine.SetVariablesAsync/UpdateVariablesAsync,同步提交显式 scope 值并合入稳定快照;业务模块自行定义复杂状态恢复。AvaloniaPlayerLocalization 归 Avalonia.GameHost,中立 JSON 查询归 GameHost,Sample 不再共享 GameLocalizationProvider 源文件。见 features/F-20261026-02-sample-extensibility 与 docs/manual/customize-avalonia-sample.md。 + +- [Ready 与宿主活动状态](lessons/L-20261026-06-ready-is-not-session-end.md):Ready 是恢复/Seek 的中间准备状态,默认宿主保留已有 IsPlaying,不应触发返回标题。 + +- [原生 Sample 窗口与显式用例验证](lessons/L-20261026-05-native-sample-window-validation.md):Process.MainWindowHandle 可能选择无标题 native helper;仅对自有 PID 的明确 Sample 窗口发 Close,并核对显式测试实际执行数。 + +- [配置 schema 与 JSON 字段身份](lessons/L-20261026-04-configuration-json-casing.md):精确 ID 校验后须同样按大小写精确解码 DTO,避免未知大小写字段覆盖已验证值;原始未知字段继续保留。 + +- [查询事实与写入上下文](lessons/L-20261026-03-query-result-and-write-context.md):候选结果版本不能代替写入方身份/基线版本;异步准备按局部作者依赖与资源代次续租,相关变化再撤销不能复活旧选择。 + +- [作者代次与视图生命周期](lessons/L-20261026-02-author-incarnation-view-lifetime.md):作者删除/换类型使旧绑定失效;页面关闭只释放表单,不推进作者 incarnation,项目草稿须跨页面继续共享并可保存。 + +- [基线对照恢复与增量时间戳](lessons/L-20261026-01-baseline-restore-timestamps.md):恢复源码备份可能保留早于基线 DLL 的时间戳,候选验证需独立输出、Rebuild 或显式失效增量构建,并核对源字节。 + +- [空本地化引用边界一致性](lessons/L-20261025-10-empty-localization-references.md):作者索引、启动加载和运行解析都将空引用按空文本处理;空对象/数组不触发 opaque,非空未知结构仍保留诊断。 + +- [资源结构与缓冲重基](lessons/L-20261025-01-variant-buffer-structural-rebase.md):ManagedContent 的写盘内容来自项目文档 Data;移动时内部 folder 要分别重基工作版和保存版,Undo/Redo 保留之后保存的其他配置。 + - [必填字符串与 schema 默认值](lessons/L-20261024-01-required-string-defaults.md):Required 字段有默认值仍会被 Normalize 补齐;要求显式提供且空串合法时不声明 schema 默认,并显式 AllowNull=false。DTO 构造函数赋值不导出为 schema 默认。 - [契约与 Git 构建元数据](lessons/L-20261023-02-contract-git-metadata.md):SDK 的 InformationalVersion 可包含 commit;严格 DLL 哈希需要稳定非功能元数据,并用不同 SourceRevisionId 重建比较验证。 @@ -38,7 +112,7 @@ ## 项目事实(已从仓库文档确认) -- 宿主分层已落地:GameHost 提供 GameSession/IGameRun/GameRun、PlayerSession、PlayerPreferences/PlayerStartup;Avalonia.GameHost 提供默认页面会话、运行绑定、展示组合、资源预加载和设置/输入/窗口适配。Sample 仅保留项目配置、启动适配、本地化和调试。中立应用接口在 Presentation.Abstractions.Application,共享音频后端在 Media.LibVlc;项目删除与 ProjectTools.Windows 已移除。见 docs/spec/architecture.md。 +- 宿主分层已落地:GameHost 提供 GameSession/IGameRun/GameRun、PlayerSession、PlayerPreferences/PlayerStartup;Avalonia.GameHost 提供默认页面会话、运行绑定、展示组合、资源预加载和设置/输入/窗口适配。Sample 仅保留项目配置、启动适配、本地化和调试。中立应用接口在 Presentation.Abstractions.Application,共享音频混音/DSP 在 Media.Audio,Windows 适配在 Media.Miniaudio;项目删除与 ProjectTools.Windows 已移除。见 docs/spec/architecture.md。 - 解决方案入口是 `GalNet.slnx`。 - `docs/spec/` 描述已经实现的稳定事实;`docs/manual/` 描述二次开发路径;`features/` 保留实现过程和历史。 @@ -46,17 +120,22 @@ - Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为完整内置运行入口;独立 GalNet.Contracts.Builtins 只依赖 Core,CreateAllModules 返回 14 个组件模块,包含 custom.invoke 的固定双字符串 schema。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/spec/runtime.md、docs/spec/entry-types.md 和 docs/manual/。 - 当前 GameSnapshot 版本为 4,不兼容旧版;持久数据在注册式 GameState 表中,快照为 StateId → version/JSON payload;读档先验证,再取消旧 scope 并替换整个状态根。Engine 不拥有执行 view,宿主先释放 Engine,再异步释放 view 与后端。 - Engine 提供终态 StopAsync 和 StateChanged,覆盖玩家推进、Choice 与阻塞实例自然完成;GameHost 仅协调应用会话,不复制 Engine 执行机制。AvaloniaGamePresenters 只组合端口,AvaloniaGamePresentation 管理平台资源。见 docs/spec/runtime.md 和 docs/spec/architecture.md。 -- Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前程序集布局见 docs/spec/architecture.md。GameContent.Settings 包含项目设置,项目/安装加载器与两个 Sample 已接通自定义音频轨道配置。 +- Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前程序集布局见 docs/spec/architecture.md。GameContent.Configuration 保存版本化 section 文档;默认作者模型位于可选 Contracts.Builtins,项目/安装加载器通过 GameContentLoadOptions 接收 schema 与文化代码投影。 - 旧的 `GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移并删除;当前共享 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`,页面架构见 `docs/spec/control.md`。 - 持久条目先在 `GameState.Update` 中修改唯一数据记录,再通知展示层;模块通过 PrimitiveRegistry.State/Restore/ValidateState 声明状态、恢复依赖和校验,宿主使用展示抽象 StateRestoreSession 统一恢复、等待和逆序清理。旧 SceneState/Active* 镜像与 Builtins 中央 replay 已删除。 - 视觉扩展应通过展示抽象和渲染端口接入;不要把平台类型或渲染资源泄漏进 Core/Runtime。 - 新增 feature 的长期实现意图记录在其自身 feature 目录;只有已验证的稳定事实才合并到 docs/spec/。 - 玩家变量桥接实现为 Runtime.Variables.PlayerVariableService,存储通过 IPlayerVariableStore 注入;Variable.Clone 保留 Uid,VariableValue 不可变,可共享。Runtime loader/SaveManager 不执行文件 I/O。见 docs/spec/architecture.md。 -- Runtime.I18n.GameTextResolver 提供 culture-first、父文化与默认文化回退,只依赖宿主查询委托;DynamicLocalization/JSON 适配留在 Sample 的 GameLocalizationProvider。见 docs/spec/i18n.md。 +- Runtime.I18n.GameTextResolver 提供 culture-first、父文化与默认文化回退,只依赖宿主查询委托;中立 JSON 文本源归 GameHost.Content,DynamicLocalization 适配归 Avalonia.GameHost.AvaloniaPlayerLocalization。见 docs/spec/i18n.md。 - Assets 的 AssetFileMaterializer 经 IAssetManager acquire 字节实体化媒体;Core 的 AssetFileLease 持有文件租约。Sample 将同一实例供音频/视频/Gallery 使用,最后租约释放才删除文件。见 docs/spec/assets.md。 - 已读进度 MarkRead 只写 Runtime ReadProgressIndex;文件实现默认 2 秒批量提交,失败保留 dirty revision 并重试。Engine StopAsync、Sample 存档和进度对象销毁均等待 flush,失败向调用者传播;后台失败可读 LastPersistenceError。见 docs/spec/runtime.md。 +- 核心重构当前边界:Runtime 仅引用 Core;场景/音频/Gallery/差分图片契约与纯算法归 Contracts.Builtins,音频执行与按 GameState 根隔离的场景管理归 Primitives.Builtins。Core 的 keySpace 身份索引按状态注册选择,不认识场景类型。GameContent/加载配置与 Gallery 玩家组合归 GameHost。见 docs/spec/architecture.md。 +- 项目模型与运行输入:ProjectConfiguration 提供版本化 section/普通 DTO,Editor 通用表单、命令、历史、保存、导出和安装使用项目 schema,未知 JSON 保留。宿主通过 SelectProjectOptions 显式投影,Engine 只消费 PlaybackOptions,音频只消费 AudioConfiguration/AudioMixOptions,文本直接接收速度。变量声明只保存在作者 Graph。见 docs/manual/new-presentation-layer.md。 +- AudioConfiguration/AudioMixOptions 位于 Contracts.Builtins.Audio,允许任意 Bus/Track 及空/未路由 Track;混音为 master/bus/track,快进保留由显式 PreserveDuringCatchUp 控制。Sample/模板显式选择三 Bus 示例,默认模块不注入固定轨道。 +- EntryDefinition.DynamicParameters 是唯一参数源,Parameters/Defaults/Options 副本及旧 ABI 适配已删除;具体编辑器投影位于 Editor.Abstraction.Schemas,Effect/SEffect 基础约束共用 ValueValidation,领域 stage/scope 继续分别拥有。 + ## 文档分层 - `features//`:一次实现的需求、设计、阶段计划、审核和人类可读总结。 @@ -267,3 +346,40 @@ - [外部调试子进程监控生命周期](lessons/L-20261024-03-external-debug-child-monitor-lifetime.md):断开调试不能等待未终止的外部进程退出;Editor 存活时仍需排空重定向输出并隔离旧会话事件。candidate,待运行复现。 - [异步构建的同步前缀](lessons/L-20261024-04-sync-build-work-ui-thread.md):Task API 首个 await 前的验证、扫描与编译仍会阻塞调用线程;先在 UI 线程捕获安全快照,再后台运行纯构建工作。candidate,待大项目运行复现。 - [动画帧颜色与主题](lessons/L-20261024-05-animated-brush-measurement.md):中间帧比较须纳入 Brush.Opacity,并显式使用实际 ThemeVariant;只看 Color 或默认资源会误报跳色。 + +- [Benchmark 条件引用与还原图](lessons/L-20261025-02-benchmark-restore-graph.md):Benchmark 使用独立 bin/benchmark、obj/benchmark 和锁文件;切换条件依赖后不能跳过 restore,以实际运行验证 deps。 + +- [Avalonia benchmark 退出码](lessons/L-20261025-03-avalonia-benchmark-exitcode.md):窗口生命周期可覆盖提前设置的 Environment.ExitCode;宿主返回后的 finally 重申失败,脚本同时验证退出码与 Ready。 +- CPU 启动事件:Benchmark=true 启用独立 GalNet.Diagnostics 与方法内 #if BENCHMARK 埋点,普通宿主无诊断依赖;scripts/benchmark-startup.ps1 自动采集 Editor/Sample,契约和用法见 docs/spec/cpu-benchmark.md、docs/manual/cpu-benchmark.md。 + +- [角色姓名文本与身份](lessons/L-20261025-04-character-name-key-identity.md):DisplayNameKey 允许共享,角色草稿按 ID 与姓名文本组合区分;仅始终唯一的姓名文本可辅助跟随改名,提交仍检查原基线。 +- 2026-10-25 Editor 面板:变量以项目级 VariablePanelState 保存作用域选择与 UID 草稿,角色用 CharacterPanelState 隔离共享姓名身份;视图和 transient VM 正常释放,Save All 仍只保存两者已应用声明。本地化筛选保留单元格身份,冻结上下文保持单套虚拟化行;见 features/F-20261023-01-editor-uiux/phase-ui-20261025.md。 + +- [启动固定快照交错对照](lessons/L-20261025-05-paired-startup-baselines.md):机器缓存/负载可使同一 exe 跨轮大幅漂移;固定 SHA256、AB/BA 配对和同一 ready 口径,不用单次 smoke 宣称收益。 +- Editor 启动在 MainWindow 构造完成时准备欢迎页;Loaded 不再重置导航。20 对交错实验确认小幅收益,主题与 Acrylic 探测均回退;见 features/F-20261025-01-cpu-startup-benchmark/experiments.md。 + +- [ReadyToRun 与契约身份](lessons/L-20261025-06-r2r-contract-identity.md):发布转换必须排除严格契约闭包并验证原始字节。Editor/Sample 的 WindowsReadyToRun 配置隔离 bin/obj/lock;仅发布程序获得预编译收益,见 docs/manual/ready-to-run.md。 +- 启动调用栈:scripts/profile-startup.ps1 显式打开 Benchmark 的 GalNet-CPU EventPipe 镜像,scripts/analyze-startup-trace.cs 对齐 JIT/采样;采样只做诊断,收益仍用无采样固定快照交错比较。 + +- [TraceEvent 事件保留](lessons/L-20261025-07-traceevent-retained-events.md):迭代器复用事件对象;立即提取字段,或 Clone 后再保留。原生启动诊断使用 scripts/profile-startup-native.ps1 和 analyze-startup-native.cs,拒绝已有 WPR 会话和丢事件 trace,见 docs/manual/cpu-benchmark.md。 + +- Editor 正常 Main 在 Avalonia 初始化前启动两个 Task.Run:设置读取与本地化数据准备;完成后交给 UI/DI,文化/主题/控件仍在 UI 线程,设计器保留同步回退。20 对复核改善约 4.1%;WGL 探测未达收益门槛,默认 ANGLE 保持,见 features/F-20261025-01-cpu-startup-benchmark/parallel-native.md。 + +- 2026-10-25 本地化资源表实测修订:正文/摘要左上对齐;输入本地保留,失焦/Ctrl+Enter/Save All 提交,不逐字刷新项目。表头与正文明确靠左,隐藏语言使表格窄于视口仍对齐。语言移除保留翻译文件且可撤销,列宽与可见性保留在项目会话;见 features/F-20261023-01-editor-uiux/follow-up-localization-20261025.md。 + +- [变窄表格的表头左边界](lessons/L-20261025-08-localization-header-alignment.md):显式宽度的表头与正文都指定 Left;真实布局覆盖内容从宽于视口变成窄于视口。 + +- [同步捕获待提交输入](lessons/L-20261025-09-textchanging-local-input.md):Avalonia TextChanged 在渲染后异步触发;TextChanging 同步更新本地输入,作者提交仍只在失焦/明确保存边界进行。 + +- [静态引用不完整警告](lessons/L-20261025-11-static-reference-warning.md):References 正常带 references.partial warning;消费方按 error/实际引用拒绝,并保留 ReferencesComplete=false,不能用诊断总数判失败。 +- 外部项目创作:CLI project.apply 使用期望哈希和原生文件清单,同事务发布 Graph/Group/资源/本地化;产品 Skill 在 skills/galnet-project-authoring,随 CLI 分发。Editor 通用热重载采纳干净项目,在同一 scope 推进所有基线并清除旧历史;本地草稿冲突保留,Contracts DLL 仍需重开。见 features/F-20261025-02-external-project-authoring/summary.md 与 docs/manual/external-project-authoring.md。 + +- 2026-10-26 引擎核心重构已完整验收:P0–P6、R1–R8 verified,1789 项普通与 10 项原生测试通过;完整构建/locked restore/格式通过。当前架构事实见前述及 docs/spec,分阶段提交与原始失败证据见 features/F-20261026-01-engine-core-simplification/summary.md。性能专项按用户要求延期。 + +- [原生音频 UTF-8 ABI](lessons/L-20261027-06-native-audio-utf8-abi.md):Windows miniaudio 窄字符 fopen 使用 ANSI;统一 UTF-8 ABI 后内部转宽字符,托管/原生同步升级。跨平台构建与 AOT 限制见 docs/manual/cross-platform-backends.md。 + +- [完整 MSVC 工具集选择](lessons/L-20261027-07-complete-msvc-toolsets.md):按目标架构检查编译器、头文件和 CRT,再显式选择并存工具集;Windows ARM64 原生库已用 14.44 交叉编译并核对 PE/ABI,x64 保持 14.51。 +- [NativeAOT 与编译工具隔离](lessons/L-20261027-08-aot-build-tool-isolation.md):标准 PublishAot 不能污染 netstandard 源生成器,restore 图和开发锁需隔离;复用 Headless Sample 自检,见 docs/manual/native-aot.md。 +- [源生成 extension data](lessons/L-20261027-09-sourcegen-extension-data.md):GalleryAnnotation 的 init 字典触发构造绑定限制,改用 set 并验证未知字段往返。 + +- 2026-10-29 默认播放器对白历史/重播/已读颜色:历史正文只在实际保存时进入 GameSnapshot.Attachments,host.dialogue.history 状态只保留计数;同 Group、同 rawId 的相邻 audio.play 与文本按本次执行关联,重播使用独立后端 voice,任何有效继续均停止。阅读前导不能成为半句稳定快照,见 [阅读前导快照边界](lessons/L-20261029-09-reading-preamble-checkpoints.md)、docs/spec/control.md、runtime.md 和 features/F-20261029-03-dialogue-history。 diff --git a/.agents/lessons/L-20260922-01-dotnet-build-sandbox.md b/.agents/lessons/L-20260922-01-dotnet-build-sandbox.md index 7dabc98b..50834f2f 100644 --- a/.agents/lessons/L-20260922-01-dotnet-build-sandbox.md +++ b/.agents/lessons/L-20260922-01-dotnet-build-sandbox.md @@ -31,3 +31,7 @@ Codex 受限 workspace 中的 GalNet .NET/Avalonia 构建与测试。 - `features/F-20260922-01-entry-instance-runtime/phase-plan.md` - 成功验证命令:`dotnet build test/GeneralTest/GeneralTest.csproj --no-restore -m:1 -v:minimal -p:UseSharedCompilation=false` + +## 2026-10-26 补充:还原也应限制 MSBuild 并发 + +F-20261026-01 的 R4a 隔离还原使用默认 dotnet restore 调度时没有输出诊断即退出 1,并遗留 380 个同一父进程启动的 MSBuild /nodemode:1 /nodeReuse:true worker。具体退出根因尚未确认,不把它归因于业务代码。通过进程父 ID、命令行和启动时间确认归属后,只清理本次遗留 worker;保留用户 Editor 与其他进程。相同 locked/artifacts 参数加 --disable-parallel -m:1 /nr:false 后还原成功。还原与构建均优先使用串行、不复用节点的验证参数;命令返回 running session 时须等待实际结束,不能把一次等待超时视为结束后启动下一构建。 diff --git a/.agents/lessons/L-20261014-05-headless-session-publication-race.md b/.agents/lessons/L-20261014-05-headless-session-publication-race.md index a6ba0672..f0f2f1e0 100644 --- a/.agents/lessons/L-20261014-05-headless-session-publication-race.md +++ b/.agents/lessons/L-20261014-05-headless-session-publication-race.md @@ -8,3 +8,9 @@ 两次 Presentation 全回归各出现一次 DisposeAsync NullReferenceException,分别发生在不同测试,产品断言均通过。Avalonia 12.0.5 的 HeadlessUnitTestSession.StartNew 在 Task.Run 回调里用闭包 task 构造会话,而外部 task 赋值尚可能未完成;DisposeAsync 等待此引用时会空引用。源码:https://github.com/AvaloniaUI/Avalonia/blob/12.0.5/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs。 Presentation 测试改用官方 GetOrStartForAssembly 配合 AvaloniaTestApplication 特性,由框架缓存会话,测试不逐项释放缓存 worker。默认 PerTest 隔离仍使每次 Dispatch 重新建立和释放应用 scope。测试必须自己关闭窗口并释放产品资源;不能捕获并吞掉任意产品异常或用成功重试掩盖断言失败。此调整避免反复创建/释放会话,不修改第三方源码,也不声称修复其内部启动竞态。 + +2026-10-26 引擎重构 R4a:Editor 隔离全回归中的 TimelineKeyDetailsBatchEditInterpolationTangentsUnitsAndPresetsThroughMountedControls 在 HeadlessUnitTestSession.DisposeAsync 发生一次空引用(调用点为测试 await using 收尾)。定向 1 项与完整 Editor 323 项重跑通过,未修改 Timeline 产品代码;原日志 r4a-editor-tests.log 与重跑日志 r4a-timeline-recheck.log/r4a-editor-recheck.log 保留。该现象与上述已知竞态的失败位置一致,不把一次成功重跑表述为框架问题已修复。 + +2026-10-26 R6/R8:Editor 全回归再次在 CharacterNestedListFieldUsesSemanticEditorAndRenameUpdatesStoredWirePath 的 DisposeAsync 复现相同空引用,319 项通过;该用例独立复核通过。R8 将 Editor 与剩余 Integration 的 StartNew/逐项 Dispose 改为官方 GetOrStartForAssembly,复用各自既有 application builder,保留每项 Dispatch、窗口关闭与产品资源释放。不捕获该空引用、不删除功能断言;完整复核结果见 feature R8。 + +R8 最终完整复核:Editor 320、Integration 128 项全部通过;缓存 worker 的改动保留了普通用例的完整执行,没有跳过或捕获框架失败。 diff --git a/.agents/lessons/L-20261022-02-selector-preparation-generation.md b/.agents/lessons/L-20261022-02-selector-preparation-generation.md index e3b85142..f70bded8 100644 --- a/.agents/lessons/L-20261022-02-selector-preparation-generation.md +++ b/.agents/lessons/L-20261022-02-selector-preparation-generation.md @@ -14,3 +14,8 @@ 适用:异步准备资源后再呈现候选的字段、Timeline 或类似选择 lease。 禁止:无条件接受等待后的当前快照;只在预热后验证选择;仅因预期的后台准备推进结果版本而永久禁用本次选择。 + + +GC4 后续(2026-10-26):托管选择器现在按查询局部作者依赖版本和资源代次检查准备请求,允许无关作者修改保留请求;写入方上下文另有版本,详见 [查询事实与写入上下文](L-20261026-03-query-result-and-write-context.md)。原始整作者快照身份是当时的保守实现,不是永久要求全局失效。 + +2026-10-28 远端 CI 跟进:首次远端运行 40559879643 在上述冷图集用例的初始 Select 失败。项目/条目创作会自动 QueuePreparation,测试若在插入条目之后才设置资源 Gate,先前启动的读取仍可在查询快照与选择之间完成。测试应在创建项目/服务之前安装 Gate,并断言显式释放前 PrepareAsync 未完成,不能用机器速度推断“冷启动”。 diff --git a/.agents/lessons/L-20261024-01-required-string-defaults.md b/.agents/lessons/L-20261024-01-required-string-defaults.md index 6aa85c7d..667cb9dc 100644 --- a/.agents/lessons/L-20261024-01-required-string-defaults.md +++ b/.agents/lessons/L-20261024-01-required-string-defaults.md @@ -12,3 +12,5 @@ 证据:CustomActionTests.Contract_rejects_invalid_arguments_before_handler_creation 初轮缺 argument 的用例失败,调整 DTO 声明后通过;显式 null 也有拒绝回归。编译入口额外断言两个字段 IsRequired、AllowNull=false、DefaultValue=null。 适用范围:可接受空串但不接受缺省/null 的 DTO 字段。不要把 Required=true 等同于“即使有默认值也必须显式提供”,也不要为了局部字段语义修改全局 Normalize 的默认值规则。关联:docs/spec/entry-types.md、docs/manual/custom-actions.md。 + +复现补充:F-20261026-01 引擎重构 R3 自定义音频中,将 TrackId 从固定 bgm/vocal 改为 Required 时,保留空串属性初始化值同样导致省略参数仍通过。统一改为构造函数初始化,并对全部普通音频/语音 schema 断言 DefaultValue=null、缺省/null 拒绝;不要仅改 Required 标记。 diff --git a/.agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md b/.agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md new file mode 100644 index 00000000..ab861fd3 --- /dev/null +++ b/.agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md @@ -0,0 +1,15 @@ +# 资源结构重写必须覆盖已跟踪缓冲 + +状态:confirmed +日期:2026-10-25 +来源:资源 ID、选择器缓存与文件移动同步修复 + +现象:移动 `.variants` 或其图片目录时,文件事务已计算新 folder,但打开过的描述文件由 ProjectContentSession 的 ManagedContent 写出;仅修改事务备份不能改变实际写出的缓冲内容。 + +根因:ProjectFileOperation 的非托管 Copies 会跳过 ManagedContent;ProjectContentSession 从保存版 EditorFileDocument.Data 写出文件。原 FileChange.Apply 只移动 Path,没有对工作版和保存版的 Data 分别重基。 + +正确做法:在结构变更的 Apply 内根据当前文档位置和当前 folder 重算路径,分别转换工作内容与最近保存内容;验证也覆盖两份转换后的文档。Undo/Redo 从当时的配置重算,不能恢复首次捕获的整份 JSON,否则会回退之后保存的 default 或 dimensions。 + +验证:ResourceIdentityTests.cs 中 OpenVariantBufferRebasesFolderAndPreservesSavedAndUnsavedChanges 的描述移动/图片目录移动两例均通过,覆盖打开的 Inspector、未保存 default、磁盘保存基线、Save 后 Undo/Redo。关联规范:docs/spec/editor-files.md。 + +适用:文件结构操作会重写内部关系,且文件可进入项目文本缓冲的场景。禁止仅改路径/磁盘备份或以未打开文件的测试替代缓冲生命周期验证。 diff --git a/.agents/lessons/L-20261025-02-benchmark-restore-graph.md b/.agents/lessons/L-20261025-02-benchmark-restore-graph.md new file mode 100644 index 00000000..487fd623 --- /dev/null +++ b/.agents/lessons/L-20261025-02-benchmark-restore-graph.md @@ -0,0 +1,9 @@ +# 条件引用必须匹配 restore 图 + +状态:candidate +来源:features/F-20261025-01-cpu-startup-benchmark + +现象:Benchmark=true 的 Editor 编译通过,但启动在 Main 抛 FileNotFoundException: GalNet.Diagnostics;输出目录缺 DLL,deps/assets 也不含它。 +已确认的直接原因:编译器看见了条件 ProjectReference,运行时依赖清单却来自不含该引用的还原结果。工作区存在普通模式构建,无法仅凭日志断言哪次 restore 覆盖了 assets。 +正确做法:切换影响依赖图的属性时允许 restore,不能用 --no-restore 证明运行时成功。Benchmark 单独使用 bin/benchmark、obj/benchmark 和独立锁文件;固定路径 Content 也必须读取同模式产物。修改 BaseIntermediateOutputPath 时继续排除整个 obj/bin,避免把另一模式生成代码编入源码。 +不要只凭编译成功交付条件依赖构建;实际启动并验证 trace。不要归因于尚未证实的其他进程。 diff --git a/.agents/lessons/L-20261025-03-avalonia-benchmark-exitcode.md b/.agents/lessons/L-20261025-03-avalonia-benchmark-exitcode.md new file mode 100644 index 00000000..bd7ee7c7 --- /dev/null +++ b/.agents/lessons/L-20261025-03-avalonia-benchmark-exitcode.md @@ -0,0 +1,8 @@ +# Avalonia 生命周期结束后重申 benchmark 失败退出码 + +状态:confirmed +来源:features/F-20261025-01-cpu-startup-benchmark + +现象:Sample 初始化失败时 trace 正确记录 Startup.Failed,Complete 中也设置 Environment.ExitCode=1,但关闭窗口后子进程仍返回 0。脚本通过检查 Ready 缺失才拒绝了该运行。 +修正:StartupBenchmark 保留 Failed 状态,宿主在 StartWithClassicDesktopLifetime 返回后的 finally 中重申退出码。无效游戏内容的最终集成检查确认进程返回 1,失败 trace 保留,脚本不生成成功摘要。 +规则:不要依赖窗口仍运行时设置的 Environment.ExitCode;桌面宿主可能在生命周期结束时覆盖它。脚本同时验证退出码和协议完成标记,不能仅以其中之一判断成功。 diff --git a/.agents/lessons/L-20261025-04-character-name-key-identity.md b/.agents/lessons/L-20261025-04-character-name-key-identity.md new file mode 100644 index 00000000..5904cb73 --- /dev/null +++ b/.agents/lessons/L-20261025-04-character-name-key-identity.md @@ -0,0 +1,15 @@ +# 姓名文本 key 不能替代角色身份 + +状态:confirmed +日期:2026-10-25 +来源:features/F-20261023-01-editor-uiux/phase-ui-20261025.md + +现象:若角色草稿和选择只按 DisplayNameKey 缓存,两个共享姓名文本的角色会互相取得草稿;删除一个角色后还可能选中另一个角色。 + +根因:CharacterCatalog 只校验角色 ID 唯一,允许多个角色使用同一 DisplayNameKey。GUI 新建默认生成不同姓名文本 key,不代表所有合法作者数据都具有该唯一性。 + +验证:源码 CharacterCatalog 的唯一约束与 CharactersSharingANameTextNeverShareDraftsOrAcquireADeletedCharactersSelection 回归共同覆盖;角色 ID 改名跟随与旧基线拒绝另有连续性回归。 + +正确做法:按角色 ID 与姓名文本 key 组合区分草稿。仅在会话中始终未出现共享的姓名文本 key 上允许推断 ID 改名;一旦出现共享,不凭文本 key 合并身份。重基缓存键时保留原声明基线,使外部修改后的提交仍接受冲突检查。 + +适用:角色表、引用选择、搜索投影与项目级草稿。禁止将文本 key、显示名或行号当作普遍唯一的作者对象身份。文本共享与作者对象身份是不同契约。 diff --git a/.agents/lessons/L-20261025-05-paired-startup-baselines.md b/.agents/lessons/L-20261025-05-paired-startup-baselines.md new file mode 100644 index 00000000..7997f7d2 --- /dev/null +++ b/.agents/lessons/L-20261025-05-paired-startup-baselines.md @@ -0,0 +1,9 @@ +# 启动优化使用固定二进制交错对照 + +状态:confirmed +来源:features/F-20261025-01-cpu-startup-benchmark/experiments.md + +证据:同一个提前准备欢迎页的 exe 快照,在主题探测中作为基线 median 1907.65ms,在后续 Acrylic 探测中作为基线 median 1473.09ms;这段变化没有基线源码优化。前后顺序跑两批、或拿最后一次 smoke 与早期基线相比,会把缓存和机器负载漂移归功于代码。 +做法:固定可运行快照和程序集 SHA256,两版各预热,再 AB/BA 配对交错,使用同一个 ready 定义;一次改变一个因素,边缘结果补反向验证,提前规定保留门槛。编译和重负载测试不与测量并行。 +验证:提前准备欢迎页 20 对 median 1938.59→1834.01ms,14 对更快,行为检查通过后提交。主题与 Acrylic 探测未达门槛,逐字节恢复原 XAML。原始 trace 和 metadata 在 artifacts/benchmarks,结论保存在 feature 文档。 +禁止:跨轮拼接绝对时间当作优化趋势;只凭单次最快值提交;更改 ready 定义后宣称启动变快。 diff --git a/.agents/lessons/L-20261025-06-r2r-contract-identity.md b/.agents/lessons/L-20261025-06-r2r-contract-identity.md new file mode 100644 index 00000000..2cfb64f9 --- /dev/null +++ b/.agents/lessons/L-20261025-06-r2r-contract-identity.md @@ -0,0 +1,18 @@ +# ReadyToRun 与严格契约字节身份 + +状态:confirmed + +现象:ReadyToRun 发布会转换输入 IL 程序集;即使反射/schema 语义相同,发布 DLL 的 SHA256 也可能变化。 + +根因:本仓契约采用严格二进制身份,Core、Contracts.Builtins 及 GalNet.Project.Contracts 参与契约闭包;预编译是二进制重写,不能当作透明复制。 + +发现方式:F-20261025-01 的同源码 IL/R2R 发布对照,比较 SHA256 与 PE ManagedNativeHeaderDirectory;最终两个非 Benchmark 发布再次核对编译输入与发布输出,并实际加载 Editor 默认契约 45 项。 + +正确做法:PublishReadyToRun=true 时通过 PublishReadyToRunExclude 排除上述三个 DLL。必须验证实际部署字节与编译输入一致、native header 为空,并执行真实契约加载/宿主启动;其他程序集可正常 R2R。命名发布配置的 RID restore 使用隔离 bin/obj/lock,相关原则见 L-20261025-02。 + +适用范围:R2R 或其他会重写程序集的发布转换;新增契约闭包依赖需重新核对排除项。 + +禁止做法:为发布加速放宽严格契约哈希;仅检查编译通过或 schema 语义相同就宣称身份安全;把 Benchmark 与普通构建的契约哈希跨模式比较。 + +来源:features/F-20261025-01-cpu-startup-benchmark/runtime-profile.md。 +正式操作:docs/manual/ready-to-run.md。 diff --git a/.agents/lessons/L-20261025-07-traceevent-retained-events.md b/.agents/lessons/L-20261025-07-traceevent-retained-events.md new file mode 100644 index 00000000..c0eb3f38 --- /dev/null +++ b/.agents/lessons/L-20261025-07-traceevent-retained-events.md @@ -0,0 +1,17 @@ +# 保留 TraceEvent 迭代结果必须 Clone + +状态:confirmed + +现象:原生 ETL 内已收到 17 个 GalNet-CPU 标记,直接把 log.Events.Where(...).ToArray() 的事件保存后,却无法找到 Editor.RunAvalonia,或读取到后续事件的 payload。 + +根因:TraceEvent 迭代器复用事件对象。延后读取保留下来的引用,不能保证它仍对应当次事件。 + +发现方式:F-20261025-01 的 native-editor-02 无丢事件 trace;改成逐项 Clone 后,同一 ETL 成功对齐阶段并获取 383 个目标区间主线程 CPU 样本,重复分析及 Editor/Sample 实测通过。 + +正确做法:立即在 foreach 内消费并提取不可变字段;确实需要保存 TraceEvent(含 CallStack/EventIndex)供后续筛选时,先 Select(item => item.Clone())。当前 scripts/analyze-startup-native.cs 使用后一种方式。 + +适用范围:TraceEvent/TraceLog 迭代器事件的延后处理;scripts/analyze-startup-trace.cs 原有逐项消费方式不需要改成全量保留。 + +禁止做法:把 ToArray/ToList 当作事件快照;因延后读取失败就判定 provider 没发标记,或凭空补齐时间轴。 + +来源:features/F-20261025-01-cpu-startup-benchmark/parallel-native.md。 diff --git a/.agents/lessons/L-20261025-08-localization-header-alignment.md b/.agents/lessons/L-20261025-08-localization-header-alignment.md new file mode 100644 index 00000000..5b3d8e1f --- /dev/null +++ b/.agents/lessons/L-20261025-08-localization-header-alignment.md @@ -0,0 +1,12 @@ +# 横向表格变窄时必须明确共用左边界 + +状态:confirmed +来源:features/F-20261023-01-editor-uiux/follow-up-localization-20261025.md + +现象:本地化表格在所有语言显示时对齐,隐藏部分语言后表头与正文错位。真实挂载测试在隐藏中间语言后测得正文左边界 362 DIP、表头 483 DIP,相差 121 DIP。 + +根因:表头 Grid 指定 Width,但保持默认 HorizontalAlignment;内容宽度变得小于 ScrollViewer 视口时,表头居中,而 ListBox 显式靠左。宽表场景掩盖了差异,与单元格语言顺序无关。 + +修正:表头与正文都显式 HorizontalAlignment=Left,并使用同一组列宽。只验证语言数据或列数不能发现实际布局错位。 + +验证:I18nVisibleLanguageHeadersStayAlignedWhenTableBecomesNarrowerThanViewport 在修复前失败,修复后通过;覆盖横向滚动后隐藏中间列、仅剩源语言及恢复语言,直接比较真实表头/单元格的 X 与宽度。不要只测表格宽于视口的初始状态。 diff --git a/.agents/lessons/L-20261025-09-textchanging-local-input.md b/.agents/lessons/L-20261025-09-textchanging-local-input.md new file mode 100644 index 00000000..85735e0c --- /dev/null +++ b/.agents/lessons/L-20261025-09-textchanging-local-input.md @@ -0,0 +1,10 @@ +# 失焦提交仍需同步捕获本地输入 + +状态:confirmed +来源:features/F-20261023-01-editor-uiux/follow-up-localization-20261025.md + +项目安装的 Avalonia 12.0.5 XML 文档明确:TextBox.TextChanged 在新文本渲染后异步发生;TextChanging 在 Text 属性更新后、渲染前同步发生。用 TextChanged 作为本地待提交模型的唯一入口,可能让紧随文字变更的 Escape 或 Save All 看到旧模型。 + +本地化表格改为 TextChanging 只同步单元格本地文字,不调用作者写入;失焦/Ctrl+Enter/Save All 才提交。保留 PreeditText 组合输入保护,提交失败仍保留输入,模型回填时按相等值过滤防止重入。已经送入历史的异步提交与后来新输入分开处理,Escape 只取消后者。 + +验证入口:I18nMountedTableCommitsOnBlurProtectsCompositionAndRetainsViewState 在 TextBox.Text 赋值后不等待渲染直接 Save All,检查保存值与焦点;另覆盖组字、Esc 和失焦提交。该挂载测试已通过;最终完整回归结果见本轮实施记录。 diff --git a/.agents/lessons/L-20261025-10-empty-localization-references.md b/.agents/lessons/L-20261025-10-empty-localization-references.md new file mode 100644 index 00000000..3eebfa82 --- /dev/null +++ b/.agents/lessons/L-20261025-10-empty-localization-references.md @@ -0,0 +1,16 @@ +# 空本地化引用需要在作者索引、加载和解析边界保持一致 + +状态:confirmed +来源:2026-10-25 角色引用分析与空对话启动问题,用户明确要求空内容按空字符串处理;未创建独立 feature。 + +现象:作者索引已经忽略精确空字符串,但 GameLocalizationLoader.Require 把空 content 当成缺失 key,启动报 missing required localization key ''。同时 layer.show 的默认 transform={} 因字段声明为 JsonElement 而产生 field.opaque,阻止角色改 ID。 + +根因:作者引用覆盖分析与运行内容校验各自实现空值规则;没有引用的空结构也进入了无法遍历 schema 的兜底诊断。 + +正确做法:缺省、null、空串及纯空白引用不要求翻译;GameTextResolver 直接返回空字符串,不查询翻译来源。空 speaker 不查角色 catalog。作者索引跳过空对象/数组,未知字段和无 schema 类型只有存在非空值时才报告覆盖不完整;非空未知结构仍保留诊断。显式空译文是合法定义,不能当作缺译。 + +验证:LocalizationReferenceIndexTests 覆盖 layer.show 空 transform 与未知空字段;GameLocalizationLoadingTests 覆盖 dialogue.text、nvl.append 和 Choice 的空引用加载;GameTextResolverTests 验证空引用不调用来源;GameTextLocalizationTests 验证对话与 NVL 展示空文本。 + +适用范围:游戏本地化引用和引用分析中的空字段;不放宽角色声明的唯一身份、JSON 语法、必填字段或非空错误引用。禁止只在 UI 隐藏诊断、只修改索引却遗漏启动加载器,或把所有非空不透明字段都视为可安全重写。 + +关联文档:docs/spec/editor-game-localization.md、docs/spec/i18n.md。 diff --git a/.agents/lessons/L-20261025-11-static-reference-warning.md b/.agents/lessons/L-20261025-11-static-reference-warning.md new file mode 100644 index 00000000..959d84f4 --- /dev/null +++ b/.agents/lessons/L-20261025-11-static-reference-warning.md @@ -0,0 +1,15 @@ +# 静态引用扫描的正常警告不等于应用失败 + +状态:confirmed +日期:2026-10-25 +来源:F-20261025-02-external-project-authoring / P2 + +现象:批量删除资源并在同一批次移除 UI JSON 引用后,ProjectApplyTests.ResourceDeletionRequiresRemainingJsonReferencesToBeUpdatedInTheSameBatch 仍返回失败。 + +根因:ProjectQueryService.References 始终附带 references.partial warning,说明自定义 C# 和运行表达式未被完整分析。以 diagnostics.Count != 0 判定失败,会把无剩余静态候选、但明确不完整的有效扫描也拒绝。 + +正确做法:应用成败按 Severity=error 和实际匹配引用判断;保留 warning,并明确 ReferencesComplete=false,不把零静态候选写成全局零引用。当前 ProjectApplyService 返回合并诊断并使用错误级别判定。 + +验证:修正后的 Editor.Shared 全部 417 项及最终批次/热重载 19 项通过;同批清除 UI 引用后资源删除成功,留下引用时拒绝。 + +适用:消费 References 或其他允许 partial/Complete=false 的查询结果。不要直接用诊断总数代替错误数量,也不要吞掉不完整性说明。 diff --git a/.agents/lessons/L-20261026-01-baseline-restore-timestamps.md b/.agents/lessons/L-20261026-01-baseline-restore-timestamps.md new file mode 100644 index 00000000..32327407 --- /dev/null +++ b/.agents/lessons/L-20261026-01-baseline-restore-timestamps.md @@ -0,0 +1,15 @@ +# 基线对照后恢复源码必须使增量构建失效 + +状态:confirmed +日期:2026-10-26 +来源:F-20261015-02-editor-authoring-workspace / GC1 + +现象:隔离快照先成功编译新 AuthorChanges API,再替换旧服务源码重现一个基线失败。恢复备份的候选源码后,测试编译报告 UndoRedoHistory 没有 AuthorChanges;磁盘上的候选源码明明已有该成员。 + +根因:File.Copy 恢复了备份的旧时间戳,而基线 DLL 的生成时间更晚。MSBuild 增量检查复用了基线 DLL。源码字节恢复不等于构建图已经失效。 + +发现方式:gc1-baseline-character.trx 独立复现原有角色引用测试失败;随后 editor-final.log 出现 CS1061,核对恢复源码与 DLL 时序后确认不一致。对快照内候选文件显式 SetLastWriteTimeUtc 或执行 Rebuild 后才能继续验证。 + +正确做法:基线与候选优先使用独立源/输出目录。若复用快照进行对照,恢复候选后必须 Rebuild,或刷新所有被替换源码的时间戳并核对生成结果;验证最后核对主工作区与快照的源字节一致。不要修改主工作区源码来迎合旧 DLL。 + +适用:复制源码备份进行回归对照、测试旧实现、热切换隔离验证快照。不能把仅恢复文件或一次 no-build 成功当作当前候选证据。构建日志、首次失败和恢复方式留在阶段记录。 diff --git a/.agents/lessons/L-20261026-02-author-incarnation-view-lifetime.md b/.agents/lessons/L-20261026-02-author-incarnation-view-lifetime.md new file mode 100644 index 00000000..4b397e1d --- /dev/null +++ b/.agents/lessons/L-20261026-02-author-incarnation-view-lifetime.md @@ -0,0 +1,17 @@ +--- +status: confirmed +created: 2026-10-26 +feature: F-20261015-02-editor-authoring-workspace +--- + +# 作者 incarnation 不随表单释放推进 + +现象:GC3 为草稿增加显式 incarnation 后,完整 Editor 回归中 Common/Inspector 共享输入变回源值,关闭所有页面后的本地化草稿保存报源冲突。 + +根因:把 incarnation 推进放进 `InvalidateParameterFields()`;该方法同时被 Group 页面 Dispose 使用。释放表单租约不等于作者对象消失,原作者内容仍存在,但草稿读源被错误标成 obsolete。 + +正确做法:`InvalidateAuthorBindings()` 只在删除/移动出原 Group 或换类型时推进作者代次,再释放参数表单;页面关闭只释放参数表单。新表单重用同一作者代次的项目输入,旧控件仍因自身 lease 释放而失去提交权。删除和 Undo 后的新绑定使用新代次,不能恢复旧控件。 + +禁止用视图生命周期、表单重建次数或对象 RuntimeHelpers 哈希冒充作者身份。覆盖的回归必须同时包括删除/Undo 拒绝旧 lease,以及关闭页面/重开字段/所有视图关闭后的保存;只测第一类会漏掉这次问题。 + +发现及验证:GC3 初轮完整 Editor 313 通过、5 失败,其中两项为上述生命周期回归;拆分后定向 12 项通过。证据在 `artifacts/group-consistency-gc3/` 的 full 与 focused-repair 日志/TRX,来源见 feature 的 phase-group-consistency.md。 diff --git a/.agents/lessons/L-20261026-02-detached-run-cancellation.md b/.agents/lessons/L-20261026-02-detached-run-cancellation.md new file mode 100644 index 00000000..941c6523 --- /dev/null +++ b/.agents/lessons/L-20261026-02-detached-run-cancellation.md @@ -0,0 +1,20 @@ +# 旧运行取消回调异常不能截断恢复提交 + +状态:confirmed +来源 Feature:[引擎核心精简](../../features/F-20261026-01-engine-core-simplification/summary.md) + +## 现象与根因 + +CancellationTokenSource.Cancel 会先发布取消,再执行已注册回调;回调抛错时 Cancel 会抛 AggregateException。Engine 恢复若直接依次 ClearActive、Cancel、Restore,旧回调异常会在状态替换前截断执行,留下已清空活动实例但旧 scope 已取消的半份运行。 + +## 正确做法 + +先完整准备和验证候选,再让旧 scope 失效。对已经脱离执行的旧 scope,隔离并诊断取消回调异常,保证 source 释放和新状态提交继续进行。该规则不适用于保存/进度落盘失败或必要的新呈现恢复失败,这些仍需向调用方报告。 + +运行 scope 的变量/结束操作,应在捕获的旧状态同步边界内检查取消;恢复提交也经过旧状态边界,避免检查通过后跨代写入。 + +## 验证与适用范围 + +GameEngineLifecycleTests.ObsoleteCancellationCallbackFailureCannotLeaveRestorationHalfApplied 已复现并验证:首个 scope 的取消回调抛 IOException,合法恢复仍回到 Ready,随后能重新执行。适用于 Engine 读档、旧 Choice 清理和相同的运行替换边界。 + +不要把所有 OperationCanceledException 或任意执行异常一概吞掉,也不要把验证失败后的旧运行先销毁。 diff --git a/.agents/lessons/L-20261026-03-forced-restore-package-hash.md b/.agents/lessons/L-20261026-03-forced-restore-package-hash.md new file mode 100644 index 00000000..7ff5027b --- /dev/null +++ b/.agents/lessons/L-20261026-03-forced-restore-package-hash.md @@ -0,0 +1,22 @@ +# 强制重算锁图不能替代包哈希验证 + +状态:confirmed +来源 Feature:[引擎核心重构 R1](../../features/F-20261026-01-engine-core-simplification/phase-plan.md) +关联:[删除项目后检查锁图](L-20261010-03-lock-graph-after-project-removal.md) + +## 现象与证据 + +R1 移除 Runtime 对 Presentation.Abstractions 的引用后,按既有流程对受影响根执行 dotnet restore --force-evaluate。Browser 使用本地包源返回成功,但其 Microsoft.NET.Sdk.WebAssembly.Pack 10.0.12 的 contentHash 从仓库既有值改为本地包的另一值;第三方版本没有改变。 + +在本机 .NET SDK 10.0.401 下,即使同时指定 --locked-mode,--force-evaluate 仍重写了该哈希并返回成功。恢复仓库原哈希后,使用 --locked-mode --force(不含 --force-evaluate)再次从相同本地源还原,明确复现 NU1403。日志位于忽略产物 artifacts/engine-core-audit/r1-browser-restore.log、r1-browser-locked.log。 + +## 原因与正确做法 + +强制重算用于生成新锁图;它接受当前解析结果并可能重新生成包身份,不能证明这些字节符合旧锁文件。包来源/字节差异的起因尚未确认,不能直接将其归因为恶意包或损坏。 + +1. 有意变更项目依赖时,强制重算后审查整个锁文件差异。 +2. 本次仅移除项目依赖,第三方版本、哈希和目标框架键均应保持原值;撤回无关生成变化,而非把本地包哈希提交为修复。 +3. 生成与验证分开:再执行不带 force-evaluate 的 locked restore;需要失效还原缓存时使用 --force。 +4. 独立报告包验证或运行时包缺失,不能因为构建已成功或重算返回零就宣称发布验证通过。 + +适用范围:已有锁文件的项目引用调整和本地源/离线还原。不要手写项目依赖图、关闭哈希验证或改写包缓存来绕过错误;需要升级第三方包时另行核实来源和版本。 diff --git a/.agents/lessons/L-20261026-03-query-result-and-write-context.md b/.agents/lessons/L-20261026-03-query-result-and-write-context.md new file mode 100644 index 00000000..ac869c5b --- /dev/null +++ b/.agents/lessons/L-20261026-03-query-result-and-write-context.md @@ -0,0 +1,21 @@ +# 查询结果与写入上下文必须分别验证 + +- 状态:confirmed +- 日期:2026-10-26 +- 来源:F-20261015-02-editor-authoring-workspace,GC4 + +## 现象与根因 + +将场景选择 lease 从全项目 Revision 改为结果版本后,首轮 Editor 回归的六个转场用例发现:选择器写入当前条目的目标参数后,条目前方候选完全相同,旧选择仍能 Resolve。候选相等只证明读取事实相等,不能证明原写入位置和基线仍有效。相同 JSON 替换 DTO 也有同样的身份问题。 + +## 正确做法 + +分开维护查询事实版本与写入方上下文版本。前者比较目标、能力、数值、存在性和完整性;后者比较写入方作者对象身份、类型和参数基线。每个成功提交主动核对已借出的查询,相关变化后再 Undo 也不得复活旧版本。展示变化和无关作者编辑不能代替实际依赖变化。 + +后台资源准备另保留查询局部的作者依赖版本与资源代次:同一请求准备完成可换发结果,资源刷新、相关作者改变或项目关闭后的迟到结果不可续租。 + +## 验证与适用范围 + +初轮证据:artifacts/group-consistency-gc4/results/gc4-editor-first.trx;修复后完整 Editor 322 项通过,写入上下文补充 9 项、资源准备相关 22 项通过。新增测试覆盖相同 JSON 替换 DTO、相关改变再 Undo、无关后文编辑保留 Timeline 输入。 + +适用于依赖缓存的 Picker、Timeline、异步表单和其他先读后写的编辑器意图。禁止只用“候选相同”允许任意旧操作,也禁止用全局作者版本替代局部依赖来取消所有输入。 diff --git a/.agents/lessons/L-20261026-04-configuration-json-casing.md b/.agents/lessons/L-20261026-04-configuration-json-casing.md new file mode 100644 index 00000000..4e05afef --- /dev/null +++ b/.agents/lessons/L-20261026-04-configuration-json-casing.md @@ -0,0 +1,16 @@ +# schema 检查与 DTO 解码必须使用同一字段身份规则 + +状态:confirmed +来源:F-20261026-01 引擎核心重构 R4b + +现象:section 按字段 ID 精确校验 count,但 JsonSerializerDefaults.Web 默认不区分大小写。输入 {"count":4,"Count":0} 能先通过 count 的最小值校验,再在 DTO 解码时让 Count=0 覆盖已验证值;未知字段也因此变成已知字段的写入入口。 + +根因:schema 查询使用 Ordinal 精确匹配,DTO 解码却使用 Web 的 PropertyNameCaseInsensitive=true,两个阶段对字段身份的定义不一致。 + +正确做法:配置 DTO 的 JSON 解码显式使用 PropertyNameCaseInsensitive=false;原始未声明字段继续保留,不解释成另一个已知字段。领域成员的 descriptor JSON options 与 DTO JSON options 也采用相同规则。 + +验证:ProjectConfigurationTests.UnknownFieldCasingDoesNotBypassDeclaredFieldValidation 覆盖 count/Count 共存、类型化修改后已知值仍有效、未知字段原样保留;r4b-final-tests.log 的 Core 166 项通过。 + +适用范围:在按精确字段 ID 校验之后反序列化为 CLR DTO 的配置、命令或内容边界。不要通过大小写不敏感解码绕过 schema,也不要为修复字段身份问题直接删除不认识的作者数据。 + +关联:docs/spec/file-formats.md;features/F-20261026-01-engine-core-simplification/summary.md。 diff --git a/.agents/lessons/L-20261026-05-native-sample-window-validation.md b/.agents/lessons/L-20261026-05-native-sample-window-validation.md new file mode 100644 index 00000000..5ef48508 --- /dev/null +++ b/.agents/lessons/L-20261026-05-native-sample-window-validation.md @@ -0,0 +1,17 @@ +# 原生 Sample 验证必须定位实际窗口并检查显式用例数 + +状态:confirmed +来源:F-20261026-01-engine-core-simplification / R8 + +## 现象与证据 + +ExternalSampleDebugTests 的三个用例可运行、编辑、Seek/Step、停止和重启,却在 CloseMainWindow 后超时,且没有宿主 shutdown 日志。r8-native-diagnostic 记录 Process.MainWindowTitle 为空;EnumWindows 显示同一 PID 有两个无标题可见窗口和一个 GalNet Avalonia 标题窗口。向后者发送 WM_CLOSE 后,三个用例均正常清理和零码退出,清理日志约 100 ms。 + +## 正确做法 + +- Windows 原生集成测试按自己启动的 PID、可见性与明确 Sample 标题定位唯一主窗口,再发送关闭消息;其他平台沿标准关闭接口。辅助代码只放测试层,不能广播关闭或操作其他进程。 +- 关闭超时时共享读写读取活动日志,不能让 FileShare 异常覆盖原始失败。 +- NUnit 显式测试单独以对应方法/fixture 过滤,并核对 TRX 的实际执行数量。本轮混合普通和显式方法的 OR 过滤只运行了两个普通用例;退出码不能证明显式用例已执行。 +- Native 截图默认写 Pictures;沙箱拒绝该路径时,复验应申请执行这一个受控测试所需的权限,不能把截图缺失归因于渲染,也不为测试给产品增加全局路径后门。 + +证据:artifacts/engine-core-audit/r8-native-diagnostic.log、r8-results/ExternalSampleConnectsAtTitleStartsEditsSeeksStopsAndRejectsOldRuns.trx。测试辅助为 NativeSampleWindow。 diff --git a/.agents/lessons/L-20261026-06-ready-is-not-session-end.md b/.agents/lessons/L-20261026-06-ready-is-not-session-end.md new file mode 100644 index 00000000..ee3d35ff --- /dev/null +++ b/.agents/lessons/L-20261026-06-ready-is-not-session-end.md @@ -0,0 +1,14 @@ +# Engine Ready 不是宿主会话终止 + +状态:confirmed +来源:F-20261026-01-engine-core-simplification / R8 + +## 现象与根因 + +原生打包差分图片验证在 Seek 后出现返回标题页和 debug run 身份改变。Engine 恢复先发布 Ready、随后回放重新进入 Running;AvaloniaGameRun 把除 Running 外的所有状态映射为 IsPlaying=false,GameShell 据此排队返回标题。该中间状态不是停止,UI 队列可能在 Running 映射之前执行导航。 + +## 正确处理 + +默认宿主对 Ready 保持已有会话活动状态:首次准备仍为未开始,活动运行中的恢复/Seek 仍为活动。Running 与 Completed/Faulted/Stopped 再更新活动标志。不要删除 Engine 的 Ready 通知,也不要让通用内核拥有页面策略;不要用固定延时掩盖导航竞态。 + +修正后真实打包 Sample 的五个呈现检查点、不同图片哈希和正常关闭通过。证据:artifacts/engine-core-audit/r8-packaged-native-verified.log(修正前)与 r8-packaged-native-final.log/trx(修正后)。对应状态映射位于 AvaloniaGameRun,当前规则同步 docs/spec/runtime.md。 diff --git a/.agents/lessons/L-20261027-01-shader-sampling-visual-qa.md b/.agents/lessons/L-20261027-01-shader-sampling-visual-qa.md new file mode 100644 index 00000000..f5b1d842 --- /dev/null +++ b/.agents/lessons/L-20261027-01-shader-sampling-visual-qa.md @@ -0,0 +1,21 @@ +# Shader 采样需要像素与视觉双重验证 + +状态:confirmed +来源:features/F-20261026-03-default-visual-library + +## 现象与复现 + +DepthBlur 的稀疏 5×5 网格采样在教室背景预览中出现重复图像条纹。SpeedLines 的定向线宽改细后,在 128×128、density=64 时所有像素 alpha=0,尽管 shader 编译完全成功。 + +## 已验证的原因 + +固定网格间隔过大使边缘与纹理产生可见重复采样。定向线在该分辨率下的 lane 坐标采样点固定落在细线范围之外,导致整条线消失;只检查编译或单个高分辨率截图不能发现这个问题。 + +## 做法 + +- 在低分辨率上验证动态程序图层存在非零与零 alpha,并比较两个逻辑时间的像素差异。 +- 线宽过细时,按 density / 画布短边估计像素覆盖范围,为线边界提供抗锯齿宽度。 +- 大半径模糊避免稀疏规则网格;本例改为中心加 48 个黄金角圆盘采样,消除了网格重影,但不据此承诺任意半径的性能。 +- 生成真实项目素材的 CPU contact sheet 并查看;仍须区分 CPU 验证与真实 GPU 验收。 + +证据:DefaultVisualRenderingTests 的 128×128 动态像素断言,以及显式 WriteDefaultVisualPreview 产生的 artifacts/visual-library-preview.png。修正后相关 27 项效果测试与 257 项展示层普通测试通过。 diff --git a/.agents/lessons/L-20261027-02-fractional-audio-delay-wrap.md b/.agents/lessons/L-20261027-02-fractional-audio-delay-wrap.md new file mode 100644 index 00000000..548d8593 --- /dev/null +++ b/.agents/lessons/L-20261027-02-fractional-audio-delay-wrap.md @@ -0,0 +1,11 @@ +# 分数延迟的浮点环绕边界 + +状态:confirmed。来源:F-20261027-01-audio-mixer,PcmMixerTests 的混响顺序和稳态处理用例。 + +现象:部分 roomSize(包括默认 0.5)在混响延迟读取中出现 IndexOutOfRangeException,而另一组房间参数正常。 + +根因:float 计算得到接近 0 的负读位置,加缓冲长度后可能舍入成恰好等于缓冲长度;只检查负数不能保证数组索引落在区间内。 + +正确做法:分数位置用 double 计算,环绕后仍检查上边界;线性插值的第二个索引同样按长度环绕。不要在 native 音频回调外层吞异常后把持续静音误判为正常输出。 + +验证:真实混响默认参数、非默认参数、有序效果和连续处理/零分配测试均覆盖该路径。记录来自测试失败后修正,不把“只跑一个 impulse 参数”当成完整边界验证。 diff --git a/.agents/lessons/L-20261027-03-timeline-stop-event-cleanup.md b/.agents/lessons/L-20261027-03-timeline-stop-event-cleanup.md new file mode 100644 index 00000000..0e0573b5 --- /dev/null +++ b/.agents/lessons/L-20261027-03-timeline-stop-event-cleanup.md @@ -0,0 +1,20 @@ +# Timeline 停止必须同时完成属性轨道和事件收尾 + +状态:confirmed +来源:features/F-20261027-02-sustained-visual-presets + +## 现象与根因 + +animation.stop / CompleteImmediately 原先只通知 presenter 跳到末帧并移除播放状态。AvaloniaAnimationPresenter 的提前完成分支仅采样轨道,不调用 beforeFrame,因此末帧 effect.stop/layer.hide 没有送达展示层。集中线虽已透明,Effect 与载体图层仍可能存在。定时和持续预设的提前停止回归均覆盖了此路径。 + +## 正确做法 + +由中立 Timeline 实例在正常完成/主动完成后补发剩余非 burst 事件,并让停止 Entry 等待 PresentationCompletion。循环的初始化、迭代事件、播放结束清理需区分;不能把初始化和清理跟随每次循环重放。首帧初始化进行中收到停止请求时,必须在启动 presenter 前再次检查停止状态。 + +点击跳过和脚本停止可以交错。不能在 presenter 任务结束时提前移除播放身份,让随后到达的停止指令绕过仍在执行的 skip 清理;使用稳定的完成信号,等呈现退出和事件清理全部结束后一起释放身份。Stop_during_click_skip_waits_for_the_same_cleanup 在修复前得到 stopping.IsCompleted=true,修复后必须在 Effect 清理门释放前保持 false。 + +循环 presenter 单独返回 Completed 不是提交末值的充分证据;没有明确停止请求时不能改变恢复后的循环初始值。scope 取消、CatchUp 分离、被替换不能借用正常完成的清理路径。 + +## 验证 + +SustainedVisualPresetTests 验证提前停止的 Effect/图层清理、异步初始化交错、清理等待、恢复、循环与同标识重用;既有 PersistentPresentationReplayRestartsEffectsAndParticlesWithSavedAnimationValues 验证宿主直接结束呈现不篡改恢复初值。 diff --git a/.agents/lessons/L-20261027-04-build-before-no-build-tests.md b/.agents/lessons/L-20261027-04-build-before-no-build-tests.md new file mode 100644 index 00000000..8f88f5ba --- /dev/null +++ b/.agents/lessons/L-20261027-04-build-before-no-build-tests.md @@ -0,0 +1,17 @@ +# 同一输出根的构建和测试必须串行 + +状态:confirmed +日期:2026-10-27 +来源:F-20261027-01-audio-mixer / 音频效果创作 + +现象:隔离构建尚未结束时启动 --no-build 的 Editor/Editor.Shared 测试,SDK 在 Copy 阶段出现 MSB3026/MSB3027/MSB3021。被占用路径均为同一 artifacts/audio-isolated/bin 下的测试依赖 DLL;正常运行的 Editor 不是这次占用来源。 + +根因:write_stdin 返回 session_id 表示构建仍在运行,不是已成功完成。测试提前加载了该输出目录的程序集,Windows 阻止构建覆盖;同时测试可能读取旧程序集,结果不能作为新源码验证证据。 + +发现方式:artifacts/audio-authoring-overlap-build.log 记录了重复复制重试和 30 个锁定错误;两个测试进程退出后,同源码的解决方案构建在约 6 秒内以 0 错误通过。 + +正确做法:明确收到 build 的 exit_code=0 后,再复制同次构建的契约产物并启动 --no-build 测试。独立测试进程可以并行,同一输出根的构建和测试不能重叠。保存源码指纹并在验证结束复核;重叠产生的日志保留但不作为最终验收。 + +适用:Windows 输出目录、隔离 artifacts、多项目构建和任何需要复用已有 DLL 的验证。禁止把 yielded/session_id 结果当作编译完成,也不要通过停止用户正在运行的 Editor 来修复自己测试进程造成的锁定。 + +相关:[隔离契约产物](L-20261019-03-isolated-contract-artifacts.md)。 diff --git a/.agents/lessons/L-20261027-05-numeric-template-drafts.md b/.agents/lessons/L-20261027-05-numeric-template-drafts.md new file mode 100644 index 00000000..4432b7c4 --- /dev/null +++ b/.agents/lessons/L-20261027-05-numeric-template-drafts.md @@ -0,0 +1,15 @@ +# 数值控件的模板初始化不能成为作者输入 + +状态:confirmed +日期:2026-10-27 +来源:F-20261023-01-editor-uiux / follow-up-group-diagnostics-numeric-20261027 + +现象:把保留草稿的数字 TextBox 换成 NumericUpDown、只覆盖 OnTextChanged 和 OnLostFocus,虽然 `.22` 可写入,却在挂载表单时产生空草稿。结构化列表/DTO 操作后保存出现源冲突,项目设置已提交值被初始草稿覆盖,外部文件刷新被误判为本地修改。 + +根因:原生 NumericUpDown 的模板挂载还会通过 Value/内部输入框同步文字;只抑制 TextProperty 的工厂初始化或改写格式化回调不足以隔离这些通知。Avalonia TextChanged 事件又晚于立即的 Enter 提交,不能用它捕获同一消息中的最新文字。 + +发现与证据:首次 Editor 全回归 15 项失败;加入挂载后 PendingCount=0 断言复现。改为借用原生主题、由字段编辑器直接拥有模板 TextBox/ButtonSpinner 事件后,结构化列表、DTO patch、设置、外部刷新等聚焦回归通过。输入使用 TextBox.PropertyChanged 同步捕获,显式刷新用同步标记保护。 + +规则:作者数字文字和提交权属于 FieldEditorContext/FieldDraftState。模板初始化、换主题、默认值显示和外部刷新不得制造草稿;不能用 NumericUpDown.Value 的自动转换/钳制回写作者 JSON。保留原生外观时需确认实际输入、按钮及键盘行为,验证初始无草稿、同消息输入后 Enter、规范回显、Undo 和关闭后保存。 + +不要仅凭源码赋值或单个合法数字测试判定控件可用,也不要靠清空项目草稿来掩盖初始化副作用。 diff --git a/.agents/lessons/L-20261027-06-native-audio-utf8-abi.md b/.agents/lessons/L-20261027-06-native-audio-utf8-abi.md new file mode 100644 index 00000000..e9d61aab --- /dev/null +++ b/.agents/lessons/L-20261027-06-native-audio-utf8-abi.md @@ -0,0 +1,23 @@ +# 原生音频的 UTF-8 ABI 与 Windows 文件打开 + +状态:confirmed +日期:2026-10-27 +来源:跨平台引擎后端适配(独立实现,未建立 feature 工作流) + +## 现象与根因 + +把 C ABI 路径统一为 UTF-8 后,不能直接用 Windows 下 miniaudio 的 ma_decoder_init_file / ma_fopen 代替原来的宽字符入口。已固定的 miniaudio 0.11.23 在 Windows 的窄字符 ma_fopen 内调用 fopen_s,使用本机 ANSI 代码页,不保证按 UTF-8 解释路径;跨平台 API 的同名窄字符串入口不等于统一编码语义。 + +## 发现与证据 + +检查 src/Infrastructure/GalNet.Media.Miniaudio/native/miniaudio.h 的 ma_fopen 实现确认上述行为。本轮将边界统一为 UTF-8,在 Windows 通过 MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS) 转换,再调用 ma_decoder_init_file_w / _wfopen;其他系统使用窄字符路径。Unicode WAV 与 Vorbis 解码回归通过;真实设备回调、暂停重建设备和释放测试通过。 + +## 正确做法 + +- 在 C ABI 明确字符串编码,平台内部再转换;保留中文路径的真实解码测试。 +- 路径编码和回调签名变化时提升原生 ABI,并同时部署托管适配器和原生库。通过版本入口拒绝混用;不能只替换托管 DLL。 +- AOT 回调使用静态 UnmanagedCallersOnly 入口和显式上下文。原生设备销毁等待回调退出后,才释放 GCHandle;系统暂停不释放声音租约或修改剧情 Pause。 + +## 范围与禁止做法 + +适用于原生媒体库的字符串互操作和回调生命周期。不要把 CMake 编译成功当作移动设备验收,也不要把静态回调改造当作全引擎 AOT 支持。当前反射 JSON/metadata 的 IL 警告仍存在,详见 docs/manual/cross-platform-backends.md。 diff --git a/.agents/lessons/L-20261027-06-property-wire-boundaries.md b/.agents/lessons/L-20261027-06-property-wire-boundaries.md new file mode 100644 index 00000000..0f3e0a99 --- /dev/null +++ b/.agents/lessons/L-20261027-06-property-wire-boundaries.md @@ -0,0 +1,17 @@ +# 属性逻辑路径不能替代实际 JSON Pointer + +状态:confirmed +日期:2026-10-27 +来源:F-20261027-03-recursive-properties + +现象:给 JsonElement 字段补上嵌套结构后,检查器能够识别 JSON 字符串内部的多个引用,但旧的角色比较/替换和文本复制只使用 JsonPointer。多个内部引用共享外层字符串位置,会发生位置键冲突或将整个字符串替换为一个引用值。 + +根因:JSON Pointer 无法穿过字符串编码边界。结构内逻辑路径和实际 wire 位置是两种信息;只迁移只读分析,遗漏写入及复制消费者,会把原来的覆盖不足变成错误写入。 + +正确做法:类型化路径保留 Member、Index、MapKey 与 JsonString 边界;对外 Pointer 在编码边界终止,内部位置使用完整逻辑路径。引用比较/去重使用逻辑位置,回写使用类型化路径的纯 Rewrite,并保留原编码、未知成员和精确数值。宿主仍检查作者身份与事务。 + +验证:RecursivePropertyTests 的编码对象替换;LocalizationReferenceIndexTests.MultipleReferencesInsideAnEncodedArrayHaveDistinctLocationsAndRewriteTheirOwnLeaves 验证同一外层 Pointer 的两条引用独立回写且保留大整数;Editor 全回归覆盖角色和嵌套文本复制。 + +额外实现陷阱:System.Text.Json.Nodes.JsonArray 不能将已经属于该数组的节点再次赋回同一位置。递归修改子节点后,如果返回的仍是原实例,应保留它而不是重新挂载。初次引用回写测试实际暴露 NodeAlreadyHasParent;比较引用后才执行替换可避免该问题。 + +禁止仅为消除 opaque 警告就宣称全部引用消费者已支持新结构;必须同时检查定位、身份比较、修改、复制与归档路径。 diff --git a/.agents/lessons/L-20261027-07-complete-msvc-toolsets.md b/.agents/lessons/L-20261027-07-complete-msvc-toolsets.md new file mode 100644 index 00000000..0ac3a7fe --- /dev/null +++ b/.agents/lessons/L-20261027-07-complete-msvc-toolsets.md @@ -0,0 +1,21 @@ +# Windows 原生构建选择完整的目标工具集 + +状态:confirmed +日期:2026-10-27 +来源:跨平台后端 ARM64 构建复验(独立实现) + +## 现象与根因 + +用户补装了 ARM64 MSVC 14.44,原生音频构建仍报告缺少 ARM64 libcmt.lib。默认 vcvarsall 选择 14.51;该版本只有 ARM64 的部分 Clang 辅助库,完整的 ARM64 编译器、头文件和 CRT 位于并存的 14.44。默认工具集不完整不等于机器上没有可用工具集。ATL/MFC 的 ARM64 安装目录也不能证明编译工具和 CRT 已装齐。 + +## 发现与正确做法 + +检查实际 lib/arm64/libcmt.lib 和 bin/Hostx64/arm64/cl.exe;不要只看目录存在或安装器显示的 ARM64 关键词。按目标架构筛选同时具有编译器、头文件、静态运行库的工具集,按版本排序后通过 vcvarsall -vcvars_ver=<完整版本> 显式选择。不同目标架构可以使用不同工具集。 + +## 验证 + +native/build.ps1 的 ARM64 构建选择 MSVC 14.44.35207 并链接成功,PE Machine 为 0xAA64,8 个 gn_* ABI 导出齐全;x64 构建仍选择 14.51.36231,PE Machine 为 0x8664。此结果证明交叉编译与产物身份,不代表 ARM64 实机播放通过。 + +## 适用范围与禁止做法 + +适用于多版本 MSVC 并存的原生构建。不要在未检查并存版本前反复要求用户安装组件,也不要为迎合默认工具集混用不同版本的头文件和 CRT。当前接入事实见 docs/manual/cross-platform-backends.md。 diff --git a/.agents/lessons/L-20261027-08-aot-build-tool-isolation.md b/.agents/lessons/L-20261027-08-aot-build-tool-isolation.md new file mode 100644 index 00000000..99f7307f --- /dev/null +++ b/.agents/lessons/L-20261027-08-aot-build-tool-isolation.md @@ -0,0 +1,17 @@ +# NativeAOT 发布属性与编译工具隔离 + +- 状态:confirmed +- 日期:2026-10-27 +- 适用范围:以 ProjectReference 挂载 netstandard 源生成器的 NativeAOT 宿主 +- 来源:引擎 AOT 实现与用户要求复用 Headless Sample +- 文档:docs/manual/native-aot.md + +## 现象与根因 + +全局 `-p:PublishAot=true` 会沿 restore 项目图进入 GalNet.SchemaGenerator,使 netstandard2.0 项目触发 NETSDK1207。仅在 ProjectReference 上配置 GlobalPropertiesToRemove 能控制构建引用,不能单独解决本仓库此 restore 路径。独立 AOT 项目并非所需修复。 + +## 正确做法与验证 + +编译工具项目显式通过 TreatAsLocalProperty 接管 PublishAot、SelfContained、RuntimeIdentifier,并将其分别设为 false、false、空。玩家仍使用标准 PublishAot 选项。实际 Headless NativeAOT 发布并运行通过,dynamicCode=False、reflectionJson=False,最终 ILLink/ILC 警告按错误处理。 + +RID/ILCompiler 恢复图另外隔离至 .galnet,设置 RestorePackagesWithLockFile=false 和单独 NuGetLockFilePath;已有开发 lock 文件时仅前者不足以避免恢复图冲突。禁止为了发布修改或删除普通开发锁文件,禁止吞掉最终链接警告。 diff --git a/.agents/lessons/L-20261027-09-sourcegen-extension-data.md b/.agents/lessons/L-20261027-09-sourcegen-extension-data.md new file mode 100644 index 00000000..4467a90a --- /dev/null +++ b/.agents/lessons/L-20261027-09-sourcegen-extension-data.md @@ -0,0 +1,17 @@ +# 源生成 JSON 与 init extension data + +- 状态:confirmed +- 日期:2026-10-27 +- 适用范围:System.Text.Json 源生成 metadata,含 JsonExtensionData 的 DTO +- 来源:引擎 AOT 实现,GalleryAnnotation 未知字段保留回归 +- 文档:docs/manual/native-aot.md、docs/spec/assets.md + +## 现象与根因 + +GalleryAnnotation.ExtensionData 使用 init 时,源生成器为该类型生成初始化构造函数并试图绑定 extension data,触发不受支持的构造参数绑定。普通反射路径未暴露这一差异,原有未知字段保留测试在切换生成 metadata 后失败。 + +## 正确做法与验证 + +该可变字典属性使用 set,保持 JsonExtensionData 和 wire 不变。原有 Gallery 测试通过;禁用反射 JSON 的 NativeAOT 自检再验证未知 annotation 字段写出、读回。对类似 DTO 不应只验证已知字段,也不要只以成功编译判定生成 codec 可用。 + +本规则针对已复现的 extension data 形状,不推广为所有 init 属性均不支持源生成。 diff --git a/.agents/lessons/L-20261027-10-open-shader-parameters.md b/.agents/lessons/L-20261027-10-open-shader-parameters.md new file mode 100644 index 00000000..7fce5877 --- /dev/null +++ b/.agents/lessons/L-20261027-10-open-shader-parameters.md @@ -0,0 +1,20 @@ +# Shader 扩展参数与封闭 DTO 的边界 + +状态:confirmed +来源:features/F-20261027-03-recursive-properties/follow-up-open-shader-parameters.md + +## 现象与根因 + +切换 shader 后保留 angle 等旧参数时,扫描曾将其报告为 field.unknown;用户确认这类参数合法,不应 warning。扫描把 shader 参数目录误当成封闭 DTO,而渲染器 ShaderProgramTextureEffect 仅枚举 descriptor.Parameters 绑定 uniform/texture,额外数据不被消费。 + +## 正确做法 + +已知 shader 的参数对象显式声明为开放对象,附加成员是没有子引用语义的原子 JSON。保留任意合法附加值,不生成漏分析 warning,不猜测其中的文本/角色 key。已声明字段仍校验类型/范围,重复成员仍诊断,未知 shader 仍报告无法确定结构。该规则也适用于 shader 预设和 Timeline 效果事件。 + +## 适用范围与禁止做法 + +规则来自领域消费者对扩展数据的明确忽略语义;不可全局关闭未知 DTO/未知 schema 的诊断,也不可自动套用到仍有封闭校验的音效或 legacy catalog。不能把“不写作者数据”等同于“必须给作者 warning”,也不能把一条合法扩展的 warning 解释为用户需要手工清理。 + +## 发现与验证 + +用户提供 Bloom 参数对象 angle/strength/softKnee/radius/threshold 并明确纠正。源码核对 TextureEffects.cs 的 descriptor 参数枚举;Core 开放对象回归验证附加对象/数组/空键、已知字段校验、重复成员、局部 patch 与别名边界。相关正式规范:docs/spec/entry-types.md、docs/spec/editor-game-localization.md。 diff --git a/.agents/lessons/L-20261027-11-solution-fixture-configuration.md b/.agents/lessons/L-20261027-11-solution-fixture-configuration.md new file mode 100644 index 00000000..7161b092 --- /dev/null +++ b/.agents/lessons/L-20261027-11-solution-fixture-configuration.md @@ -0,0 +1,9 @@ +# Solution 构建必须覆盖测试读取的 fixture 配置 + +状态:confirmed。来源:F-20261027-04-tagged-release。 + +- 现象:Release 全量构建成功,契约配置流水线有三项失败;测试读取的 SchemaA Release DLL 时间仍是旧版本,缺少 developer.options。 +- 证据:out/ci/tagged-release-candidate/build.log 显示 solution 外的 SchemaA/SchemaB 引用实际构建到 bin/Debug;测试按自身 Release 编译常量读取 bin/Release。 +- 修正:将全部 fixture 项目纳入 GalNet.slnx,让 solution 为引用工程传递相同配置;fixture-build.log 确认输出 Release,ProjectContractPipelineTests 8 项通过。 +- 适用:测试或 CLI 从普通 bin 路径加载程序集、包含 ReferenceOutputAssembly=false 的契约 fixture。 +- 禁止:依赖开发机残留 DLL,或通过复制旧产物/跳过失败用例让 CI 通过。 diff --git a/.agents/lessons/L-20261027-12-generated-init-defaults.md b/.agents/lessons/L-20261027-12-generated-init-defaults.md new file mode 100644 index 00000000..303acbf5 --- /dev/null +++ b/.agents/lessons/L-20261027-12-generated-init-defaults.md @@ -0,0 +1,9 @@ +# 源生成 JSON 的 init-only 默认值 + +状态:confirmed。来源:F-20261027-04-tagged-release。 + +- 现象:精简作者 JSON 只声明音轨 id,生成元数据反序列化后 MaxVoices、DefaultGain 均为 0,导致合法音轨配置被拒绝;Bus gain 也变为 0。 +- 根因:init-only 成员通过生成器的构造绑定初始化,省略字段的默认绑定值覆盖 CLR 属性初始化器。反射路径的行为不能作为源生成路径证据。 +- 验证:GeneratedMetadataPreservesDefaultsForOmittedAudioFields 同时断言音轨容量/增益与 Bus 增益,先失败,显式带可选默认值的 JsonConstructor 后通过。 +- 修正:保留 public 无参构造与 record/init 不可变语义,增加 JsonConstructor 及明确的字段默认值。 +- 禁止:将 DTO 全部改成可写属性破坏已有所有权保证,或放宽音轨容量校验掩盖默认值丢失。 diff --git a/.agents/lessons/L-20261028-01-native-publish-payload.md b/.agents/lessons/L-20261028-01-native-publish-payload.md new file mode 100644 index 00000000..0cab15cb --- /dev/null +++ b/.agents/lessons/L-20261028-01-native-publish-payload.md @@ -0,0 +1,10 @@ +# 原生 NuGet 内容不一定遵守发布 RID 与符号开关 + +- 状态:confirmed +- 来源:F-20261027-04-tagged-release,用户追问发行体积。 +- 现象:win-x64 自包含发布仍带 VLC win-x86 / win-arm64,合计约 178 MiB;Editor 的 Skia/HarfBuzz 原生 PDB 约 100 MiB。 +- 根因:原生依赖通过 NuGet 内容/构建规则复制多架构和符号文件,不能从 `-r win-x64` 或托管符号复制设置推导最终产物只含目标架构、没有 PDB。 +- 发现:对真实 ZIP 解压后的文件按路径、扩展名和字节统计,而非仅检查 csproj 属性。 +- 做法:在最终应用载荷生成 manifest/hash 之前过滤 PDB 与明确不支持的 VLC 架构;发布验收同时拒绝多余文件并确认 x64 VLC 核心/codec 仍在。禁止按 DLL 扩展名一律删除或在 hash 封存后修改 ZIP。 +- 边界:只调整应用发行载荷,构建目录可继续保留符号;x64 插件仍需按实际音视频能力验收后才能进一步裁减。 +- 文档:docs/manual/release-packaging.md。 diff --git a/.agents/lessons/L-20261028-02-pak-index-payload-verification.md b/.agents/lessons/L-20261028-02-pak-index-payload-verification.md new file mode 100644 index 00000000..5c9c6feb --- /dev/null +++ b/.agents/lessons/L-20261028-02-pak-index-payload-verification.md @@ -0,0 +1,15 @@ +# PAK metadata 索引不等于 payload 完整性验证 + +状态:confirmed,2026-10-28。 + +现象:P4 覆盖包集成测试翻转普通 sprite payload 的一个字节,安装准备仍成功,直到资源读取才检查出哈希错误。 + +根因:Archive.Deserialize 验证控制描述、索引、范围等;普通资源的解压长度和 payload 哈希由 GetAsset 校验。MetadataIndex 仅返回表项,不触发解码。覆盖包的全文件 hash 用于优先级排序,是针对当前文件计算的值,不能替代已知期望校验和。 + +正确做法:无外部可信期望哈希的独立覆盖包,在准备时逐个 GetAsset 并验证 payload;基础/剧情包还需与清单的已知全文件哈希比较。不要将“可反序列化索引”写成“所有内容校验通过”。 + +验证:StoryPackageExportTests.ResourcePatchExportsBeforeBaseAndSessionsFreezeTheSelectedOverrideSet,损坏覆盖包必须在 CreateAssetProviders 阶段抛 InvalidDataException。 + +适用:PAK 安装、导入、资源索引和任何以 metadata-only 优化装载的流程。 + +来源:features/F-20261028-01-content-packaging/phase-4.md;docs/spec/assets.md。 diff --git a/.agents/lessons/L-20261028-03-internal-snapshot-content-identity.md b/.agents/lessons/L-20261028-03-internal-snapshot-content-identity.md new file mode 100644 index 00000000..178b032d --- /dev/null +++ b/.agents/lessons/L-20261028-03-internal-snapshot-content-identity.md @@ -0,0 +1,15 @@ +# 内部重建快照也要保留内容身份 + +状态:confirmed,2026-10-28。 + +现象:打包原生 Sample 可正常启动,但调试 Seek 在 RestoreFromCoreAsync 报“不同内容构建或缺少剧情包”。开发目录没有复现,因为开发 ContentBuildId 为空。 + +根因:给存档增加 ContentBuildId/StoryPackages 后,普通稳定快照已加身份,调试 replay 仍手写 new GameSnapshot,只复制节点、状态和变量。Sample 的精简调试存档也有同样遗漏。 + +正确做法:区分外部恢复输入和当前引擎生成的内部快照。内部 replay 基线由当前引擎 StampContent,精简外部副本保留源身份;不要放宽外部存档兼容校验。新增快照字段时搜索所有手写复制、缓存和替代工厂,并使用非空构建身份测试。 + +验证:Debug 配置下显式 Packaged_variants_render_in_native_sample_and_close_cleanly 通过;Runtime DebugSessionTests 增加当前/缓存/隔离 Seek 与未标记外档拒绝回归,SampleDebugSnapshotFactoryTests 增加身份复制断言。 + +适用:调试、回放、回滚、存档精简及跨模块快照工厂。 + +来源:features/F-20261028-01-content-packaging/phase-6.md;docs/spec/runtime.md。 diff --git a/.agents/lessons/L-20261028-04-optional-parameter-binary-api.md b/.agents/lessons/L-20261028-04-optional-parameter-binary-api.md new file mode 100644 index 00000000..da3d6a87 --- /dev/null +++ b/.agents/lessons/L-20261028-04-optional-parameter-binary-api.md @@ -0,0 +1,15 @@ +# 可选参数也会改变公开方法的 CLR 签名 + +状态:confirmed,2026-10-28。 + +现象:FieldDescriptor 新增可选 resourceTypeId 后,当前源码可编译,但既有 SchemaGenerator 二进制兼容测试抛 MissingMethodException。 + +根因:C# 可选参数只影响调用方源码的默认值补齐,不能保留旧 CLR 构造函数签名。旧契约 DLL 仍调用原八参数构造函数。 + +正确做法:保留旧签名的转发重载,再提供新签名;保留不会造成源调用歧义的参数规则。用旧 API 独立编译 consumer,再让它装载当前 Core 验证,不以重新编译当前源码替代二进制兼容证据。 + +验证:FieldSchemaGeneratorTests.AConsumerCompiledAgainstTheOldDescriptorApiLoadsAgainstCurrentCore,以及最终 SchemaGenerator 全套 20/20 通过。 + +适用:Core/Contracts 的公开构造函数、插件契约、公开扩展方法和宿主加载的预编译模块。格式版本升级不自动授权破坏无关 CLR API。 + +来源:features/F-20261028-01-content-packaging/phase-6.md。 diff --git a/.agents/lessons/L-20261029-01-content-baseline-field-completeness.md b/.agents/lessons/L-20261029-01-content-baseline-field-completeness.md new file mode 100644 index 00000000..b4764fe5 --- /dev/null +++ b/.agents/lessons/L-20261029-01-content-baseline-field-completeness.md @@ -0,0 +1,13 @@ +# 新持久字段必须进入手写保存基线比较 + +状态:confirmed,2026-10-29。 + +现象:资源补丁即时保存回归中,未保存的 Group.storyPackage 仍在工作版,磁盘保持原值,但 ContentSession.IsDirty 错误为 false。 + +根因:DTO、克隆和重载已保留 StoryPackage,ProjectContentBaseline.Node 的手写相等比较却没有该字段。完整构建和保存/撤销回归不能自动覆盖“只改该字段”的 dirty 判断。 + +正确做法:增加持久字段时搜索序列化、克隆、重载、等价判断、恢复与发布基线;验证单独修改字段使会话 dirty,局部发布其他内容后仍 dirty,磁盘没有被顺带保存。不以强制 Save All 掩盖漏项。 + +验证:ResourcePatchesPersistWithoutSavingOtherContentAndExportDoesNotCommitDrafts,包名工作版与磁盘不同时 IsDirty=true,资源补丁节保存及导出均保留差异。 + +来源:features/F-20261028-01-content-packaging/follow-up-panel-20261028.md;ProjectContentBaseline.Node。 diff --git a/.agents/lessons/L-20261029-02-window-decoration-theme-resources.md b/.agents/lessons/L-20261029-02-window-decoration-theme-resources.md new file mode 100644 index 00000000..ae10bdd0 --- /dev/null +++ b/.agents/lessons/L-20261029-02-window-decoration-theme-resources.md @@ -0,0 +1,13 @@ +# 窗口装饰颜色与播放器内容样式的边界 + +状态:confirmed +日期:2026-10-29 +来源:Sample UI 调整(独立修复,无 feature 记录) + +- 现象:播放器内容使用 Mono 浅色,系统/宿主主题可能仍是深色;标题栏关闭按钮颜色与背景混在一起。为 UrsaWindow 内容模板下的 Button 写选择器不能命中独立装饰层。 +- 原因:Ursa 2.1/Avalonia 12 的关闭按钮属于 WindowDrawnDecorations,不是 UrsaWindow 内容模板的后代。当前 Headless 平台不会生成平台窗口装饰,GetVisualDescendants 只返回内容控件,不能用“找不到关闭按钮”推断原生 UI 没有按钮。 +- 正确做法:在窗口资源层将 CaptionButtonForeground、关闭按钮悬停/按下颜色、TitleBarBackground 等真实装饰资源映射到玩家调色板。完整播放器 Fluent 样式仍放在播放器根;窗口只共享调色板,避免改变宿主窗口控件主题。 +- 验证:SampleWindowThemeTests 检查实际装饰资源与玩家前景色一致,并验证退出遮罩的取消焦点、Escape 和结果。窗口装饰的像素与系统交互仍需原生桌面验证;Headless 资源断言不替代原生截图。 +- 禁止:把 Window 内容树中的 Button 选择器当成所有窗口装饰按钮的覆盖;为了取到标题栏控件而给整个 Window 挂上播放器 FluentTheme。 +- 依据:Ursa.Avalonia v2.1.0 的 UrsaWindow.axaml(独立 KEY_URSAWINDOW_DRAWN_DECORATIONS);本次 artifacts/sample-ui-tests/sample-window.trx 与 artifacts/sample-ui-captures/Sample-ExitConfirmation.png。 +- 正式文档:docs/spec/control.md、docs/manual/customize-avalonia-sample.md。 diff --git a/.agents/lessons/L-20261029-03-powershell-runtime-api-compatibility.md b/.agents/lessons/L-20261029-03-powershell-runtime-api-compatibility.md new file mode 100644 index 00000000..6f3d960a --- /dev/null +++ b/.agents/lessons/L-20261029-03-powershell-runtime-api-compatibility.md @@ -0,0 +1,12 @@ +# PowerShell 脚本必须验证实际宿主的 .NET API + +状态:confirmed +日期:2026-10-29 +来源:用户反馈 Sample 启动脚本在 Windows PowerShell 报 MethodNotFound(独立修复) + +- 现象:run-avalonia-sample.ps1 调用 System.IO.Path.TrimEndingDirectorySeparator 失败;Headless 脚本使用同一表达式。 +- 根因:该方法存在于现代 .NET,但 Windows PowerShell 5.1 运行于 .NET Framework,不提供它。开发环境默认 pwsh 的成功不能证明系统 powershell.exe 兼容。 +- 正确做法:无显式高版本宿主要求的简单路径处理使用两者共有的 API。本次改为 String.TrimEnd(char[]);项目路径已转绝对路径,仅在与固定 GameTestCase 路径比较时去除末尾分隔符,不用于文件删除或根目录重写。 +- 验证:Windows PowerShell 5.1.26100.9444 与 PowerShell 7 各运行两个原样复制的 Sample 脚本,覆盖默认、反斜杠结尾、斜杠结尾、大小写、自定义项目、SkipBuild,共 24 项通过。外部构建/部署/启动被记录桩替代,只验证实际宿主下的路径及调用控制流,不声称重新验收播放器。 +- 禁止:仅做 PowerShell 语法解析或仅用 pwsh 执行,就宣称 Windows PowerShell 兼容;通过要求用户换 shell 掩盖普通脚本新引入的 API 不兼容。 +- 证据:artifacts/powershell-sample-compat/;相关脚本 scripts/samples/run-avalonia-sample.ps1、run-headless-sample.ps1。 diff --git a/.agents/lessons/L-20261029-04-bounded-scene-capture.md b/.agents/lessons/L-20261029-04-bounded-scene-capture.md new file mode 100644 index 00000000..ab15128e --- /dev/null +++ b/.agents/lessons/L-20261029-04-bounded-scene-capture.md @@ -0,0 +1,17 @@ +# L-20261029-04:缩小场景预览时也要限制效果处理像素 + +- 状态:confirmed +- 来源:2026-10-29 Sample 保存与截图卡顿跟进(独立修复) +- 关联:docs/spec/control.md,SceneLayerHost,SceneRenderPipeline + +## 现象与根因 + +只把 RenderTargetBitmap 或 Image 缩小,仍可能在 UI 线程内运行完整逻辑分辨率的 CPU 效果链。场景自定义绘制操作内部自行分配纹理,外层缩放不约束它的像素预算。 + +## 正确做法 + +捕获入口分别传入逻辑尺寸和像素尺寸;图层原生大小、X/Y、粒子坐标继续用原逻辑空间,绘制画布缩放到输出像素空间。层效果与场景效果的中间纹理都使用预览像素尺寸。正式截图保留原分辨率,PNG 编码在渲染后移到后台。同一截图弹窗的两种界面包含状态可以分别缓存。 + +## 验证与禁止做法 + +SceneLayerHostTests.Bounded_snapshot_preserves_native_texture_coordinates_and_bounds_effect_work 验证非零位置的 Native 图片和效果源纹理尺寸;ScreenshotEntryTests.BoundedCaptureIncludesControlsPreservesSceneAndLeavesFullExportUnchanged 验证界面叠层、后台编码和后续完整导出。禁止仅凭小图片的输出尺寸宣称渲染计算已缩小;禁止把低分辨率预览用于正式导出。 diff --git a/.agents/lessons/L-20261029-05-save-thumbnail-notifications.md b/.agents/lessons/L-20261029-05-save-thumbnail-notifications.md new file mode 100644 index 00000000..4904946b --- /dev/null +++ b/.agents/lessons/L-20261029-05-save-thumbnail-notifications.md @@ -0,0 +1,15 @@ +# L-20261029-05:存档列表通知不能反复重建已解码缩略图 + +- 状态:confirmed +- 来源:2026-10-29 Sample 保存卡顿跟进(独立修复) +- 关联:docs/spec/control.md,AvaloniaGameSession,SaveSlotsPageViewModel + +## 现象与根因 + +整表 Clear 后逐项 Add,每次 CollectionChanged 都重建全部行并重新解码图片,60 个槽位会产生二次方增长的重复工作。异步磁盘写入无法避免随后 UI 线程的图片解码卡顿。 + +## 正确做法与验证 + +整表准备好后发送一次 Reset;单槽保存使用 Replace,VM 只替换对应行并释放旧图,改名仅更新名称。保存前后只查询目标元数据,自定义存储通过可选 ISaveSlotInfoSource 实现,原 ISaveService 保持兼容。缩略图生成和旧图解码均限制尺寸。 + +GameThemeTests.Saving_one_slot_preserves_the_other_rows_and_decoded_previews 验证其他行与 Bitmap 身份保持不变;FileSaveServiceTests.SingleSlotLookupMatchesListMetadataForOccupiedEmptyAndCorruptSlots 验证单槽查询语义。禁止在每次集合通知中无条件 Clear/Rebuild 带解码资源的全部行。 diff --git a/.agents/lessons/L-20261029-06-native-exit-confirmation.md b/.agents/lessons/L-20261029-06-native-exit-confirmation.md new file mode 100644 index 00000000..454deb17 --- /dev/null +++ b/.agents/lessons/L-20261029-06-native-exit-confirmation.md @@ -0,0 +1,9 @@ +# 原生 Sample 关闭测试须完成退出确认 + +- 状态:confirmed +- 来源:F-20261029-01-desktop-package-layout。 +- 现象:开发发行包已通过调试连接、开始、变量修改、定位和停止,原生测试仍在退出处超时;finally 的临时目录清理 IOException 可掩盖原始超时。 +- 根因:MainWindow 的退出遮罩默认聚焦 Cancel;仅向自有窗口发送 WM_CLOSE 不会完成关闭,不能据此推断发行包的释放链有问题。 +- 做法:NativeSampleWindow 先按 PID 与明确标题选取唯一自有窗口,再排队 WM_CLOSE、Tab、Enter,完成真实确认;继续断言零退出码和玩家变量已落盘。输入只发给该 HWND,不切换用户前台窗口。 +- 验证:未确认时三种原生场景均失败;补充确认后,同一开发 ZIP 三种场景全部通过,另两种真实整合内容加载也通过。见 out/package-layout/tests/published-native-confirmed.trx。 +- 边界:这是测试适配,不在玩家代码中跳过确认,也不通过强杀进程宣称正常退出验收成功。失败清理异常需要保留原始诊断。 diff --git a/.agents/lessons/L-20261029-07-self-contained-platform-assemblies.md b/.agents/lessons/L-20261029-07-self-contained-platform-assemblies.md new file mode 100644 index 00000000..3ee74d4a --- /dev/null +++ b/.agents/lessons/L-20261029-07-self-contained-platform-assemblies.md @@ -0,0 +1,18 @@ +# 自包含部署的业务 DLL 不能按 corelib 目录排除 + +状态:confirmed +来源 Feature:F-20261029-04-new-project-application + +## 现象与根因 + +识别契约依赖闭包时,若把 TRUSTED_PLATFORM_ASSEMBLIES 中与 System.Private.CoreLib 同目录的条目全部视为平台程序集,框架依赖部署可以工作,自包含部署却会把同目录的 GalNet.Core、Contracts.Builtins 等业务依赖一起排除。目录位置不表示程序集归属。 + +## 发现与证据 + +当前自包含发行的 lib 同时包含 framework 与应用 DLL;GalNet.Editor.deps.json 的 targets 含 runtimepack.Microsoft.NETCore.App.Runtime.win-x64/10.0.12,其 runtime 节点单独列出平台资产。用自包含 ContractSmoke 实际调用 ProjectContractSource,SchemaA 依赖闭包保留 Core、Builtins、SchemaDependency,读取真实 Sample Debug 输出只得到 Core、Builtins、Project.Contracts 三个 DLL,保持原始字节且释放临时目录。 + +## 正确做法与边界 + +自包含部署从运行时 deps 清单的 runtimepack.Microsoft.NETCore.App.Runtime.* runtime 资产识别平台程序集;框架依赖部署才使用独立框架目录中的 TPA 条目。不得直接按自包含 lib 目录排除程序集,也不得把全部 TPA 当作纯 BCL。此规则用于依赖收集;已有 ProjectSchemaContext 的平台回退逻辑未在本 feature 中修改,不据此宣称它的所有部署边界已修复。 + +证据:out/new-project-app-smoke/published/ContractSmoke.exe;实现:src/Editor/GalNet.Editor.Shared/Services/ProjectContractSource.cs;设计与总结:features/F-20261029-04-new-project-application/。 diff --git a/.agents/lessons/L-20261029-08-invalid-data-startup-fallback.md b/.agents/lessons/L-20261029-08-invalid-data-startup-fallback.md new file mode 100644 index 00000000..df89b022 --- /dev/null +++ b/.agents/lessons/L-20261029-08-invalid-data-startup-fallback.md @@ -0,0 +1,12 @@ +# 安装内容损坏异常与启动 profile 回退 + +状态:confirmed +日期:2026-10-29 +来源:features/F-20261029-02-packed-game-documents + +- 现象:新增 game.pak 身份读取后,profile 定位先于日志和启动窗口执行,损坏包可能提前终止程序。 +- 根因:`System.IO.InvalidDataException` 的基类是 `SystemException`,不是 `IOException`;原来针对文件与 JSON 的 catch 不会包含 GPAK 验证失败。 +- 发现:复核 Sample 启动顺序,并在实际 PowerShell 宿主查询异常 BaseType 确认。 +- 正确做法:只在允许缺失身份的 profile 探测边界显式处理 InvalidDataException,暂用路径摘要;正式内容加载继续拒绝损坏包,由启动窗口报告错误。 +- 禁止:把内容加载校验本身改成静默回退,或认为所有 System.IO 命名空间异常都会被 IOException 捕获。 +- 回归:PackedDocumentTests.CorruptedPackedDocumentsAreRejectedWithoutLegacyFallback 同时验证 profile 探测不抛出与内容加载仍拒绝;isolated-integration.trx 中 game.pak/localization.pak 两用例通过。 diff --git a/.agents/lessons/L-20261029-09-reading-preamble-checkpoints.md b/.agents/lessons/L-20261029-09-reading-preamble-checkpoints.md new file mode 100644 index 00000000..05b3b198 --- /dev/null +++ b/.agents/lessons/L-20261029-09-reading-preamble-checkpoints.md @@ -0,0 +1,22 @@ +# 阅读复合条目的存档边界应覆盖语音前导 + +状态:confirmed +来源:F-20261029-03-dialogue-history + +## 现象和根因 + +带语音对白编译为非流程阻塞 audio.play 和阻塞 dialogue.text。Engine 原先在音频后发布稳定快照,EntryIndex 指向文本;读档只重执行文本,不能通过本次执行恢复同源语音关联,而且恢复的音频缺少原语的自动等待所有权。 + +## 正确做法 + +复用 readingBoundaryId,从前导原语开始至阅读原语完成前保持此前稳定快照。阅读原语进入后由原有阻塞状态继续保护;CatchUp 完整执行后才能发布新稳定点。宿主历史按快照中的小游标截取正文,当前句读档重执行不重复。 + +展示完成的异步 continuation 不保证先于玩家下一次推进执行;历史游标还须在 OnCheckpointCreating(Engine 执行门内)同步提交。不能只依赖后台观察 PresentationCompletion。CheckpointFlushesPresentedTextEvenIfItsPresentationContinuationHasNotRun 用延迟的呈现完成任务覆盖该交错。 + +## 证据 + +DialogueHistoryTests.EngineCheckpointRestartsTheWholeVoicedLineAndKeepsHistoryWithoutDuplicates:显示带语音当前句时保存仍为整句入口,读档重新执行音频/文本并保留语音引用;进入下一句后只保存此前历史前缀。Runtime 124 项和现有自动等待集成用例通过。 + +## 边界 + +不要只序列化一个历史语音字段掩盖半句快照问题,也不要从当前正在响的音频推断文本关系。Core/Runtime 仅处理阅读边界,不识别具体音频或对白类型。 diff --git a/.agents/lessons/L-20261029-10-disabled-title-foreground.md b/.agents/lessons/L-20261029-10-disabled-title-foreground.md new file mode 100644 index 00000000..65540a92 --- /dev/null +++ b/.agents/lessons/L-20261029-10-disabled-title-foreground.md @@ -0,0 +1,11 @@ +# 模态遮罩下的静态标题前景色 + +- 状态:confirmed +- 日期:2026-10-29 +- 来源:用户反馈退出确认出现后主页标题消失;独立 Sample UI 修复。 +- 现象:退出确认将 PlayerContent.IsEnabled 设为 false。主页 GameTitle 没有显式 Foreground,继承内容容器的禁用文字色,再叠加遮罩后标题显著变淡;标题字符串与可见性没有改变。 +- 根因:容器禁用会改变后代继承的前景画刷。之前的弹窗截图没有执行主页导航或 CompleteStartup,背景为空,未覆盖实际主页。 +- 做法:Classic/Minimal/Paper 静态标题显式绑定 TextPrimary;保留背景禁用及输入保护。弹窗测试挂载真实主页,比较打开前后标题画刷的 Color 与 Opacity,不能只检查标题字符串或 IsVisible。 +- 验证:修复前实际挂载回归失败(标题颜色由 #FF181D25 变为 #FF1C1F23,截图显著变淡);修复后 SampleWindowThemeTests 通过,三布局×三配色的 9 项挂载测试通过。前后截图在 out/exit-title/。 +- 边界:静态标题保留正常色;可操作控件的禁用反馈不在此规则范围。不要为保留标题而解除背景输入禁用,也不要用空背景截图证明主页仍可见。 +- 正式文档:docs/spec/control.md、docs/manual/customize-avalonia-sample.md。 diff --git a/.gitattributes b/.gitattributes index d513d984..2e5afff7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,4 +10,8 @@ *.yml text *.ico binary -*.png binary \ No newline at end of file +*.png binary + +# Preserve the checksum of pinned upstream source bytes on Windows checkouts. +src/Infrastructure/GalNet.Media.Miniaudio/native/miniaudio.h -text +src/Infrastructure/GalNet.Media.Miniaudio/native/stb_vorbis.c -text diff --git a/.github/workflows/backend-portability.yml b/.github/workflows/backend-portability.yml new file mode 100644 index 00000000..f14a1cbb --- /dev/null +++ b/.github/workflows/backend-portability.yml @@ -0,0 +1,90 @@ +name: Backend portability + +on: + pull_request: + paths: + - 'src/Shared/**' + - 'src/Infrastructure/**' + - 'src/Tooling/**' + - 'src/Samples/GalNet.Sample.Headless/**' + - 'src/Samples/GalNet.Sample.Contracts/**' + - 'test/GalNet.Assets.Tests/**' + - 'test/GalNet.Media.Audio.Tests/**' + - 'Directory.*' + - 'global.json' + - 'scripts/test-native-aot.ps1' + - '.github/workflows/backend-portability.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + portable-backends: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + library: galnet_miniaudio.dll + - os: ubuntu-latest + library: libgalnet_miniaudio.so + - os: macos-latest + library: libgalnet_miniaudio.dylib + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.401 + - name: Build native audio library + shell: pwsh + run: | + cmake -S src/Infrastructure/GalNet.Media.Miniaudio/native -B artifacts/native -DCMAKE_BUILD_TYPE=Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build artifacts/native --config Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --install artifacts/native --config Release --prefix artifacts/native/install + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Verify shared resources and audio decoding + shell: pwsh + run: | + $nativePath = Join-Path $PWD 'artifacts/native/install/${{ matrix.library }}' + foreach ($project in @('GalNet.Assets.Tests', 'GalNet.Media.Audio.Tests')) { + $projectPath = "test/$project/$project.csproj" + dotnet restore $projectPath --locked-mode -p:GalNetMiniaudioNativePath="$nativePath" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build $projectPath --no-restore --configuration Release -p:GalNetMiniaudioNativePath="$nativePath" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test $projectPath --no-build --no-restore --configuration Release -p:GalNetMiniaudioNativePath="$nativePath" --logger "trx;LogFileName=$project.trx" --results-directory artifacts/results + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - uses: actions/upload-artifact@v4 + if: always() + with: + name: backend-tests-${{ matrix.os }} + path: artifacts/results + if-no-files-found: warn + + native-aot: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.401 + - name: Publish and execute NativeAOT engine acceptance + shell: pwsh + run: ./scripts/test-native-aot.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6946a6d..92703cde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,138 +3,36 @@ name: CI on: pull_request: push: - branches: - - master + branches: [master] workflow_dispatch: permissions: contents: read -jobs: - format: - runs-on: windows-latest - timeout-minutes: 20 - env: - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_NOLOGO: '1' - AVALONIA_TELEMETRY_OPTOUT: '1' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.401 - - name: Restore locked dependencies (excluding launcher) - shell: pwsh - run: | - $projects = Get-ChildItem src, test -Filter *.csproj -Recurse | - Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } | - Sort-Object FullName - foreach ($project in $projects) { - dotnet restore $project.FullName --locked-mode - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - - name: Verify whitespace - shell: pwsh - run: | - $projects = Get-ChildItem src, test -Filter *.csproj -Recurse | - Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } | - Sort-Object FullName - foreach ($project in $projects) { - dotnet format whitespace $project.FullName --no-restore --verify-no-changes --verbosity quiet - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - - name: Verify imports - shell: pwsh - run: | - $projects = Get-ChildItem src, test -Filter *.csproj -Recurse | - Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } | - Sort-Object FullName - foreach ($project in $projects) { - dotnet format style $project.FullName --no-restore --verify-no-changes --diagnostics IDE0005 --verbosity quiet - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true - desktop-build-and-test: +jobs: + verify: + strategy: + fail-fast: false + matrix: + stage: [Format, BuildTest] runs-on: windows-latest - timeout-minutes: 30 - env: - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_NOLOGO: '1' - AVALONIA_TELEMETRY_OPTOUT: '1' + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.401 - - name: Restore test projects - shell: pwsh - run: | - Get-ChildItem test -Filter *.csproj -Recurse | ForEach-Object { - dotnet restore $_.FullName --locked-mode - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - - name: Restore editor CLI - run: dotnet restore src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj --locked-mode - - name: Restore headless sample - run: dotnet restore src/Samples/GalNet.Sample.Headless/GalNet.Sample.Headless.csproj --locked-mode - - name: Build editor CLI - run: dotnet build src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj --no-restore --configuration Release -p:ContinuousIntegrationBuild=true - - name: Build headless sample - run: dotnet build src/Samples/GalNet.Sample.Headless/GalNet.Sample.Headless.csproj --no-restore --configuration Release -p:ContinuousIntegrationBuild=true - - name: Test pure logic and architecture with coverage - shell: pwsh - run: | - $projects = @( - 'GalNet.Architecture.Tests', - 'GalNet.Core.Tests', - 'GalNet.Runtime.Tests', - 'GalNet.GameHost.Tests', - 'GalNet.Primitives.Builtins.Tests', - 'GalNet.Assets.Tests', - 'GalNet.Storage.FileSystem.Tests' - ) - foreach ($project in $projects) { - dotnet test "test/$project/$project.csproj" --no-restore --configuration Release --logger "trx;LogFileName=$project.trx" --results-directory artifacts/test-results --collect:"XPlat Code Coverage" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - - name: Test editor and presentation with coverage + global-json-file: global.json + - name: Run shared local CI entry shell: pwsh - run: | - $projects = @( - 'GalNet.Editor.Shared.Tests', - 'GalNet.Editor.Tests', - 'GalNet.Presentation.Tests' - ) - foreach ($project in $projects) { - dotnet test "test/$project/$project.csproj" --no-restore --configuration Release --logger "trx;LogFileName=$project.trx" --results-directory artifacts/test-results --collect:"XPlat Code Coverage" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - - name: Test integration with coverage - run: dotnet test test/GalNet.IntegrationTests/GalNet.IntegrationTests.csproj --no-restore --configuration Release --logger "trx;LogFileName=GalNet.IntegrationTests.trx" --results-directory artifacts/test-results --collect:"XPlat Code Coverage" - - name: Upload test results + run: ./scripts/ci/Invoke-CI.ps1 -Stage ${{ matrix.stage }} -OutputRoot out/ci/${{ matrix.stage }} -Coverage + - name: Upload check logs and test results if: always() uses: actions/upload-artifact@v4 with: - name: desktop-test-results - path: artifacts/test-results + name: ci-${{ matrix.stage }} + path: out/ci/${{ matrix.stage }} if-no-files-found: warn - - platform-solution-build: - if: github.event_name == 'workflow_dispatch' - runs-on: windows-latest - timeout-minutes: 45 - env: - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_NOLOGO: '1' - AVALONIA_TELEMETRY_OPTOUT: '1' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.401 - - name: Restore workloads - run: dotnet workload restore GalNet.slnx --version 10.0.401.1 - - name: Restore locked dependencies - run: dotnet restore GalNet.slnx --locked-mode - - name: Build full solution - run: dotnet build GalNet.slnx --no-restore --configuration Release -p:ContinuousIntegrationBuild=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0026739d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: Local-only candidate version (no upload) + required: true + default: 0.1.0-dev.1 + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate-source: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Require the release tag to belong to master + if: github.event_name == 'push' + env: + RELEASE_TAG: ${{ github.ref_name }} + shell: pwsh + run: | + git fetch --no-tags origin '+refs/heads/master:refs/remotes/origin/master' + if ($LASTEXITCODE -ne 0) { throw 'Could not fetch the release main branch.' } + ./scripts/ci/Test-ReleaseRef.ps1 -Tag $env:RELEASE_TAG + + build: + needs: validate-source + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + - name: Run the same CI checks as local development + shell: pwsh + run: ./scripts/ci/Invoke-CI.ps1 -Stage All -OutputRoot out/ci/release + - name: Build and validate the local release + env: + CANDIDATE_VERSION: ${{ inputs.version }} + shell: pwsh + run: | + if ('${{ github.event_name }}' -eq 'push') { + ./scripts/release/Release.ps1 -Tag $env:GITHUB_REF_NAME -SampleMode R2R + } else { + ./scripts/release/Release.ps1 -Version $env:CANDIDATE_VERSION -SampleMode R2R + } + - name: Upload sealed release files + uses: actions/upload-artifact@v4 + with: + name: release-files + path: | + out/release/*/nuget/*.nupkg + out/release/*/assets/*.zip + out/release/*/SHA256SUMS + out/release/*/release-index.json + if-no-files-found: error + - name: Upload validation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-evidence + path: | + out/ci/release + out/release/.work/**/logs + out/release/.work/**/package-validation.json + include-hidden-files: true + if-no-files-found: warn + + publish: + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: build + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + - uses: actions/download-artifact@v4 + with: + name: release-files + path: out/release + - name: Upload the verified files without rebuilding + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + ./scripts/release/Push.ps1 -Index "out/release/$version/release-index.json" + - name: Preserve upload journal + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-push-journal + path: out/release/*/push-state.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index c4311db1..48cd1eed 100644 --- a/.gitignore +++ b/.gitignore @@ -461,6 +461,13 @@ riderModule.iml !.vscode/extensions.json # Project-specific +/artifacts/benchmarks/ +/out/ +!/scripts/release/ +!/scripts/release/** +!/src/Editor/GalNet.Editor/Properties/PublishProfiles/WindowsReadyToRun.pubxml +!/src/Samples/GalNet.Sample.Avalonia/Properties/PublishProfiles/WindowsReadyToRun.pubxml +!/src/Editor/GalNet.Editor.Headless/Properties/PublishProfiles/WindowsReadyToRun.pubxml /output/ dev/* .galnet/ diff --git a/Directory.Build.props b/Directory.Build.props index 0495e51d..973ef183 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,5 +9,22 @@ true true true + $(DefineConstants);BENCHMARK + + + + obj/benchmark/ + bin/benchmark/ + $(MSBuildProjectDirectory)/obj/benchmark/packages.lock.json + + + + obj/publish/WindowsReadyToRun/ + bin/publish/WindowsReadyToRun/ + $(MSBuildProjectDirectory)/packages.lock.WindowsReadyToRun.json + + + + $(DefaultItemExcludes);$(MSBuildProjectDirectory)/obj/**;$(MSBuildProjectDirectory)/bin/** diff --git a/Directory.Build.targets b/Directory.Build.targets index cf2a329b..dad3fec8 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -6,4 +6,22 @@ false + + + + + + + + + diff --git a/GalNet.slnx b/GalNet.slnx index aea9aac8..0e8ee1ce 100644 --- a/GalNet.slnx +++ b/GalNet.slnx @@ -6,6 +6,15 @@ + + + + + + + + + @@ -14,20 +23,11 @@ + + - - - - - - - - - - - @@ -41,6 +41,7 @@ + @@ -51,6 +52,8 @@ + + diff --git a/GameTestCase/Assets/Effects/Default/Bloom.sksl b/GameTestCase/Assets/Effects/Default/Bloom.sksl new file mode 100644 index 00000000..5b24459a --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Bloom.sksl @@ -0,0 +1,54 @@ +/* +@gal.effect v=1 +@input source +@targets layer,scenePost + +@param strength + uniform: strength + type: float + default: 0 + range: 0..5 + animatable: true + +@param radius + uniform: radius + type: float + default: 16 + range: 0..64 + animatable: true + +@param threshold + uniform: threshold + type: float + default: 0.8 + range: 0..1 + animatable: true + +@param softKnee + uniform: softKnee + type: float + default: 0.1 + range: 0.001..1 + animatable: true +*/ +uniform shader source; +uniform float strength, radius, threshold, softKnee; +half4 main(float2 p) { + half4 c = source.eval(p); + float3 glow = float3(0); + float weights = 0.0; + for (int y = -2; y <= 2; y++) { + for (int x = -2; x <= 2; x++) { + float3 rgb = float3(source.eval(p + float2(float(x),float(y))*radius*0.5).rgb); + float luma = dot(rgb,float3(0.2126,0.7152,0.0722)); + float soft = clamp(luma-threshold+softKnee,0.0,2.0*softKnee); + soft = soft*soft/(4.0*max(softKnee,0.001)); + float w = exp(-float(x*x+y*y)/4.5); + glow += rgb * (max(luma-threshold,soft)/max(luma,0.0001)) * w; + weights += w; + } + } + // Keep premultiplied RGB inside alpha for partially transparent layers. + float3 room = max(float3(c.a)-float3(c.rgb),float3(0)); + return half4(half3(float3(c.rgb)+room*clamp(glow*strength/max(weights,0.001),0.0,1.0)),c.a); +} diff --git a/GameTestCase/Assets/Effects/Default/Bloom.sksl.meta b/GameTestCase/Assets/Effects/Default/Bloom.sksl.meta new file mode 100644 index 00000000..89d3b0f4 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Bloom.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/Bloom.sksl","id":"b1000000000000000000000000000001","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/Blur.sksl b/GameTestCase/Assets/Effects/Default/Blur.sksl new file mode 100644 index 00000000..fd198451 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Blur.sksl @@ -0,0 +1,34 @@ +/* +@gal.effect v=1 +@input source +@targets layer,scenePost + +@param radius + uniform: radius + type: float + default: 0 + range: 0..64 + animatable: true + +@param direction + uniform: direction + type: vector2 + default: [1,0] + animatable: false +*/ +uniform shader source; +uniform float radius; +uniform float2 direction; +half4 main(float2 p) { + if (radius <= 0.001) return source.eval(p); + float4 sum = float4(source.eval(p)); + float weights = 1.0; + for (int i = 1; i <= 12; i++) { + float t = float(i) / 12.0; + float w = exp(-4.5 * t * t); + float2 d = direction * radius * t; + sum += (float4(source.eval(p+d)) + float4(source.eval(p-d))) * w; + weights += 2.0 * w; + } + return half4(sum / weights); +} diff --git a/GameTestCase/Assets/Effects/Default/Blur.sksl.meta b/GameTestCase/Assets/Effects/Default/Blur.sksl.meta new file mode 100644 index 00000000..401a9e5f --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Blur.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/Blur.sksl","id":"b1000000000000000000000000000002","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/ColorGrade.sksl b/GameTestCase/Assets/Effects/Default/ColorGrade.sksl new file mode 100644 index 00000000..3ef9be59 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/ColorGrade.sksl @@ -0,0 +1,45 @@ +/* +@gal.effect v=1 +@input source +@targets layer,scenePost + +@param exposure + uniform: exposure + type: float + default: 0 + range: -4..4 + animatable: true + +@param saturation + uniform: saturation + type: float + default: 1 + range: 0..2 + animatable: true + +@param contrast + uniform: contrast + type: float + default: 1 + range: 0..2 + animatable: true + +@param temperature + uniform: temperature + type: float + default: 0 + range: -1..1 + animatable: true +*/ +uniform shader source; +uniform float exposure, saturation, contrast, temperature; +half4 main(float2 p) { + half4 c = source.eval(p); + if (c.a <= 0.0) return c; + float3 rgb = float3(c.rgb) / float(c.a); + rgb *= exp2(exposure); + rgb = mix(float3(dot(rgb,float3(0.2126,0.7152,0.0722))),rgb,saturation); + rgb = (rgb - 0.5) * contrast + 0.5; + rgb += temperature * float3(0.12,0.025,-0.12); + return half4(half3(clamp(rgb,0.0,1.0) * c.a), c.a); +} diff --git a/GameTestCase/Assets/Effects/Default/ColorGrade.sksl.meta b/GameTestCase/Assets/Effects/Default/ColorGrade.sksl.meta new file mode 100644 index 00000000..676a1d0a --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/ColorGrade.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/ColorGrade.sksl","id":"b1000000000000000000000000000004","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/DepthBlur.sksl b/GameTestCase/Assets/Effects/Default/DepthBlur.sksl new file mode 100644 index 00000000..d4879d46 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/DepthBlur.sksl @@ -0,0 +1,51 @@ +/* +@gal.effect v=1 +@input source +@targets layer + +@param focusZ + uniform: focusZ + type: float + default: 0 + animatable: true + +@param focusRange + uniform: focusRange + type: float + default: 0 + range: 0..100000 + animatable: true + +@param falloff + uniform: falloff + type: float + default: 0.1 + range: 0..64 + animatable: true + +@param maxRadius + uniform: maxRadius + type: float + default: 24 + range: 0..64 + animatable: true +*/ +uniform shader source; +uniform float layerZ; +uniform float focusZ, focusRange, falloff, maxRadius; +half4 main(float2 p) { + float r = clamp((abs(layerZ-focusZ)-focusRange)*falloff,0.0,maxRadius); + if (r <= 0.001) return source.eval(p); + float4 sum = float4(source.eval(p)); + float weights = 1.0; + // A dense disk avoids the repeated-image grid produced by widely spaced square taps. + for (int i = 1; i <= 48; i++) { + float t = float(i)/48.0; + float angle = float(i)*2.39996323; + float2 offset = float2(cos(angle),sin(angle))*sqrt(t)*r; + float w = exp(-4.5*t); + sum += float4(source.eval(p+offset))*w; + weights += w; + } + return half4(sum/weights); +} diff --git a/GameTestCase/Assets/Effects/Default/DepthBlur.sksl.meta b/GameTestCase/Assets/Effects/Default/DepthBlur.sksl.meta new file mode 100644 index 00000000..17f3965e --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/DepthBlur.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/DepthBlur.sksl","id":"b1000000000000000000000000000005","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/MaskReveal.sksl b/GameTestCase/Assets/Effects/Default/MaskReveal.sksl new file mode 100644 index 00000000..345b8612 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/MaskReveal.sksl @@ -0,0 +1,33 @@ +/* +@gal.effect v=1 +@input source +@targets layer + +@param progress + uniform: progress + type: float + default: 0 + range: 0..1 + animatable: true + +@param softness + uniform: softness + type: float + default: 0.05 + range: 0.001..1 + animatable: true + +@texture mask + uniform: mask + required: true +*/ +uniform shader source; +uniform shader mask; +uniform float progress, softness; +half4 main(float2 p) { + if (progress <= 0.0) return half4(0); + if (progress >= 1.0) return source.eval(p); + float value = dot(float3(mask.eval(p).rgb),float3(0.2126,0.7152,0.0722)); + float edge = mix(-softness,1.0+softness,progress); + return source.eval(p)*half(1.0-smoothstep(edge-softness,edge+softness,value)); +} diff --git a/GameTestCase/Assets/Effects/Default/MaskReveal.sksl.meta b/GameTestCase/Assets/Effects/Default/MaskReveal.sksl.meta new file mode 100644 index 00000000..4690a920 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/MaskReveal.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/MaskReveal.sksl","id":"b1000000000000000000000000000008","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/Reveal.sksl b/GameTestCase/Assets/Effects/Default/Reveal.sksl new file mode 100644 index 00000000..819f8dd6 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Reveal.sksl @@ -0,0 +1,66 @@ +/* +@gal.effect v=1 +@input source +@targets layer + +@param progress + uniform: progress + type: float + default: 0 + range: 0..1 + animatable: true + +@param softness + uniform: softness + type: float + default: 0.05 + range: 0.001..1 + animatable: true + +@param mode + uniform: mode + type: enum + default: Wipe + options: Wipe,Radial,Noise,Blinds + animatable: false + +@param angle + uniform: angle + type: float + default: 0 + range: -360..360 + animatable: true + +@param count + uniform: count + type: float + default: 12 + range: 1..256 + animatable: true + +@param center + uniform: center + type: vector2 + default: [0.5,0.5] + animatable: false +*/ +uniform shader source; +uniform float2 size, center; +uniform float progress, softness, mode, angle, count; +float hash(float2 v) { return fract(sin(dot(v,float2(127.1,311.7)))*43758.5453); } +half4 main(float2 p) { + if (progress <= 0.0) return half4(0); + if (progress >= 1.0) return source.eval(p); + float2 uv = p/max(size,float2(1)); + float a = angle*0.01745329252; + float2 axis = float2(cos(a),sin(a)); + float value = dot(uv-0.5,axis)/max(abs(axis.x)+abs(axis.y),0.001)+0.5; + if (mode > 0.5 && mode < 1.5) { + float farthest = length(max(center,1.0-center)); + value = length(uv-center)/max(farthest,0.001); + } else if (mode > 1.5 && mode < 2.5) value = hash(floor(uv*160.0)); + else if (mode > 2.5) value = fract(value*count); + float edge = mix(-softness,1.0+softness,progress); + float alpha = 1.0-smoothstep(edge-softness,edge+softness,value); + return source.eval(p)*half(alpha); +} diff --git a/GameTestCase/Assets/Effects/Default/Reveal.sksl.meta b/GameTestCase/Assets/Effects/Default/Reveal.sksl.meta new file mode 100644 index 00000000..cc96c17d --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Reveal.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/Reveal.sksl","id":"b1000000000000000000000000000007","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/SpeedLines.sksl b/GameTestCase/Assets/Effects/Default/SpeedLines.sksl new file mode 100644 index 00000000..00960294 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/SpeedLines.sksl @@ -0,0 +1,102 @@ +/* +@gal.effect v=1 +@input source +@targets layer + +@param time + uniform: time + type: float + default: 0 + animatable: true + +@param speed + uniform: speed + type: float + default: 2 + range: -20..20 + animatable: true + +@param density + uniform: density + type: float + default: 64 + range: 4..256 + animatable: true + +@param strength + uniform: strength + type: float + default: 1 + range: 0..1 + animatable: true + +@param flicker + uniform: flicker + type: float + default: 0.3 + range: 0..1 + animatable: true + +@param mode + uniform: mode + type: enum + default: Radial + options: Radial,Directional + animatable: false + +@param angle + uniform: angle + type: float + default: 0 + range: -360..360 + animatable: true + +@param center + uniform: center + type: vector2 + default: [0.5,0.5] + animatable: false + +@param color + uniform: color + type: color + default: #FFFFFF + animatable: false +*/ +uniform shader source; +uniform float2 size, center; +uniform float time, speed, density, strength, flicker, mode, angle; +uniform float4 color; +float hash(float x) { return fract(sin(x*127.1+311.7)*43758.5453); } +half4 main(float2 p) { + float2 q = (p-center*size)/max(min(size.x,size.y),1.0); + float stroke; + if (mode < 0.5) { + float angular = (atan(q.y,q.x)+3.14159265)/6.2831853*floor(density); + float sector = floor(angular); + float noise = hash(sector); + float width = mix(0.035,0.14,hash(sector+71.0)); + float across = 1.0-smoothstep(width,width+0.045,abs(fract(angular)-0.5)); + float r = length(q); + float head = fract(time*speed*0.25+noise)*1.6; + float along = smoothstep(head-0.8,head-0.65,r)*(1.0-smoothstep(head,head+0.12,r)); + stroke = across * along * smoothstep(0.08+noise*0.1,0.36,r); + } else { + float a = angle*0.01745329252; + float2 axis = float2(cos(a),sin(a)); + float coordinate = dot(q,float2(-axis.y,axis.x))*density; + float lane = floor(coordinate); + float noise = hash(lane); + float width = mix(0.035,0.14,hash(lane+71.0)); + float aa = max(0.02,0.5*density/max(min(size.x,size.y),1.0)); + float across = 1.0-smoothstep(max(0.0,width-aa),width+aa,abs(fract(coordinate)-0.5)); + float u = fract((dot(q,axis)-time*speed)*0.5+noise); + float along = smoothstep(0.1,0.2,u)*(1.0-smoothstep(0.8,0.9,u)); + stroke = across * along; + } + float blink = 1.0-flicker*(0.5+0.5*sin(time*37.0)); + float alpha = clamp(stroke*blink*strength*color.a,0.0,1.0); + // A shader-generated overlay; the transparent source is an explicit layer carrier. + half4 inputColor = source.eval(p); + return half4(half3(color.rgb*alpha),half(alpha)) + inputColor * half(1.0-alpha); +} diff --git a/GameTestCase/Assets/Effects/Default/SpeedLines.sksl.meta b/GameTestCase/Assets/Effects/Default/SpeedLines.sksl.meta new file mode 100644 index 00000000..d5f20ef3 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/SpeedLines.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/SpeedLines.sksl","id":"b1000000000000000000000000000006","type":"effect-program"} diff --git a/GameTestCase/Assets/Effects/Default/Vignette.sksl b/GameTestCase/Assets/Effects/Default/Vignette.sksl new file mode 100644 index 00000000..14a249a7 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Vignette.sksl @@ -0,0 +1,42 @@ +/* +@gal.effect v=1 +@input source +@targets layer,scenePost + +@param strength + uniform: strength + type: float + default: 0 + range: 0..1 + animatable: true + +@param radius + uniform: radius + type: float + default: 0.45 + range: 0..2 + animatable: true + +@param softness + uniform: softness + type: float + default: 0.4 + range: 0.001..2 + animatable: true + +@param center + uniform: center + type: vector2 + default: [0.5,0.5] + animatable: false +*/ +uniform shader source; +uniform float2 size; +uniform float strength, radius, softness; +uniform float2 center; +half4 main(float2 p) { + half4 c = source.eval(p); + float d = length((p / max(size,float2(1))) - center); + float fade = smoothstep(radius, radius + max(softness,0.001), d); + return half4(c.rgb * half(1.0 - strength * fade), c.a); +} diff --git a/GameTestCase/Assets/Effects/Default/Vignette.sksl.meta b/GameTestCase/Assets/Effects/Default/Vignette.sksl.meta new file mode 100644 index 00000000..a6610d51 --- /dev/null +++ b/GameTestCase/Assets/Effects/Default/Vignette.sksl.meta @@ -0,0 +1 @@ +{"path":"Effects/Default/Vignette.sksl","id":"b1000000000000000000000000000003","type":"effect-program"} diff --git a/GameTestCase/Graph/groups/ending.rawgalgroup b/GameTestCase/Graph/groups/ending.rawgalgroup index 075115da..c55b9290 100644 --- a/GameTestCase/Graph/groups/ending.rawgalgroup +++ b/GameTestCase/Graph/groups/ending.rawgalgroup @@ -2,8 +2,383 @@ "version": 2, "kind": "Raw", "entries": [ - { "id": "stop-snow", "type": "particle.stop", "parameters": { "instanceId": "route-particles" } }, - { "id": "hide-portrait", "type": "layer.hide", "parameters": { "handleId": "teacher" } }, - { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.ending.entry.line.content" } } + { + "id": "stop-snow", + "type": "particle.stop", + "parameters": { + "instanceId": "route-particles" + } + }, + { + "id": "hide-portrait", + "type": "layer.hide", + "parameters": { + "handleId": "teacher" + } + }, + { + "id": "hide-toy-backdrop", + "type": "layer.hide", + "parameters": { + "handleId": "shader-toy-background" + } + }, + { + "id": "visual-background", + "type": "layer.show", + "parameters": { + "handleId": "visual-bg", + "assetId": "a1000000000000000000000000000001", + "z": 0, + "displayMode": "Fill" + } + }, + { + "id": "visual-portrait", + "type": "variants.show", + "parameters": { + "handleId": "visual-hero", + "assetId": "a1000000000000000000000000000020", + "selection": { + "expression": "shy" + }, + "transform": { + "x": 420, + "y": 80 + }, + "z": 10 + } + }, + { + "id": "visual-intro", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.visual-intro.content" + } + }, + { + "id": "visual-bloom", + "type": "effect.apply", + "parameters": { + "instanceId": "visual-bloom", + "program": "b1000000000000000000000000000001", + "parameters": { + "strength": 2, + "radius": 24 + } + } + }, + { + "id": "visual-vignette", + "type": "effect.apply", + "parameters": { + "instanceId": "visual-vignette", + "program": "b1000000000000000000000000000003", + "parameters": { + "strength": 0.8, + "radius": 0.2 + } + } + }, + { + "id": "visual-grade", + "type": "effect.apply", + "parameters": { + "instanceId": "visual-grade", + "program": "b1000000000000000000000000000004", + "parameters": { + "temperature": 0.4, + "saturation": 0.8 + } + } + }, + { + "id": "visual-grade-line", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.visual-grade.content" + } + }, + { + "id": "stop-visual-bloom", + "type": "effect.stop", + "parameters": { + "instanceId": "visual-bloom" + } + }, + { + "id": "stop-visual-vignette", + "type": "effect.stop", + "parameters": { + "instanceId": "visual-vignette" + } + }, + { + "id": "stop-visual-grade", + "type": "effect.stop", + "parameters": { + "instanceId": "visual-grade" + } + }, + { + "id": "depth-bg", + "type": "effect.apply", + "parameters": { + "instanceId": "depth-bg", + "targetHandleId": "visual-bg", + "program": "b1000000000000000000000000000005", + "parameters": { + "focusZ": 10, + "falloff": 2, + "maxRadius": 24 + } + } + }, + { + "id": "depth-hero", + "type": "effect.apply", + "parameters": { + "instanceId": "depth-hero", + "targetHandleId": "visual-hero", + "program": "b1000000000000000000000000000005", + "parameters": { + "focusZ": 10, + "falloff": 2, + "maxRadius": 24 + } + } + }, + { + "id": "visual-depth", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.visual-depth.content" + } + }, + { + "id": "rack-focus", + "type": "timeline.play", + "parameters": { + "timeline": { + "playbackHandleId": "visual-rack-focus", + "frameRate": 60, + "durationFrames": 60, + "blocking": true, + "skippable": true, + "tracks": [ + { + "handleId": "depth-bg", + "property": "focusZ", + "keys": [ + { + "frame": 0, + "value": 10 + }, + { + "frame": 60, + "value": 0 + } + ] + }, + { + "handleId": "depth-hero", + "property": "focusZ", + "keys": [ + { + "frame": 0, + "value": 10 + }, + { + "frame": 60, + "value": 0 + } + ] + } + ], + "events": [] + } + } + }, + { + "id": "visual-focus", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.visual-focus.content" + } + }, + { + "id": "stop-depth-bg", + "type": "effect.stop", + "parameters": { + "instanceId": "depth-bg" + } + }, + { + "id": "stop-depth-hero", + "type": "effect.stop", + "parameters": { + "instanceId": "depth-hero" + } + }, + { + "id": "jump", + "type": "preset.motion", + "parameters": { + "playbackHandleId": "visual-jump", + "handleId": "visual-hero", + "motion": "Jump", + "distance": 24, + "durationFrames": 24 + } + }, + { + "id": "nod", + "type": "preset.motion", + "parameters": { + "playbackHandleId": "visual-nod", + "handleId": "visual-hero", + "motion": "Nod", + "rotationDegrees": 8, + "durationFrames": 30 + } + }, + { + "id": "shake", + "type": "preset.shake", + "parameters": { + "playbackHandleId": "visual-shake", + "handleId": "visual-hero", + "amplitude": 12, + "angle": 15, + "durationFrames": 36 + } + }, + { + "id": "radial-lines", + "type": "preset.speedLines", + "parameters": { + "playbackHandleId": "visual-radial-lines", + "program": "b1000000000000000000000000000006", + "parameters": { + "mode": "Radial", + "density": 80, + "color": "#FFFFFF" + }, + "durationFrames": 90 + } + }, + { + "id": "directional-lines", + "type": "preset.speedLines", + "parameters": { + "playbackHandleId": "visual-directional-lines", + "program": "b1000000000000000000000000000006", + "parameters": { + "mode": "Directional", + "angle": 0, + "color": "#FFFFFF" + }, + "speed": 5, + "durationFrames": 90 + } + }, + { + "id": "impact", + "type": "preset.impact", + "parameters": { + "playbackHandleId": "visual-impact", + "program": "b1000000000000000000000000000006", + "handleId": "visual-hero", + "amplitude": 18, + "durationFrames": 48 + } + }, + { + "id": "visual-impact", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.visual-impact.content" + } + }, + { + "id": "push", + "type": "preset.motion", + "parameters": { + "playbackHandleId": "visual-push", + "handleId": "visual-bg", + "motion": "Push", + "scale": 1.1, + "anchorX": 200, + "distance": 0, + "durationFrames": 60 + } + }, + { + "id": "wipe", + "type": "transition.reveal", + "parameters": { + "playbackHandleId": "visual-wipe", + "oldHandleId": "visual-bg", + "newHandleId": "visual-bg2", + "assetId": "a1000000000000000000000000000002", + "program": "b1000000000000000000000000000007", + "mode": "Wipe", + "durationFrames": 60 + } + }, + { + "id": "blur", + "type": "transition.blur", + "parameters": { + "playbackHandleId": "visual-blur", + "oldHandleId": "visual-bg2", + "newHandleId": "visual-bg3", + "assetId": "a1000000000000000000000000000001", + "blurProgram": "b1000000000000000000000000000002", + "durationFrames": 60 + } + }, + { + "id": "mask", + "type": "transition.reveal", + "parameters": { + "playbackHandleId": "visual-mask", + "oldHandleId": "visual-bg3", + "newHandleId": "visual-bg4", + "assetId": "a1000000000000000000000000000002", + "program": "b1000000000000000000000000000008", + "mode": "Mask", + "maskAsset": "a1000000000000000000000000000003", + "durationFrames": 60 + } + }, + { + "id": "visual-transitions", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.visual-transitions.content" + } + }, + { + "id": "hide-visual-hero", + "type": "layer.hide", + "parameters": { + "handleId": "visual-hero" + } + }, + { + "id": "line", + "type": "dialogue.text", + "parameters": { + "speaker": "guide", + "content": "group.ending.entry.line.content" + } + } ] } diff --git a/GameTestCase/Graph/groups/intro.rawgalgroup b/GameTestCase/Graph/groups/intro.rawgalgroup index 49bc009e..0ba89034 100644 --- a/GameTestCase/Graph/groups/intro.rawgalgroup +++ b/GameTestCase/Graph/groups/intro.rawgalgroup @@ -27,19 +27,21 @@ { "id": "video-background", "type": "video.play", "parameters": { "asset": "a1000000000000000000000000000011", "handleId": "sample-video", "blocking": "false", "transform": { "x": -160, "y": 60, "scaleX": 0.5, "scaleY": 0.5, "rotationDegrees": 10 }, "z": 5, "opacity": 0.7, "displayMode": "Uniform" } }, { "id": "intro-video-background", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-video-background.content" } }, { "id": "video-stop", "type": "video.stop", "parameters": { "handleId": "sample-video" } }, - { "id": "bgm-duck", "type": "audio.seffect.apply", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-duck", "id": "seffect.gain", "parameters": { "gain": 0.35 } } }, + { "id": "bgm-duck", "type": "audio.seffect.apply", "parameters": { "targetId": "bgm", "instanceId": "sample-bgm-duck", "id": "seffect.gain", "parameters": { "gain": 0.35 } } }, { "id": "intro-line-effect-on", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-effect-on.content" } }, { "id": "bgm-pause-for-save", "type": "audio.pause", "parameters": { "trackId": "bgm" } }, { "id": "audio-save-checkpoint", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.audio-save-checkpoint.content" } }, { "id": "bgm-resume-after-save", "type": "audio.resume", "parameters": { "trackId": "bgm" } }, - { "id": "bgm-unduck", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-duck" } }, + { "id": "bgm-unduck", "type": "audio.seffect.stop", "parameters": { "targetId": "bgm", "instanceId": "sample-bgm-duck" } }, { "id": "intro-line-effect-off", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-effect-off.content" } }, - { "id": "bgm-low-pass", "type": "audio.seffect.apply", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-low-pass", "id": "seffect.lowPass", "parameters": { "cutoffHz": 900, "attenuationDb": -20 } } }, + { "id": "bgm-room", "type": "audio.seffect.apply", "parameters": { "scope": "bus", "targetId": "bgm", "instanceId": "sample-room", "id": "seffect.reverb", "parameters": { "roomSize": 0.65, "decaySeconds": 1.8, "damping": 0.4, "mix": 0.25 } } }, + { "id": "bgm-low-pass", "type": "audio.seffect.apply", "parameters": { "targetId": "bgm", "instanceId": "sample-bgm-low-pass", "id": "seffect.lowPass", "parameters": { "cutoffHz": 900, "q": 0.7071 } } }, { "id": "intro-line-low-pass", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-low-pass.content" } }, - { "id": "bgm-stop-low-pass", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-low-pass" } }, - { "id": "bgm-high-pass", "type": "audio.seffect.apply", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-high-pass", "id": "seffect.highPass", "parameters": { "cutoffHz": 1200, "attenuationDb": -20 } } }, + { "id": "bgm-stop-low-pass", "type": "audio.seffect.stop", "parameters": { "targetId": "bgm", "instanceId": "sample-bgm-low-pass" } }, + { "id": "bgm-high-pass", "type": "audio.seffect.apply", "parameters": { "targetId": "bgm", "instanceId": "sample-bgm-high-pass", "id": "seffect.highPass", "parameters": { "cutoffHz": 1200, "q": 0.7071 } } }, { "id": "intro-line-high-pass", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-high-pass.content" } }, - { "id": "bgm-stop-high-pass", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-high-pass" } }, + { "id": "bgm-stop-high-pass", "type": "audio.seffect.stop", "parameters": { "targetId": "bgm", "instanceId": "sample-bgm-high-pass" } }, + { "id": "bgm-room-stop", "type": "audio.seffect.stop", "parameters": { "scope": "bus", "targetId": "bgm", "instanceId": "sample-room" } }, { "id": "portrait-jitter", "type": "animation.animate", "parameters": { "playbackHandleId": "portrait-jitter", "handleId": "teacher", "property": "transform.y", "from": 0, "to": 12, "duration": 0.06, "curve": "EaseInOut", "blocking": "false", "skippable": "false", "loopMode": "PingPong", "blendMode": "Additive" } }, { "id": "variants-during-jitter", "type": "variants.select", "parameters": { "handleId": "teacher", "dimension": "expression", "value": "wink" } }, { "id": "intro-line-2", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-2.content" } }, diff --git a/GameTestCase/I18n/en-US/groups/ending.json b/GameTestCase/I18n/en-US/groups/ending.json index 49a07596..ad00cc98 100644 --- a/GameTestCase/I18n/en-US/groups/ending.json +++ b/GameTestCase/I18n/en-US/groups/ending.json @@ -1,3 +1,9 @@ { + "group.ending.entry.visual-intro.content": "The default visual library is next: editable project shaders and compiled performance presets.", + "group.ending.entry.visual-grade.content": "Bloom, vignette and warm color grading can be combined, then stopped independently.", + "group.ending.entry.visual-depth.content": "Layer Z depth of field: the portrait at Z=10 is sharp and the background at Z=0 is blurred.", + "group.ending.entry.visual-focus.content": "The focus moved to Z=0: the background is now sharp and the portrait is blurred.", + "group.ending.entry.visual-impact.content": "Jump, nod, character shake, animated radial and directional lines, then a combined impact. Every temporary overlay is cleaned up.", + "group.ending.entry.visual-transitions.content": "A directional soft wipe, two-pass blur crossfade, and image-mask dissolve. This example uses the brightness of an ordinary project image as the mask.", "group.ending.entry.line.content": "Runtime smoke test complete." } diff --git a/GameTestCase/I18n/zh-CN/groups/ending.json b/GameTestCase/I18n/zh-CN/groups/ending.json index 7ef5ff46..92399c28 100644 --- a/GameTestCase/I18n/zh-CN/groups/ending.json +++ b/GameTestCase/I18n/zh-CN/groups/ending.json @@ -1,3 +1,9 @@ { + "group.ending.entry.visual-intro.content": "接下来展示默认视觉库:可编辑的项目 shader,以及编译为时间线的演出预设。", + "group.ending.entry.visual-grade.content": "Bloom、暗角和暖色调色可以叠加,也可以分别停止。", + "group.ending.entry.visual-depth.content": "按图层 Z 景深:Z=10 的人物清晰,Z=0 的背景模糊。", + "group.ending.entry.visual-focus.content": "焦点移到 Z=0 后,背景变清晰,人物变模糊。", + "group.ending.entry.visual-impact.content": "依次展示跳跃、点头、角色震动、动态径向与定向集中线,最后组合冲击;临时叠加图层会自动清理。", + "group.ending.entry.visual-transitions.content": "依次展示柔边擦除、两遍模糊交叉淡化和图片遮罩溶解;此例用普通项目图片的亮度作为遮罩。", "group.ending.entry.line.content": "运行时冒烟测试完成。" } diff --git a/GameTestCase/README.md b/GameTestCase/README.md index 42556dce..6fe3e709 100644 --- a/GameTestCase/README.md +++ b/GameTestCase/README.md @@ -1,5 +1,7 @@ # Compiled-content runtime and presentation smoke test +Both routes now finish with the default visual library showcase in `Graph/groups/ending.rawgalgroup`: bloom, vignette, grading, Z-based rack focus, character motion, synchronized shake, animated radial/directional speed lines, impact, wipe, blur and image-mask transitions. The editable default shaders live in `Assets/Effects/Default/`; see [the visual authoring manual](../docs/manual/default-visuals.md). + This Raw-authoring game exercises the compiled runtime slice: The game includes English (`en-US`, its configured default) and Simplified Chinese @@ -72,7 +74,7 @@ in `variants.show`; each image still has its own sprite ID. See From the repository root, validate and rebuild with the current project contracts: ```powershell -./scripts/build-project-contracts.ps1 -ContractsProject ./src/Samples/GalNet.Sample.Contracts/GalNet.Sample.Contracts.csproj -ProjectDirectory ./GameTestCase +./scripts/contracts/build-project-contracts.ps1 -ContractsProject ./src/Samples/GalNet.Sample.Contracts/GalNet.Sample.Contracts.csproj -ProjectDirectory ./GameTestCase dotnet run --project ./src/Editor/GalNet.Editor.Headless -- validate ./GameTestCase dotnet run --project ./src/Editor/GalNet.Editor.Headless -- build ./GameTestCase ``` @@ -107,6 +109,17 @@ intro group executes `gallery.unlock(100)`, making that item viewable after the Build and run from the repository root: +The Sample launch scripts build and deploy Sample.Contracts before compiling this +fixture, including on a fresh checkout: + +```powershell +./scripts/samples/run-avalonia-sample.ps1 +./scripts/samples/run-headless-sample.ps1 +``` + +`-SkipBuild` skips both contract deployment and content compilation. For direct +CLI use, deploy the contracts as shown above before running: + ```text dotnet run --project src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj -- build GameTestCase --output artifacts/sample-build dotnet run --project src/Samples/GalNet.Sample.Headless/GalNet.Sample.Headless.csproj -- artifacts/sample-build @@ -122,7 +135,7 @@ Native decode tests are opt-in: set `GALNET_VIDEO_ASSETS` to the absolute `GameT 本目录是当前格式的作者内容,entry 集合由独立 Sample.Contracts 项目声明。先从仓库根构建并部署工具契约,再使用 CLI 或打开 Editor: ```powershell -./scripts/build-project-contracts.ps1 -ContractsProject ./src/Samples/GalNet.Sample.Contracts/GalNet.Sample.Contracts.csproj -ProjectDirectory ./GameTestCase +./scripts/contracts/build-project-contracts.ps1 -ContractsProject ./src/Samples/GalNet.Sample.Contracts/GalNet.Sample.Contracts.csproj -ProjectDirectory ./GameTestCase dotnet run --project ./src/Editor/GalNet.Editor.Headless -- schema list ./GameTestCase dotnet run --project ./src/Editor/GalNet.Editor.Headless -- validate ./GameTestCase ``` @@ -137,3 +150,5 @@ Contracts/ 是本机构建产物,不提交 DLL;游戏 Sample 正常引用同 intro 在 gain 效果启用后新增暂停 BGM 的对白检查点。运行到提示时新建存档,然后读档:背景/立绘与 gain 轨道效果应恢复,BGM 保持暂停;推进执行 audio.resume 后从头播放,并保留该效果直到后续 stop 指令。使用新存档验证,旧格式文件不做迁移。 自动回归 `ModularSaveRestoreTests` 直接编译该实际作者文件中的图层/音频条目,通过 FileSaveService 写入全新槽位,再由 Engine 和注册式恢复会话读回;后端使用可观察的测试 host。实际 LibVLC 声音效果仍需按上面的原生媒体方式人工检查。 + +Audio uses project section v2 (`audioBuses` / `busId`). The intro combines real track low/high-pass filters with bus `seffect.reverb`; effect entries use `scope` / `targetId`. The default Windows x64 host uses miniaudio; videos remain on LibVLC. diff --git a/GameTestCase/settings.json b/GameTestCase/settings.json index 309da3d9..e7a41a90 100644 --- a/GameTestCase/settings.json +++ b/GameTestCase/settings.json @@ -1,13 +1,74 @@ { - "targetLocale": { - "code": "en-US" - }, - "availableLocales": [ - { - "code": "en-US" - }, - { - "code": "zh-CN" + "version": 1, + "sections": { + "galnet.project": { + "version": 2, + "data": { + "defaultVisualPrograms": { + "bloom": "b1000000000000000000000000000001", + "blur": "b1000000000000000000000000000002", + "vignette": "b1000000000000000000000000000003", + "colorGrade": "b1000000000000000000000000000004", + "depthBlur": "b1000000000000000000000000000005", + "speedLines": "b1000000000000000000000000000006", + "reveal": "b1000000000000000000000000000007", + "maskReveal": "b1000000000000000000000000000008" + }, + "targetLocale": { + "code": "en-US" + }, + "availableLocales": [ + { + "code": "en-US" + }, + { + "code": "zh-CN" + } + ], + "audioBuses": [ + { + "id": "bgm", + "name": "Settings.BgmVolume" + }, + { + "id": "sfx", + "name": "Settings.SfxVolume" + }, + { + "id": "vocal", + "name": "Settings.VoiceVolume" + } + ], + "audioTracks": [ + { + "id": "bgm", + "busId": "bgm", + "name": "Music", + "preserveDuringCatchUp": true, + "defaultGain": 0.8, + "maxVoices": 1, + "maxQueuedItems": 64 + }, + { + "id": "sfx", + "busId": "sfx", + "name": "Effects", + "preserveDuringCatchUp": false, + "defaultGain": 1, + "maxVoices": 8, + "maxQueuedItems": 0 + }, + { + "id": "vocal", + "busId": "vocal", + "name": "Voice", + "preserveDuringCatchUp": false, + "defaultGain": 1, + "maxVoices": 1, + "maxQueuedItems": 64 + } + ] + } } - ] + } } diff --git a/README.md b/README.md index 0ed1a136..9bdb47e9 100644 --- a/README.md +++ b/README.md @@ -61,20 +61,22 @@ dotnet format style GalNet.slnx --no-restore --verify-no-changes --diagnostics I 以下脚本默认编译并运行 [`GameTestCase`](GameTestCase/README.md): ```powershell -.\scripts\run-headless-sample.ps1 -.\scripts\run-avalonia-sample.ps1 +.\scripts\samples\run-headless-sample.ps1 +.\scripts\samples\run-avalonia-sample.ps1 ``` Headless 示例在终端中交互;Avalonia 示例会启动桌面窗口。两个脚本都支持通过 `-Project`、`-BuildOutput` 和 `-SkipBuild` 指定已有内容。 +构建仓库自带的 `GameTestCase` 时,脚本会先构建并部署 Sample.Contracts,再编译剧情;`-SkipBuild` 跳过这两步。通过 `-Project` 指向其他内容项目时,需先按 [Sample.Contracts 说明](src/Samples/GalNet.Sample.Contracts/README.md) 部署与玩家匹配的项目契约。 + 统计项目代码量可以运行: ```powershell -.\scripts\count-code-lines.ps1 -.\scripts\count-code-lines.ps1 -Json +.\scripts\maintenance\count-code-lines.ps1 +.\scripts\maintenance\count-code-lines.ps1 -Json ``` -脚本按编辑器、测试、文档和其他源码分组,显示文件数、物理行数和非空行数。默认识别 `.cs`、`.xaml`、`.axaml` 及常见源码扩展,并跳过构建产物、依赖、缓存和 agent 工具目录。 +脚本按编辑器、测试、文档和其他源码分组,显示文件数、物理行数和非空行数。默认识别 `.cs`、`.xaml`、`.axaml` 及常见源码扩展,并跳过构建产物、依赖、缓存和 agent 工具目录。其他脚本按 contracts、samples、performance、maintenance、release 分类,见 [scripts/README.md](scripts/README.md)。 ## 开发流程 diff --git a/docs/README.md b/docs/README.md index e9f03c09..eed785c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ `spec/` 描述已实现的模块契约和当前限制。新增或修改功能后,应更新其唯一对应页面,而不是另建设计或 ADR 文档。 - [架构与程序集边界](spec/architecture.md) +- [CPU 性能事件](spec/cpu-benchmark.md) - [运行时与存档](spec/runtime.md) - [条目类型与项目契约](spec/entry-types.md) - [资源管理](spec/assets.md) @@ -26,6 +27,12 @@ ## 二次开发手册 +- [内容分包与资源补丁](manual/content-packaging.md):单图章节启动、依赖闭包、剧情分包和独立资源覆盖矩阵。 +- [Track / Bus 音频混音](manual/audio-mixing.md) +- [跨平台后端接入](manual/cross-platform-backends.md):原生媒体构建、包内 PAK、缓存目录、设备生命周期及移动端验收边界。 +- [NativeAOT 发布与扩展契约](manual/native-aot.md):复用 Headless Sample 发布、自检与源生成 JSON 元数据注册。 +- [默认视觉资源与演出预设](manual/default-visuals.md) + - [接入新的表现层](manual/new-presentation-layer.md):为 WPF、WinUI 或其他 UI 框架实现宿主和呈现适配的最小路径。 - [定制 Avalonia Sample](manual/customize-avalonia-sample.md):基于现有 Sample 落地项目配置、主题、原语和平台能力。 - [注册通用 action](manual/custom-actions.md):用固定双字符串原语调用项目逻辑,配置同步、异步和完整实例的运行行为。 @@ -33,3 +40,7 @@ - [编辑游戏文本与翻译](manual/game-localization.md):开发语言创作、多语言表格、语言顺序和草稿处理。 - [创建图片差分图集](manual/image-variants.md):目录导入、配置与差分预览、剧情初始选择及维度切换。 - [使用与扩展 Editor 创作工作区](manual/editor-authoring-workspace.md):常用字段、Inspector、会话草稿、问题定位与手动布局。 +- [外部工具创作与热重载](manual/external-project-authoring.md):CLI 批量应用、Agent Skill、Git 回退和 Editor 外部文件同步。 +- [测量 CPU 启动](manual/cpu-benchmark.md):编译开关、Editor/Sample 自动化基线与方法内埋点。 +- [使用 Windows ReadyToRun 发布](manual/ready-to-run.md):发布版启动优化、隔离产物与契约字节身份。 +- [发布 Editor 与 Sample](manual/release-packaging.md):本地 ZIP、GameTestCase / Galpak 整合包和 GitHub Release tag 流程。 diff --git a/docs/glossary.md b/docs/glossary.md index 74986ff2..45b07041 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -18,9 +18,10 @@ | target profile | `TargetProfileEntryCatalog` | 组合根为某个编辑或运行目标冻结的一组 Entry 模块及其 schema;它决定哪些 primitive/composite 可作者化和编译,未挂载类型不会回退到全局内置目录。 | | 原始组文件 | `.rawgalgroup` | 编辑器保存的 Group 源文件,可含 primitive 和 composite,`kind` 为 `Raw`。 | | 编译组文件 | `.galgroup` | 仅供 Runtime 加载的 Group 产物,只含 primitive,`kind` 为 `Compiled`。 | -| 运行时 | `IGameRuntime` / `GameRuntime` | 当前节点、条目位置、变量、场景实例和文本解析器的唯一状态源。 | +| 运行时 | `GalNet.Runtime.Runtime.IGameRuntime` / `GameRuntime` | 当前节点、条目位置、变量、场景实例和文本解析器的唯一状态源;执行模块借用 Core.Runtime.IGameContext。 | | 游戏引擎 | `GameEngine` | 驱动节点转移、活动 primitive 队列、batch skip、Choice 和稳定快照。 | -| 游戏视图 | `IGameView` | 单条 primitive 的定义解析、参数规范化、实例创建和 dispatch 入口。 | +| 指令分发端口 | `Core.Execution.IPrimitiveDispatcher` | 单条 primitive 的定义解析、参数规范化、实例创建和 dispatch 入口。 | +| 游戏执行上下文 | `IGameContext` | primitive/呈现借用的变量、场景与游戏能力;游标修改和读档由 Engine 管理。 | | 呈现端口 | presenter interfaces | 宿主提供的窄端口,例如 dialogue、choice、layer、animation、effect。 | | 快照 | `GameSnapshot` | 可保存和恢复的运行时位置、变量与场景状态。 | | Gallery 目录 | `GalleryCatalog` | 由资源 `.meta.gallery[]` 与冻结类型注册聚合、在发行包中生成快照的不可变类型与资源条目索引,通过 `GameContent.Gallery` 交给宿主。 | @@ -77,8 +78,10 @@ | 资源管理器 | `IAssetManager` | 从 provider 按 GUID 定位资源,按 `(GUID, CLR 类型)` 解码/缓存,并以 `AssetHandle` 引用计数释放已解析资源。 | | 资源句柄 | `AssetHandle` | 一次成功 acquire 的独立资源引用;幂等 Dispose 只释放该次引用。 | | 资源解码器 | `IAssetDecoder` | 将某种资源 type ID 的 `IGameFile` 解码为目标 CLR 类型;由宿主向资源管理器注册,不属于资源 metadata 类型注册。 | -| 游戏内容提供者 | `IGameContentProvider` | 读取 Graph、设置、I18n 与 Gallery 等特殊内容的边界;`ProjectGameContentProvider` 从开发目录聚合 metadata,`InstalledGameContentProvider` 读取导出生成的 `gallery.json`。 | +| 游戏内容提供者 | `IGameContentProvider` | 读取 Graph、设置、I18n 与 Gallery 等特殊内容的边界;`ProjectGameContentProvider` 从开发目录聚合 metadata,`InstalledGameContentProvider` 从 game.pak/localization.pak 读取内部文档与文本,兼容旧散文档。 | | pak | `.pak` | `PakBuilder` 构建的资源归档,含资源索引和数据。 | -| 分发包 | `.galpak` | ZIP 分发容器,含根特殊内容、生成的 Gallery JSON、`Assets/Paks/000-base.pak` 与 JSON manifest;可验证后解压安装。 | -| manifest | `<项目名>.galnet` | 当前 `.galpak` ZIP 根目录中唯一的 JSON manifest,描述项目与所有包的 SHA-256/大小;不是独立逻辑二进制。 | +| 分发包 | `.galpak` | ZIP 分发容器,v3 以 `pak/` 为外层,含 game.pak、localization.pak、`Assets/Paks/*-base.pak` 和空扩展目录;可验证后解压安装。剧情 PAK 随导出生成在外部,资源覆盖 PAK 独立导出。 | +| 剧情包 | story PAK | 同名 `storyPackage` Group 的独立依赖闭包减去本体资源,可由多个物理 PAK 组成;整体未安装时沿正常出口跳过。 | +| 资源覆盖包 | override PAK | 携带原资源到替换资源的映射与目标依赖;按 priority、最终 PAK hash 确定覆盖来源。 | +| manifest | `installation.json` | galpak v3 的 game.pak 内部安装清单,载荷路径相对内容根,描述项目与其余载荷 SHA-256/大小;兼容 v1/v2 的 `<项目名>.galnet` 外置清单。 | | 游戏安装 | `GameInstallation` | 将开发目录或验证并安装后的 `.galpak` 统一为内容 provider 与有序资源 provider 的宿主输入。 | diff --git a/docs/manual/audio-mixing.md b/docs/manual/audio-mixing.md new file mode 100644 index 00000000..8123a749 --- /dev/null +++ b/docs/manual/audio-mixing.md @@ -0,0 +1,63 @@ +# Track / Bus 音频混音 + +默认宿主使用 `MiniaudioAudioPlaybackHost`,原生设备/解码适配位于 `GalNet.Media.Miniaudio`,跨平台 PCM 混音与 DSP 位于 `GalNet.Media.Audio`。LibVLC 继续服务视频和 Gallery,剧情音频不再通过独立 LibVLC player 输出。 + +## 对象职责 + +- 播放实例:资源、内容 gain、`fadeIn` / `fadeOut`、`loopCount`(0 为持续重复)和 `completion`(丢弃或重新入队)。替换时默认采用旧实例自己的淡出;显式 `audio.stop.fadeOut` 可覆盖。自然结尾淡出需要解码器提供时长。 +- Track:`maxVoices`、有界队列/溢出规则、`playbackOrder`(Sequential/Random)、`repeat`(None/One/All)、轨道音量和效果、`busId`。随机选择状态随存档恢复。失败、显式停止不会按自然结束重新入队。 +- Bus:真实汇总所属 Track,再执行一次共享效果链和整组音量。轨道不指定 Bus 时直接进入 Master。 + +顺序为实例包络/gain → Track 汇总/有序 SEffect/音量 → Bus 汇总/有序 SEffect/音量 → Master 音量和限幅。Bus 效果不会复制到每个 Track 或实例。空输入仍处理效果尾音;停止音源不会清空房间混响。读档/reset 会清空旧 DSP 缓冲,不保存精确播放游标、包络时钟或尾音。 + +## 效果与混音条目 + +`audio.seffect.apply` / `audio.seffect.stop` 使用 `scope`(`track` / `bus`)、必填 `targetId` 和 `instanceId`。首次 apply 添加到链尾;相同实例/类型更新参数并保留链位置。移除平滑旁路,不重启声音。已附着的效果可在空闲时保留,新音源第一帧即经过目标链。 + +Editor 的应用表单依次选择作用范围、Track/Bus、效果类型,再编辑参数。效果类型为 ComboBox;`id` 是其内部值。新建条目自动生成隐藏的 `instanceId`,复制时生成新身份并重映射批内引用,打开和保存既有内容不会补写身份。同一条目在剧情循环中再次执行仍操作原实例。 + +停止表单先选择 Track/Bus,再选择该剧情位置之前已经挂载的实例,列表显示效果类型、创建来源和一定/可能存在。只列出直接挂载的效果;Track 不列出所属 Bus 的效果。分支、循环或自定义语义使分析不完整时显示提示,候选不要求先运行游戏。 + +“全部效果”是显式 `all: true`,此时不填写 `instanceId`;不能同时指定非空实例。省略实例不会隐含停止全部。应用效果要求非空实例身份,既有无效身份保留原文供修复。 + +```json +{"type":"audio.seffect.apply","parameters":{"scope":"bus","targetId":"environment","instanceId":"room","id":"seffect.reverb","parameters":{"roomSize":0.65,"decaySeconds":1.8,"damping":0.4,"mix":0.25}}} +``` + +剧情后续调整已有实例使用 `audio.seffect.update`。在 Editor 中选择目标和实例后会展示对应参数;只填写需要改变的字段,未指定值保持不变。运行时合并后验证全部参数,失败时保留原值;实例不存在时无操作,不改变类型、不创建新实例、不改变链位置。 + +```json +{"type":"audio.seffect.update","parameters":{"scope":"bus","targetId":"environment","instanceId":"room","parameters":{"mix":0.4}}} +``` + +```json +{"type":"audio.seffect.stop","parameters":{"scope":"bus","targetId":"environment","all":true}} +``` + +同一精确挂载身份在不同分支来自不同类型时,仍能停止,但更新表单不会猜测参数类型。既有未知类型、参数和失效引用保留,查询刷新不自动清空或改选。音频效果参数尚未接入 Timeline 动画,也没有新增音频 Timeline 事件。 + +内置效果: + +| ID | 参数 | +| --- | --- | +| `seffect.gain` | `gain`,0~1 | +| `seffect.lowPass` / `seffect.highPass` | 必填 `cutoffHz`,20~20000 Hz;`q` 默认 0.7071068,0.1~10;真实二阶滤波 | +| `seffect.reverb` | `roomSize` 默认 0.5;`decaySeconds` 默认 1.5(0.1~20 秒);`damping` 默认 0.4;`mix` 默认 0.25;其余范围 0~1 | + +混响是基础算法房间混响,空间大小、衰减、高频阻尼和干湿比例分别控制。更新参数/移除有平滑处理;移除效果会淡掉其输出,保留尾音的通常做法是仅停止音源。 + +`audio.mix.set` 使用同样的 `scope` / `targetId`,配置 `gain`(默认 1)与 `muted`(默认 false)。这是可存档的剧情混音值;玩家音量由 `AudioMixOptions` 的 Master/Buses/Tracks 独立叠加。Master 静音覆盖干声和效果尾音。未指定字段使用 DTO 默认值,并非增量 patch。 + +## 配置、构建与边界 + +默认 `galnet.project` section 版本为 2,使用 `audioBuses` / `audioTracks[].busId`。旧 section v1 被明确拒绝,需更新字段和版本;SEffect 旧 `trackId` 改为 `targetId`,旧 EQ 参数 `attenuationDb` 删除。音频轨道/效果状态表版本为 2,并新增 `builtin.audio.channels`,旧档不能混用。 + +Windows 开发构建需要 Visual Studio C++ Build Tools;源码固定 miniaudio 0.11.23,构建按目标 RID 选择 x64/ARM64 工具链,在 `obj/native//` 生成并复制 `galnet_miniaudio.dll`,发布目录也包含它。ARM64 需要安装对应 C++ 工具;脚本按目标架构检查编译器、头文件和静态运行库,并显式选择版本最高的完整 MSVC 工具集,允许 ARM64 与 x64 使用不同的并存版本。其他系统通过 `native/CMakeLists.txt` 构建,并由 `GalNetMiniaudioNativePath` 传入匹配目标的动态库;Android/iOS 的包内原生库归宿主平台项目部署,不能仅依赖普通 Content 复制。当前实机验证 Windows x64,Windows ARM64 已验证原生库交叉编译、PE 架构和导出接口。内置解码支持 WAV/MP3/Ogg Vorbis/FLAC,其他格式明确报错;当前不提供 Send、跨 Bus 路由或 Sidechain。构建示例和验收边界见[跨平台后端接入](cross-platform-backends.md)。 + +原生 ABI 当前为 2:路径统一为 UTF-8,Windows 内部转换为宽字符;设备回调使用静态 `UnmanagedCallersOnly` 入口和显式上下文,导入使用 `LibraryImport`。托管适配器和原生库必须一起更新,ABI 检查会拒绝不匹配版本;旧库缺少版本入口时同样不能运行。 + +后端构造工厂为 `AvaloniaGamePresentationOptions.CreateAudioHost(assets, files)`。新适配器的所有权交给呈现会话,借用的 materializer 仍由宿主拥有。默认后端在后台解码至每实例两秒 PCM 有界缓冲,设备回调不读文件、不等待 Runtime 锁、不发布终态订阅;终态与文件释放在后台完成。源耗尽导致的缓冲不足填静音,不能据此宣称任意存储/设备条件下绝无断音。 + +设备不可用时保留 PCM 效果目录和逻辑能力,避免设备状态使可移植存档的效果校验失效;实际 Start 通过 Failed 终态降级。 + +`PcmAudioPlaybackHost.SupportsOutputSuspension` 表示输出实现是否支持可选的 `IAudioOutputLifecycle`。宿主可在控制线程调用 `SuspendOutput()` / `ResumeOutput()`;不得从设备渲染回调调用。暂停等待设备回调结束,不改变剧情 voice 的 Pause、gain、位置或 DSP 状态;解码仍可在有界缓冲范围内预读。Miniaudio 暂停时释放设备,恢复时重新选择默认设备,失败可重试;不自动监听系统音频焦点、路由或应用事件。宿主应先处理系统音频会话,再通知后端,并单独协调剧情推进。输出暂停不等于 Engine 停止,也不生成系统终止后的恢复点。 diff --git a/docs/manual/audio-playback.md b/docs/manual/audio-playback.md new file mode 100644 index 00000000..b2aaf3f2 --- /dev/null +++ b/docs/manual/audio-playback.md @@ -0,0 +1,42 @@ +# 音频重复与轮播 + +音轨选择使用项目设置中的稳定音轨 ID,`asset` 使用音频资源 ID。以下示例中的资源 ID 需要替换为项目实际值。 + +## 重复同一首音乐 + +```json +{ + "type": "audio.play", + "parameters": { + "trackId": "bgm", + "asset": "music-resource-id", + "placement": "replaceAll", + "loopCount": 0, + "completion": "discard" + } +} +``` + +`loopCount` 为总播放次数:1 播放一次、3 连续播放三次、0 无限重复。默认值为 1。重复通过 Runtime 收到结束事件后重新启动,不保证无缝衔接。无限重复会占用当前逻辑声部,直到被停止、替换或 next 移除。 + +## 多首曲目轮播 + +在允许排队的单声部音轨上,依次添加两个 `audio.enqueue` 条目,每个条目分别设置不同的 `asset`,并都设置: + +```json +{ + "trackId": "bgm", + "asset": "music-a-resource-id", + "loopCount": 1, + "completion": "requeueTail" +} +``` + +结果按 A、B、A、B 轮播。只有一个条目时会反复播放这一项。`completion` 默认为 `discard`,表示播放次数耗尽后丢弃;`requeueTail` 每次回到队尾时重置该项的播放次数进度。排队仍受音轨容量和溢出策略限制,失败项不会重新入队。 + +## 停止、等待与恢复 + +- `audio.stop` 停止当前项,`clearQueue` 为字符串 `"true"` 时同时清空待播项;不会因 completion 再次启动。`audio.next` 丢弃当前项并切换下一项。`audio.clear` 只清空待播队列。 +- `autoBlocking` 为字符串 `"true"` 时,自动推进等待完整逻辑播放结束;无限循环或轮播要靠停止/跳过终止等待。通常让 BGM 保持默认非自动阻塞。 +- 暂停保留播放和队列;读档从当前轮开头恢复,保留已完成的有限循环轮数,不恢复音频游标或淡化时钟。 +- 淡出期间调整音量或静音仍然有效。媒体文件直接复用已有资源租约;最后一个声音、视频或 Gallery 消费者释放后才删除临时文件。 diff --git a/docs/manual/content-packaging.md b/docs/manual/content-packaging.md new file mode 100644 index 00000000..4561583e --- /dev/null +++ b/docs/manual/content-packaging.md @@ -0,0 +1,46 @@ +# 游戏内容分包与资源补丁 + +项目保持一张 Graph。章节可在入口按 save 变量分支,首页按钮传不同初始值;见[自定义 Sample](customize-avalonia-sample.md#单图章节启动参数)。 + +## 剧情包 + +选中 Group,在节点检查器填写「剧情包名称」,移开焦点提交。留空属于本体,相同名称聚合为一个剧情包;名称区分大小写,但不允许仅大小写不同的两组名称。候选列表提供已有名称。不需要另建剧情补丁面板。 + +正常 galpak 导出会先保存当前项目,再从受保护的作者快照生成本体及全部剧情包。基础 PAK 放入 galpak,剧情 PAK 放在旁边 `StoryPatches//`。目录导出将它们放在输出目录内相同位置。分发时可以不带某个剧情包的所有分片,运行会沿其正常出口跳过;只缺一个分片、损坏或错版会报错。旧构建的剧情包不能配到新构建。 + +新 galpak 使用 v3 布局,所有归档条目位于 `pak/` 下。使用普通解压工具解压到播放器目录后得到 EXE 同级的 `pak/`,玩家版双击 EXE 自动加载。配置/人物/Gallery/清单封装为 `game.pak`,内置文本封装为 `localization.pak`,每种语言合并成一个逻辑条目。玩家目录无需分发这些散装 JSON。`pak/I18n//**/*.json` 只用于民间翻译等外置重载,缺省为空,详见[国际化](../spec/i18n.md#外部文本覆盖)。 + +剧情与资源覆盖包分别放入 `pak/StoryPatches//`、`pak/ResourcePatches/`;即使没有任何补丁,也会保留这两个目录和 I18n 空目录。整合打包会将 galpak 旁匹配 buildId 的剧情目录与资源覆盖目录一并复制进去。目录导出与 `galpak install --output` 的参数均表示内容根,应指定 `app/pak`,不会生成双层 `pak/pak`。旧 v1/v2 galpak 仍可用安装器读取。 + +本体先对全部普通 Group 和明确的基础根收集依赖。每个剧情包只扣除本体已有资源:A/B 都使用而本体没使用的资源,两包各带一份。条件分支里的静态资源全部计入,图集带齐其声明图片;不会因某次试玩没有走到分支而漏包。 + +项目设置的 `galnet.packaging` 提供 maxPakBytes(默认 128 MiB)、includeResources 和 declaredCompleteResources。阈值按未压缩编译资源字节计算,单个超大资源独占一片,不拆资源。includeResources 用于 UI、自定义代码等静态剧情外的根;未知/动态引用还需在 declaredCompleteResources 中确认已补全依赖(资源 ID 或 Group 节点 ID)。声明完整不会自动添加资源。 + +布局报告为 galpak 旁的 `<项目>.layout.json`,目录导出内置于 game.pak 的 packaging-layout.json,可查看每个资源的归属、直接依赖及未打包项。已有 Gallery 和默认视觉程序绑定属于基础根。文本和人物完整保留在本体文档包,不单独做剧情语言补丁。封装复用压缩 JSON,不引入 BSON;封装不是加密。 + +## 资源补丁矩阵 + +从面板菜单打开「资源补丁」。左列列出所有 Group(含剧情包)的资源引用,勾选「显示间接资源」可看到图集图片等依赖。点击原资源旁的定位图标进入编辑器文件管理器,悬停查看使用位置。没有静态引用的原资源可通过「补充原资源」加入,它也成为基础打包的显式根。 + +1. 点击工具栏的新增图标创建补丁列。名称回车或移开焦点后自动保存,优先级用数字输入调整;列头图标分别用于导出和删除。 +2. 在格子里用现有资源选择器选择替换资源,空格子不覆盖。替换资源须有独立 ID 且类型相同;图集还须保留原维度名和原变体键,可以增加变体。 +3. 点击该列的导出图标独立生成 PAK,不需要先导出 galpak。导出只读取已保存的补丁定义和资源快照,不保存其他项目内容,也不提交其他页面未完成的输入。普通 galpak 导出不会自动生成资源覆盖包。 +4. 将 PAK 放在安装目录或其父目录的 `ResourcePatches/` 下,重新启动/新建安装会话采用它。 + +列、映射和补充原资源的有效修改会立即写入 settings.json 的 `galnet.resourceOverrides`,无需应用或另行保存。撤销/重做也同步写盘,只发布该配置节,不顺带保存其他剧情或设置改动;失败会保留原版本并显示错误。定义继续支持恢复和重开。改名/移动资源保持 ID,因此映射仍有效;复制产生新 ID,不自动替换旧映射。删除原资源或目标会显示缺失,导出不能静默忽略缺失目标。 + +优先级数值越大越优先;相同时按完整 PAK 的 SHA-256 字典序,较大者优先,同文件重复放置只算一次。没有语言条件或人工冲突选择页。每次引用只映射一次,A→B/B→C 不会把 A 直接变成 C。 + +覆盖包携带 B 的完整依赖,明确读取该包内 B。携带资源不会隐式替换本体同 ID 资源;递归依赖同 ID 却内容不同会拒绝,应给修改后的依赖分配新 ID,必要时另建显式映射。通过「项目设置」中的资源补丁 section 可补充当前列的 includeResources / declaredCompleteResources,用于自定义资源的动态依赖。 + +未安装的剧情包不会因资源覆盖包存在而被当作已安装。Graph/Group 不能通过资源矩阵覆盖。当前不提供运行中热装卸和 Gallery 补丁。 + +## 存档与格式 + +存档 v5 校验基础构建和准备时已安装的全部剧情包。删除其中任意剧情包都会阻止恢复,即使当时尚未播放到该包。文档封装格式参与 buildId,新旧布局不会误用同一构建存档。旧 galpak v1/v2 与 content v2 安装目录保持读取兼容,旧 GPAK v2 需重新导出;不自动转换旧格式。 + +资源覆盖不改变存档中的逻辑资源 ID。图集替换后,读档会保留差分选择意图,并按当前有效图集重新解析实际图片;移除覆盖后也会重新解析。普通未覆盖图集仍严格校验保存结果与内容是否一致。 + +例如目标为 128 MiB 时,300 MiB 的单文件会完整独占一个约 300 MiB 载荷的分片,不截断、不切碎,也不与后续资源混装。分包阈值是布局目标,不是内存限制;当前归档写入和读取仍会保留整片缓冲。使用大资源时应结合实际导出与运行峰值调整阈值,单资源超过阈值时仍完整保留。 + +当前归档使用 byte[],单资源与单 PAK 受约 2 GiB 的缓冲容量限制,实际还受可用内存约束;更大的媒体需要流式归档能力,不能通过调高阈值获得支持。 diff --git a/docs/manual/cpu-benchmark.md b/docs/manual/cpu-benchmark.md new file mode 100644 index 00000000..fecc5cbc --- /dev/null +++ b/docs/manual/cpu-benchmark.md @@ -0,0 +1,105 @@ +# 测量 CPU 启动 + +需要 Windows、.NET 10 SDK 和 PowerShell 7。脚本先以 Benchmark=true 构建,再启动独立进程;构建不计入 Startup.Total。默认 Release、1 次预热、5 次测量。输出包含 Chrome Trace JSON、stdout/stderr、逐阶段 CSV、中位数/P95 摘要和运行环境/源码元数据。 + +```powershell +pwsh -File scripts/performance/benchmark-startup.ps1 -Target Editor -Iterations 10 +pwsh -File scripts/performance/benchmark-startup.ps1 -Target Editor -Configuration Debug -Iterations 10 +pwsh -File scripts/performance/benchmark-startup.ps1 -Target Sample -GameDirectory GameTestCase/Output -Profile Temp/benchmark-profile -Iterations 10 +``` + +Sample 的游戏内容需要预先构建,Profile 应是已完成语言选择的测试档案(可复制已有 profile 到临时目录),避免弹窗等待。每次 Sample 启动仍会执行正常启动逻辑,包括更新该档案的启动次数。脚本不会替你构建游戏内容或修改语言选择逻辑。不要将个人正式档案用于 benchmark。 + +`-Warmup 0` 可以保留第一次运行,但不等于操作系统冷缓存测试。脚本不清理系统缓存。相同配置、内容、profile 初始状态和机器负载下比较结果;5 次测量的 P95 只是 nearest-rank 样本分位数,样本少时通常就是最大值。外部进程探测得到的窗口时间和 Startup.Total 定义不同,不能直接比较。 + +`-OutputDirectory` 指定结果目录;默认 `out/benchmarks/-`。`-TimeoutSeconds` 默认 60。`-SkipBuild` 仅用于已经使用相同配置和 Benchmark=true 构建的输出,普通程序不会导出 trace。脚本串行运行,仅对自己创建的超时进程执行 Kill,不会结束已有 Editor 或游戏。超时、非零退出、缺失 Ready、事件溢出均中止并保留已生成数据。构建耗时仅代表本次实际构建(可能增量),不等同于 clean build 基线。 + +Sample 的基准脚本构建时显式开启 `EnableLaunchArguments=true`,用于传入隔离的内容目录和 profile,不需要开启游戏调试。手动构建/发布用于 `-SkipBuild` 或采样分析的 Sample 时,也须传入 `-p:EnableLaunchArguments=true`;默认玩家构建只加载应用同级 `pak/`。 + +在 Perfetto 或 Chrome Trace 查看器中手工打开任一 run JSON;CSV 可直接对比不同结果目录。元数据中的源码状态可能包含未提交文件,测量期间避免同时修改代码、编译或运行其他高负载任务。 + +## 添加埋点 + +让需要埋点的项目在 Benchmark=true 时引用 GalNet.Diagnostics,在方法内部显式添加编译保护: + +```csharp +public async Task LoadAsync() +{ +#if BENCHMARK + using var measurement = GalNet.Diagnostics.CpuProfiler.Shared.Measure("Game.Load", "Loading"); +#endif + await LoadAssetsAsync(); +#if BENCHMARK + GalNet.Diagnostics.CpuProfiler.Shared.Mark("Game.AssetsLoaded", "Loading"); +#endif +} +``` + +`using` 在异常返回时也会结束区间。普通构建会移除整个埋点语句,包括参数求值、计时对象和 Dispose。可以在一段代码外加局部块限定区间,也可以跨 await;名称应稳定,避免逐帧字符串拼接。该系统适合阶段诊断,测量本身有锁和分配成本,不用于纳秒级微基准。 + +启动之外的自定义捕获可创建 `new CpuProfiler(capacity)`,在完成所有区间后调用 Stop 和 WriteTrace(Stream)。不要使用已被 StartupBenchmark 停止的 Shared 实例继续采集。 + +## 验证 + +```powershell +dotnet test test/GalNet.Diagnostics.Tests -p:Benchmark=true -p:UseSharedCompilation=false -m:1 +dotnet test test/GalNet.Diagnostics.Tests -p:Benchmark=false -p:UseSharedCompilation=false -m:1 +``` + +运行普通宿主构建时不传 Benchmark,或显式 `-p:Benchmark=false`。Benchmark 输出使用每项目 bin/benchmark/,中间产物和锁文件使用 obj/benchmark,避免与普通构建的条件依赖图互相覆盖。切换模式后的首次构建必须允许 restore;-SkipBuild 只复用同模式现有产物。Core/Contracts 不引用诊断程序集。 + +仓库在 CI=true 时启用 locked restore;全新 CI 工作区需要先准备 Benchmark 隔离锁文件,例如显式执行一次 `dotnet restore -p:Benchmark=true -p:RestoreLockedMode=false`,再复用该还原目录。脚本不修改仓库的 CI 锁定策略。 + +## 交错比较两个固定版本 + +先分别构建并保存完整可运行目录,再使用固定 exe 路径做对照: + +```powershell +pwsh -File scripts/performance/compare-startup.ps1 -Baseline Temp/startup-experiments/A/GalNet.Editor.exe -Candidate Temp/startup-experiments/B/GalNet.Editor.exe -Pairs 10 -OutputDirectory out/benchmarks/my-comparison +``` + +比较脚本默认针对 Editor,也支持 `-Target Sample -GameDirectory -Profile `。两版各预热一次,再按 AB、BA 交替运行。每对只启动一个版本,保持同一个 Startup.Ready 完成点;输出 comparison.csv 和 summary.csv。每次元数据记录实际 exe 路径与对应宿主 DLL SHA256,避免工作树变化后误认测量版本。单独复用固定版本也可给 benchmark-startup.ps1 传入 `-SkipBuild -Executable `。 + +Editor 额外记录 Logging、ConfigureAvalonia、RunAvalonia、Window.Opened/Loaded 和 Startup.Loaded,便于区分配置、平台初始化和首屏布局间隔。不能把启动后的整个事件循环当作一个配置阶段。 + +## 运行时调用栈与 JIT + +先构建 Benchmark=true 的宿主。工具可安装在仓库临时目录,不修改全局工具;以下版本是本轮验证版本: + +```powershell +dotnet tool install dotnet-trace --tool-path Temp/dotnet-tools --version 10.0.750501 +pwsh -File scripts/performance/profile-startup.ps1 -Executable src/Editor/GalNet.Editor/bin/benchmark/Release/net10.0/GalNet.Editor.exe -TraceTool Temp/dotnet-tools/dotnet-trace.exe +dotnet run scripts/performance/analyze-startup-trace.cs -- /startup.nettrace +``` + +采集脚本在自己启动的进程上启用 GalNet-CPU 镜像、CLR JIT/加载事件和托管栈采样,正常就绪后自动关闭。保存 nettrace、CPU JSON、工具日志、版本及宿主 SHA256;超时仅停止本次采集进程树。默认输出在 out/benchmarks/profile-。不支持普通无 Benchmark 埋点的宿主。 + +Sample 同样可采集,用 PowerShell 数组传入游戏和测试档案参数: + +```powershell +& scripts/performance/profile-startup.ps1 -Executable -TraceTool Temp/dotnet-tools/dotnet-trace.exe -ApplicationArguments @('', '--profile', '') +``` + +分析器是需要 .NET 10 SDK 的 file-based app,固定使用 TraceEvent 3.1.21 与 scripts/performance/packages.lock.json;显式关闭工具自身的 AOT,并允许其诊断报告反射序列化。仅解析本地符号,不联网下载原生符号。生成 `.analysis.json`,提供阶段墙钟时间、主线程 JIT 区间并集、样本数、托管叶帧及包含帧、较慢 JIT 方法和丢事件检查。Sample 缺少 RunAvalonia 标记时使用 Ready 对齐,不虚构框架前置间隔。 + +这些数字是诊断信息:JIT 区间并集不是精确 CPU 占用;采样可能把 JIT/原生工作归于最近的托管帧,Windows 原生调用内部仍需其他工具。嵌套阶段、包含栈样本不能求和。正式性能对比必须关闭 EventPipe 镜像与采样,并使用固定版本交错脚本。 + +## Windows 原生启动栈 + +使用管理员 PowerShell、系统 WPR 与前述 Benchmark 宿主。无需打开 WPA,脚本可离线分析;需要交互查看时用 WPA 打开 ETL。 + +```powershell +dotnet publish src/Editor/GalNet.Editor/GalNet.Editor.csproj -c Release -r win-x64 -p:Benchmark=true -p:PublishProfile=WindowsReadyToRun -o Temp/EditorBenchmarkReadyToRun +pwsh -File scripts/performance/profile-startup-native.ps1 -Executable Temp/EditorBenchmarkReadyToRun/GalNet.Editor.exe -OutputDirectory out/benchmarks/native-editor +$capture = 'out/benchmarks/native-editor' +$targetProcessId = (Get-Content "$capture/metadata.json" -Raw | ConvertFrom-Json).ProcessId +dotnet run scripts/performance/analyze-startup-native.cs -- "$capture/startup.etl" $targetProcessId --symbols +``` + +示例 exe 必须发布时启用 Benchmark=true。Sample 同样使用 `-ApplicationArguments @('', '--profile', '')`。每次使用新的输出目录。 + +脚本仅在 WPR 明确报告空闲时启动;已有录制或无法识别的本地化状态会直接拒绝,不停止其他录制。它采集短时系统级 CPU/模块事件和 GalNet 阶段,子进程就绪后正常关闭,再停止自己启动的录制。超时只终止本次子进程树;WPR 停止合并可能比程序启动长,不计入 benchmark。 + +分析器按 metadata 的 PID 筛选主线程栈,输出 `.analysis.json` 中逐阶段采样计数、叶帧/包含帧与原始栈,拒绝丢事件 trace。默认不访问符号服务器;显式 `--symbols` 时从微软服务器解析 D3D11、DXGI、ImageHlp、WinTrust 等系统模块,缓存到当前目录的 Temp/native-symbols,详情在 `.symbols.log`。第三方驱动未必有公开函数符号,仍可看到模块与系统调用链。 + +CPU 采样数量不等于精确毫秒数,也不能证明线程等待或 GPU 首帧时间。若全量 GeneralProfile 有丢事件,优先缩小 profile;当前 startup-native.wprp 不启用磁盘/文件/上下文切换等高流量事件。正式 A/B 比较不同时运行 WPR 或调用栈分析。 diff --git a/docs/manual/cross-platform-backends.md b/docs/manual/cross-platform-backends.md new file mode 100644 index 00000000..2822dacd --- /dev/null +++ b/docs/manual/cross-platform-backends.md @@ -0,0 +1,73 @@ +# 跨平台后端接入 + +本页覆盖引擎后端,不创建 Avalonia Android/iOS App 模板。Core/Runtime 的执行和存档逻辑、PAK 格式、PCM 混音/DSP 继续共享;平台宿主选择资源入口、媒体实现和原生库部署。普通目录中的存档/变量/进度继续使用现有 .NET 文件实现,由宿主传入沙盒可写目录,不再包装一套 File API。 + +## 从包内流读取 PAK + +```csharp +var provider = new PakStreamProvider( + new Dictionary> + { + ["assets"] = () => OpenPackagedResource("Assets/assets.pak") + }, resourceTypes); +using var assets = new AssetManager([provider]); +await using var files = new AssetFileMaterializer(assets, applicationCacheDirectory); +``` + +`OpenPackagedResource` 是宿主提供的普通委托,可调用 Avalonia AssetLoader、Android AssetManager 或其他资源 API。每次返回独立、位于 PAK 起点的新可读流;provider 负责关闭流,不要求流可寻址,也不枚举包内目录。此入口沿用当前 Archive 的全量内存加载,不是大 PAK 的随机访问或流式解压方案。媒体实体化始终来自 AssetManager acquire 的字节,并保留文件租约。 + +Graph/settings/I18n 的默认 `InstalledGameContentProvider` 仍读取展开后的安装目录。可将已验证游戏内容安装到应用私有目录再使用该实现;直接从应用包读取这些内容需实现现有 `IGameContentProvider`。新增 PAK 流入口不表示整个安装内容加载器已变成流接口。 + +## 构建原生音频 + +桌面 CMake 构建示例(在所需架构的编译器环境执行): + +```sh +cmake -S src/Infrastructure/GalNet.Media.Miniaudio/native -B out/native -DCMAKE_BUILD_TYPE=Release +cmake --build out/native --config Release +cmake --install out/native --config Release --prefix out/native/install +``` + +产物为 Windows 的 `galnet_miniaudio.dll`、Linux 的 `libgalnet_miniaudio.so`、macOS 的 `libgalnet_miniaudio.dylib`。Windows 默认托管构建也可自动调用 MSVC,按目标 RID 选择 win-x64/win-arm64,原生输出按 RID 隔离。脚本从已发现的 VS 实例中选择目标架构的编译器、头文件和静态运行库齐全的最高版本工具集,并用 `-vcvars_ver` 显式初始化;不要求默认最新工具集也安装该架构。自备动态库时传入绝对路径: + +```sh +dotnet build path/to/Host.csproj -p:GalNetMiniaudioNativePath=/absolute/path/libgalnet_miniaudio.so +``` + +自备路径只关闭默认自动原生编译,并把该动态库复制到普通构建/发布输出;库的 CPU 架构和系统 ABI 必须与目标一致。Miniaudio 原生 ABI 为 2,托管和原生部分应同时更新。构建禁用 null 音频设备,无法打开真实设备时按初始化失败报告。 + +Android 使用 NDK CMake 工具链,例如: + +```sh +cmake -S src/Infrastructure/GalNet.Media.Miniaudio/native -B out/native-android \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-23 -DCMAKE_BUILD_TYPE=Release +cmake --build out/native-android +``` + +平台宿主将生成的 `.so` 声明为对应 ABI 的 `AndroidNativeLibrary`;普通 Content 复制不替代 APK 内原生库打包。系统音频焦点和路由事件仍由宿主接入。 + +iOS 在有 Xcode 的 macOS 上构建静态库,设备与模拟器分别构建: + +```sh +cmake -S src/Infrastructure/GalNet.Media.Miniaudio/native -B out/native-ios \ + -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphoneos -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 -DGALNET_MINIAUDIO_STATIC=ON -DCMAKE_BUILD_TYPE=Release +cmake --build out/native-ios --config Release +``` + +宿主用 `NativeReference` 链接生成的 `.a`,选择 `Kind=Static`、`ForceLoad=true` 并带上 CoreFoundation/CoreAudio/AudioToolbox frameworks;构建托管适配器时传入全局属性 `GalNetMiniaudioStaticLink=true`,使导入绑定 `__Internal`。动态和静态托管变体应使用独立输出目录。宿主负责 AVAudioSession 策略,先激活系统音频会话再恢复设备。这些移动端命令是接入路径,当前 Windows 验证环境未执行其编译、签名和实机播放。 + +## 系统中断与视频替换 + +`PcmAudioPlaybackHost` 可通过 `SupportsOutputSuspension` 检查能力,在控制线程调用 `SuspendOutput` / `ResumeOutput`。Miniaudio 暂停会关闭设备并等待回调结束,恢复重新打开当前默认设备;保留源、租约、剧情 Pause 和 DSP,失败可重试。后台读取只继续到有界 PCM 缓冲填满。宿主负责将系统中断/路由事件映射到这些调用,并另外处理剧情暂停与恢复点;后端不会调用终态 `Engine.StopAsync`。 + +剧情视频可通过 `AvaloniaGamePresentationOptions.CreateVideoPresenter` 提供任意 `IVideoPresenter`,返回实例由呈现会话拥有并优先异步释放。该 presenter 需要将画面接入宿主场景组合,不能仅创建一个脱离图层的播放器。默认仍为 LibVLC CPU 像素输出,不宣称已经支持移动端硬件解码与零拷贝。需要继续用 LibVLC 时,可用 `LibVlcRuntime(() => CreatePlatformLibVlc())` 完成平台初始化并创建实例;运行时拥有返回值,工厂失败保留 InitializationError。Gallery 当前仍使用默认 LibVLC 服务和控件,不会随剧情视频工厂一起替换。 + +## AOT 与验证范围 + +静态音频回调和源生成 P/Invoke 已消除该原生边界的动态委托 thunk;Core/Runtime、内置契约、Assets 和文件持久化的玩家 JSON 路径也已接入源生成元数据。状态数组、自定义资源、配置和字段 schema 均提供显式注册入口,普通 JIT 保留反射兼容路径。现有 Headless Sample 用标准 PublishAot 选项完成 Windows x64 原生发布并运行自检,确认动态代码/反射 JSON 均关闭;最终 ILLink/ILC 警告作为错误处理。操作和验收范围见 [NativeAOT 发布与扩展契约](native-aot.md)。 + +桌面自检覆盖引擎和文件后端,不代表 Avalonia/Skia、LibVLC、原生设备或 Editor/CLI 作者编译器全部支持 AOT。移动端最终发布闭包、原生打包和实机生命周期仍需对应环境验收。不要用全局关闭裁剪或吞掉 IL 警告作为支持证据。 + +本轮本地证据:资源测试 76 项、音频普通测试 26 项、真实 Windows x64 设备测试 2 项、视频工厂/失败清理测试 5 项及架构测试 52 项通过;CMake 动态库在 Windows x64 构建成功。Core/Runtime 的 AOT/裁剪分析构建通过但仍报告 IL2026/IL3050 等警告。补装 ARM64 MSVC 14.44 后,Windows ARM64 原生库交叉编译通过,确认 PE Machine 为 `0xAA64` 且 8 个原生 ABI 导出齐全;`GalNet.Media.Miniaudio` 的 Release / `-r win-arm64` 托管及原生组合构建也通过,0 错误。并验证 x64 仍选用完整的 MSVC 14.51,PE Machine 为 `0x8664`。Windows ARM64 尚未做实机运行验收。`.github/workflows/backend-portability.yml` 增加 Windows/Linux/macOS 的资源、原生解码和 CMake 验证任务,远端任务尚未在本地执行。Android/iOS 原生打包、系统中断、移动 GPU/Shader 和移动宿主 AOT 发布验收仍待对应环境完成。 diff --git a/docs/manual/customize-avalonia-sample.md b/docs/manual/customize-avalonia-sample.md index 124aa2bb..3ca3dfbd 100644 --- a/docs/manual/customize-avalonia-sample.md +++ b/docs/manual/customize-avalonia-sample.md @@ -2,19 +2,126 @@ `GalNet.Sample.Avalonia` 是项目级组合根示例:保留启动参数、项目选择、本地化和调试策略;通用会话、页面与展示组合应优先复用 `Avalonia.GameHost`、`Avalonia.GameView` 和 Themes,而不是复制其生命周期代码。 +## 调试构建与玩家发行 + +Sample 通过 MSBuild 属性 `EnableGameDebugging` 控制游戏调试能力,启用时定义 `GALNET_GAME_DEBUG`。Debug 配置默认开启,Release 默认关闭;也可以显式构建带调试能力的优化版本: + +```powershell +dotnet build src/Samples/GalNet.Sample.Avalonia -c Release -p:EnableGameDebugging=true +``` + +关闭时不编译调试服务、存档辅助工具、日志窗口和菜单,不引用 `GalNet.Debugging`,也不接入 Avalonia Developer Tools。独立的 `EnableLaunchArguments` 定义 `GALNET_LAUNCH_ARGS`,默认跟随 `EnableGameDebugging`:玩家版不编译内容路径、`--profile` 与调试参数解析,传入参数明确报错;开发版允许覆盖内容路径/profile。也可以在关闭游戏调试时显式开启普通参数;开启游戏调试则必须开启参数能力。 + +所有构建无内容参数时默认加载应用同级 `pak/`,不依赖当前工作目录。发行依赖位于 `lib/`,程序明确区分程序集根与应用根。缺失或损坏内容通过启动窗口报告。默认 profile 为用户 `LocalApplicationData/GalNet//`:摘要输入是 `project:` 加 game.pak 内的 projectId(旧安装读取 content.json),与安装路径和 buildId 无关;开发目录缺少内容身份时使用 `directory:` 加目录绝对路径。日志位于 profile 的 `logs/`。此策略由 Sample 显式传入共享会话,旧 profile 不自动迁移或删除。 + +`ConfigureServices` 的 `enableDebug` 只允许在已经编入调试能力的构建中抑制服务注册,不能给玩家构建重新启用调试。Release 关闭模式使用独立的 `packages.lock.NoGameDebugging.json`;切换开关后必须重新 restore,不能复用另一种依赖图的 `--no-restore` 输出。WindowsReadyToRun 玩家版使用该 profile 的独立锁文件,开发版使用 `packages.lock.WindowsReadyToRun.Debugging.json`。 + +仓库发行脚本默认 `-SampleFlavor Player`,显式关闭游戏调试、启动参数和 PDB;`-SampleFlavor Developer` 使用带调试和参数能力的 Release 构建并保留符号,ZIP 名追加 `-developer`。两者均不改变契约程序集的编译参数和严格字节身份,也不改变 PAK 覆盖顺序或补丁策略。 + +默认宿主的扩展目标是让项目自己实现功能。手机、小游戏、商城等可以使用项目服务、XAML、primitive 与状态恢复组合,不需要给框架增加同名业务系统。 + +## 替换服务与页面行为 + +`AddAvaloniaGameViewPages` 通过 `TryAddScoped` 提供默认服务,项目可以在调用前注册替代实现,或在调用后显式覆盖。标题、设置、游戏页面和 Shell 的默认 VM 可派生;标题命令的处理方法可以重载。注册派生 VM 的别名和实际 View: + +```csharp +services.AddScoped(); +services.AddScoped(); +services.AddAvaloniaGameViewPages(theme, + configureViews: views => + { + views.Register(); + views.Register(); + }); +``` + +`MyTitleViewModel` 派生 `TitlePageViewModel`,自己的按钮命令可调用 `IGameNavigationService.Navigate()`;返回使用现有导航历史。工厂按实际 VM 类型选择 View,因此派生类型也需要映射。更大幅度的定制可以替换 `IGameNavigationService`、`IPageViewFactory`,或由自己的窗口构造 Shell。页面的 XAML 和业务服务不需要字段生成器。 + +## 自有设置模型与手写 XAML + +默认会话消费 `IAvaloniaPlayerSettings`,仅要求投影 `PlaybackOptions`、`AudioMix`、`TextSpeed`,接收玩家存储与音频声明,并提供变更通知和 `FlushAsync`。默认实现是 `AvaloniaPlayerSettingsService`,项目可以实现接口、持有自己的 DTO 和持久化规则,再注册到 DI。`IPlayerSettingsService` 应解析到同一实例;`IPlayerStartup` 可替换首次启动策略。窗口绑定和显示恢复可按需要实现。 + +自动表单使用 `Categories` 和 `Editors`;手写设置页可以直接绑定自己的模型/命令,分类可为空。复杂偏好可以拆成玩家变量,或由项目显式序列化到自己的 profile 数据;不要把完整设置 DTO 放进 Engine。应用后发布 `SettingsChanged`,默认宿主将投影值更新到当前运行。预览、草稿取消、校验和格式迁移由项目设置服务定义。 + +实际可运行示例见 `test/GalNet.IntegrationTests/ProjectSettingsView.axaml` 与 `SampleExtensibilityTests.cs`:独立偏好模型、手写 XAML、应用命令、保存重开,以及自定义标题按钮/页面。 + +## 玩家与运行绑定 + +`AvaloniaGameHostOptions.AttachPlayer` 在内容、首次启动及设置恢复完成后接入,收到借用的 `PlayerSession`、内容与 profile 路径;`AttachRun` 在每次运行 Prepare 完成后接入,收到当前 Engine。回调返回的 `IDisposable` 由宿主释放;若同时实现 `IAsyncDisposable`,宿主等待异步释放,项目可取消并排空自己的后台任务。 + +运行替换/停止先终止 Engine,再释放运行绑定;玩家绑定在所有运行结束后、玩家存储和资源释放前结束。回调抛错会进入正常准备失败清理。需要多个项目服务时,在组合根中组合它们的绑定;不要在回调中留下未观察的任务或 `async void`。 + +活动运行中的外部变量修改使用: + +```csharp +await engine.SetVariablesAsync(new Dictionary +{ + ["save.phoneReply"] = "accept", + ["player.chapterUnlocked"] = true +}, cancellationToken); +``` + +条件扣款、计数累加可用 `UpdateVariablesAsync(current => changes)`:在同一执行门内读取带作用域前缀的独立快照并计算修改批次,校验通过后提交;计算委托必须同步,抛错时不修改变量。 + +名称必须显式带 `save.` 或 `player.`。入口先验证全部值,再通过 Engine 唯一执行门修改;排队请求遇到恢复/停止会失效。显式修改的存档变量会合入稳定快照,保留原游标和呈现状态;玩家变量仍由玩家服务 flush,默认宿主退出时等待落盘。读取使用 `engine.Runtime.GetVariables(scope)` 的独立快照。玩家/profile 服务可以在没有活动运行时保存玩家级数据;不要在活动运行中绕过该入口修改剧情要读取的变量。 + +此入口不是任意业务事务、商城订单协议或小游戏原地续玩的实现。项目仍负责奖励幂等、交互窗口校验、自定义状态及恢复策略;后台操作应绑定其实际所属的玩家或运行生命周期。 + +## 单图章节启动参数 + +项目保留唯一 Graph,章节可以在图入口按变量分支。默认标题 VM 提供 `StartNewGameWithVariablesCommand`,参数为 `IReadOnlyDictionary`;派生首页 VM 可暴露不同字典供章节按钮绑定,例如 `new Dictionary { ["save.chapter"] = 2 }`,无需每个按钮单独实现启动流程或填写 Graph ID。标题命令沿用 Loading → Prepare → 游戏页 → Begin 的流程。 + +自定义应用也可通过 `IParameterizedGameSessionService.PrepareNewGameAsync(initialSaveVariables, ct)` 准备,再调用继承的 `BeginPreparedGameAsync`;不需要页面切换时可直接调用带字典的 `StartNewGameAsync`。默认 AvaloniaGameSession 实现该可选接口,原有无参启动和其他宿主实现无需强制接收新参数。 + +例如在已有派生首页 VM 中增加两个参数属性,Graph 声明 chapter 并在入口判断其值: + +```csharp +public IReadOnlyDictionary ChapterOne { get; } = + new Dictionary { ["save.chapter"] = 1 }; +public IReadOnlyDictionary ChapterTwo { get; } = + new Dictionary { ["save.chapter"] = 2 }; +``` + +首页 View 共用已有命令: + +```xml +