Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .agents/agent-knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@
- 解决方案入口是 `GalNet.slnx`。
- `docs/spec/` 描述已经实现的稳定事实;`docs/design/` 描述设计和分阶段计划。
- Runtime/Core 应保持与 Avalonia、WPF、Skia、具体文件系统和其他平台实现解耦。
- Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为主内置入口。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/adr/0002-entry-schema-execution-registration.md 和 docs/development.md。
- 当前 GameSnapshot 版本为 3,不兼容旧版;读档取消旧运行 scope 并替换场景对象。Engine 不拥有执行 view,宿主先释放 Engine,再异步释放 view 与后端。
- Engine 提供终态 StopAsync 和 StateChanged,覆盖玩家推进、Choice 与阻塞实例自然完成;不新增通用 Runner。AvaloniaGamePresenters 只组合端口,SampleGamePresentation 管理平台资源。见 docs/design/game-runner-refactor.md 和 docs/spec/runtime.md。
- Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前 20 个生产项目。GameContent.Settings 包含项目设置,项目/安装加载器与两个 Sample 已接通自定义音频轨道配置。
- 旧的 `GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移并删除;当前共享 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`,页面架构见 `docs/spec/control.md`。
- 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由 Engine 根据状态重放展示。
- 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由宿主通过 PersistentSceneRestorer 根据状态重放展示。
- 视觉扩展应通过展示抽象和渲染端口接入;不要把平台类型或渲染资源泄漏进 Core/Runtime。
- 当前已有大型阶段计划:[runtime-presentation-decoupling-phase-plan.md](../docs/design/runtime-presentation-decoupling-phase-plan.md) 和 [render-effects-pipeline-plan.md](../docs/design/render-effects-pipeline-plan.md)。新增 feature 应链接它们,不要复制其中的长期设计。

Expand All @@ -32,6 +36,8 @@

- [F-20261009-02-video-entries](../features/F-20261009-02-video-entries/feature.md) 已实现剧情视频图层、阻塞分段 seek、停止/替换清理和非阻塞视频定义恢复。平台视频必须经 AssetManager 获取,解码帧通过租约进入场景管线;播放游标不入存档。验证见 feature summary。

- [F-20261010-02-runtime-i18n](../features/F-20261010-02-runtime-i18n/summary.md) Phase 1–5 已实现。Sample 用 Core 2.1.2 + ReflectionBinding;每 JSON 独立 provider,culture-first 查询内嵌/包内/External/额外源;首次选择在 Engine 前原子保存 locale/count。设置切换不重译当前剧情。安装器保留 External 并拒绝覆盖不同包,导出不包含第三方文件。当前契约见 `docs/spec/i18n.md`,人工 GUI 全程与原生媒体 opt-in 验证限制见 summary。

- `F-20260926-01-gallery-meta-pages` 的实现、正式文档与示例 Gallery 解锁 smoke test 已完成;其外部阻塞项 `EditorSettingsSerializationTests.LastDockLayout_RoundTripsAsAString` 已由 `F-20260929-04-quality-baseline` 修复,Gallery feature 可按自身流程收尾。
- [F-20260916-02-audio-system](../features/F-20260916-02-audio-system/feature.md) 已完成首期:轨道逻辑、`AudioRuntime`、`IAudioPlaybackHost`、Avalonia LibVLC host、语音文本 composite 和 Sample 验收已落地。Runtime 只能传 asset ID;平台 host 必须经 `IAssetManager` 实体化临时媒体文件。后续 [F-20261009-01-track-seffects](../features/F-20261009-01-track-seffects/feature.md) 增加 host 注册的 `SEffectCatalog`、`seffect.gain` 与基于 LibVLC equalizer 的 `seffect.lowPass` / `seffect.highPass`;item scope、动画、存档、玩家音量和角色默认轨道均未实现。
- `F-20260929-01-compiled-content-pipeline` 正在实施;粒子 Primitive、Runtime 状态与 Presenter 桥接及存档后的 effect/particle 展示重放已经验证,完整 Sample/Headless smoke 与 Phase 4 收尾仍待完成。
Expand All @@ -49,6 +55,9 @@
## 经验索引

- [受限环境中的 .NET/Avalonia 构建](lessons/L-20260922-01-dotnet-build-sandbox.md):禁用 Roslyn shared compilation,并关闭 Avalonia build telemetry。
- [读档必须隔离旧运行实例](lessons/L-20261010-02-restore-run-scope.md):取消与场景对象隔离同时使用;句柄复用的清理需校验实例 identity;快照不能共享 mutable 对象。
- [删除项目后检查依赖锁图](lessons/L-20261010-03-lock-graph-after-project-removal.md):检查 lock 内容;缓存可能保留旧项目,需要对受影响 roots 强制重算再 locked restore。
- [Engine 停止覆盖所有续跑](lessons/L-20261010-04-engine-stop-continuations.md):首次推进返回不等于运行结束;停止须取消 scope、等待内部 continuation,统一发布终态。
- [平台无关的进度契约归属](lessons/L-20260922-02-core-progress-contract.md):已被 ADR-0001 和 F-20260929-07 取代;当前进度端口由实际消费者 Runtime 拥有。
- [受限环境中的平台 SDK 枚举](lessons/L-20260925-01-platform-sdk-sandbox.md):全解决方案构建可能只因 Android/Browser/iOS 的 SDK 探测读取用户目录失败;应区分平台环境限制和业务项目编译结果。
- [受限环境中的 Avalonia licensing 枚举](lessons/L-20260926-01-avalonia-license-sandbox.md):Avalonia BuildServices 即使关闭 telemetry 仍可能读取用户级 licensing tickets;应与业务编译失败区分。
Expand All @@ -69,3 +78,6 @@
- [项目移动后必须清理旧构建中间产物](lessons/L-20260929-08-project-move-stale-restore-path.md):移动 .NET 项目后先删除随项目移动的 `bin/obj`,再从新路径 locked restore,避免旧绝对路径缓存重建旧目录和 lock 文件。

- [动态视频图层的加载与帧生命周期](lessons/L-20261010-01-video-layer-lifetime.md):待加载源先登记,hide/replace 取消加载;帧租约与独立播放 identity 防止晚发布和旧完成误清理。
- [DynamicLocalization Avalonia 主版本兼容性](lessons/L-20261010-05-dynamiclocalization-avalonia-version.md):2.1.0 与 2.1.2 的 Avalonia extension 都不适用于当前 Avalonia 12 Sample;后者虽能编译仍在启动时调用已移除的 Binding 构造函数。Sample 使用 Core + 自有 `ReflectionBinding` 适配层。
- [JSON provider 的整 culture 替换](lessons/L-20261010-07-json-provider-replaces-culture.md):2.1.2 的 LoadJsonString 不增量合并,也不做父 culture 回退;分文件需独立 provider 与宿主有序查询。
- [Render 内的粒子耗尽清理](lessons/L-20261010-08-render-pass-particle-drain.md):confirmed;UI Dispatch 在 UI 线程同步执行,不能作为渲染回调清理的延迟队列;使用保证异步的 `Post`,并以 emitter identity 防止旧回调删除替换实例。
22 changes: 22 additions & 0 deletions .agents/lessons/L-20261010-02-restore-run-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 读档必须隔离旧运行实例

状态:confirmed
来源:Entry 契约/执行注册重构(docs/design/entry-registration-refactor.md)

## 现象与根因

仅清空 Engine 活动列表并在原 SceneState 上恢复内容,不能阻止仍持有 Runtime 的旧异步实例在 finally 中清理新状态。取消令牌只是一种协作信号,不保证旧任务已退出。

## 发现与证据

审查发现 Engine 原先读档不更新 scope cancellation。新增 GameEnginePrimitiveDispatchTests.RestoreCancelsOldScopeAndIsolatesSceneAndSnapshots 验证旧 scope 被取消、旧实例的场景引用不能清理新场景、控制流操作抛出取消异常、调用者快照修改不污染 Engine。

## 正确做法

先验证存档版本并复制输入,再取消旧 scope;恢复时创建新的 SceneState/SceneInstanceManager。实例收到 RunScopedRuntime,持有该代场景对象,变量和控制流操作检查该代令牌。外部快照与 checkpoint 参数深复制。句柄复用时,异步清理还须检查实例 identity,不能仅按字符串句柄移除。

## 适用范围与禁止做法

适用于读档、重载、重启、同句柄替换。禁止把 ClearActive 当作取消;禁止让旧 finally 按句柄无条件清理新状态;禁止取消现有运行后才检查非法存档版本。

关联:docs/spec/runtime.md;GameRuntimeSnapshotTests;AnimationPrimitiveInstance;AnimationPlanPrimitiveInstance。
14 changes: 14 additions & 0 deletions .agents/lessons/L-20261010-03-lock-graph-after-project-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 删除项目后检查依赖锁图

状态:confirmed
来源:Entry 注册重构,删除 Presentation.Defaults

## 现象与根因

删除 csproj 和 ProjectReference 后,普通 restore 与后续 locked restore 返回成功,但外层 packages.lock.json 仍保存已删除项目及其旧依赖。还原缓存没有重新生成锁图;成功返回不能替代检查锁文件内容。

## 证据与正确做法

本轮用 rg 检查仍存在的 galnet.presentation.defaults 锁图条目。对 Integration、Editor.Tests、Presentation.Tests 的具体 csproj 执行 dotnet restore --force-evaluate 后,所有受影响生产与测试 lock 文件移除了旧项目;再执行 --locked-mode 验证。不要手写锁文件或仅依据退出码认定依赖图已同步。

适用范围:项目删除、依赖移除或跨项目引用变化。边界:此记录不建议无条件删除 bin/obj;强制重算只针对已确认受影响的 restore roots。
16 changes: 16 additions & 0 deletions .agents/lessons/L-20261010-04-engine-stop-continuations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Engine 停止必须覆盖自动续跑,而不只跟踪首次推进

状态:confirmed
来源:docs/design/game-runner-refactor.md;GameEngineLifecycleTests

## 现象与根因

旧 Sample 用 GameRunCoordinator 只追踪首次 AdvanceAsync,后续玩家点击、选择结果和阻塞实例完成的续跑不在同一停止边界。只取消首次推进任务不能取消 Engine scope,也无法保证旧 continuation 不再访问宿主资源。

## 正确做法

Engine 在终态停止时先拒绝新请求、取消执行 scope,再清理队列并等待内部 continuation 退出。选择等待必须通过 scope token 中断,不能无条件等待不协作取消的 presenter Task。终态通知必须覆盖所有续跑入口并在执行锁外发布,宿主按 Engine、执行 view、平台后端顺序释放。

## 验证与禁止做法

GameEngineLifecycleTests 验证自然阻塞完成与 Choice 的完成/失败通知、不协作 Choice 的停止、执行中与排队推进的停止、正常结束后不重放和 view 非拥有关系。禁止把“首次 AdvanceAsync 已返回”当成游戏已停止;禁止让宿主分别猜测每种异步入口的终态;禁止在状态事件中同步等待 Engine 异步任务。
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# DynamicLocalization Avalonia package must match the host Avalonia major version

状态:confirmed

来源 Feature:F-20261010-02-runtime-i18n

## 现象

`DynamicLocalization.Avalonia` 2.1.0 的 NuGet manifest 为 net6.0 至 net9.0 target groups 声明了 `Avalonia (>= 11.0.0)`,其 assembly 在载入时要求 `Avalonia.Markup.Xaml, Version=11.0.0.0`。GalNet 的 Avalonia Sample 已使用 Avalonia 12.0.5,因此不能直接采用该版本的 XAML markup extension。2.1.2 虽将依赖范围更新为 `Avalonia [11.0.0, 13.0.0)`,但运行时仍调用 Avalonia 12 已移除的 `Binding(string, BindingMode)` 构造函数。

## 根因

该包的 2.1.0 release 尚未针对 Avalonia 12 编译;Core package 是平台无关的,不具有此 Avalonia assembly dependency。2.1.2 的包元数据虽然允许 Avalonia 12,但实际 `LocalizeExtension` 仍为旧 `Binding` API 编译,因此 restore 和 build 都不能证明可启动。

## 正确做法

保留 Avalonia 12,不为使用 markup extension 降级全仓 Avalonia。Sample 只保留 `DynamicLocalization.Core` 的 `CultureService`,自有 extension 按官方 demo 绑定 Core 的 `LocalizedString.Value`,将平台 binding 换成 Avalonia 12 `ReflectionBinding`。`LocalizedString` 监听 culture/provider change,不能只在自己的 ApplyCulture wrapper 中发通知,否则外部直接改 culture 时不会更新。任何未来重新引入第三方 extension 的方案都必须做真实启动验证。

## 适用范围

任何将 `DynamicLocalization.Avalonia` 引入当前 Avalonia 12 GalNet 应用的 feature。

## 不要做什么

不要仅因 package 可 restore 就假定其 XAML extension 可与当前 Avalonia major 运行,也不要为单个本地化功能降级共享 Avalonia 依赖。

## 证据

- 用户于 2026-10-10 报告的 Sample 启动堆栈:`LocalizeExtension.ProvideValue` 调用缺失的 `Avalonia.Data.Binding..ctor(string, BindingMode)`。
- `GalNet.Sample.Avalonia` 在移除 `DynamicLocalization.Avalonia`、保留 Core 并改用自有适配层后成功构建;输出目录不再有 `DynamicLocalization.Avalonia.dll`。
13 changes: 13 additions & 0 deletions .agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 已存储的 i18n key 与执行位置

状态:confirmed(用户确认,2026-10-10)

来源:F-20261010-02-runtime-i18n。

现象:实现讨论把 raw ID 当成需要透传 Runtime 的本地化身份,随后又提出在编译时将作者文本改写成 key;两者均偏离用户确认的契约。

根因:混淆了 Editor 生成 key 的输入与 Runtime 消费的完整 key。每个 group 的 Entry.Id 在编译前后都表示位置,组合展开会改变编号。content 在 raw 阶段已是 key。

正确做法:未来 Editor 自动生成 key 时使用 raw 阶段的 ID;key 保存后原样编译、展开、执行。不要按运行时序号重新生成,不增加仅为解析 key 使用的 SourceEntryId 属性,不擅自把 raw content 解释为源文本。

适用:GalNet 内置文本条目与未来翻译编辑工具。自定义参数的本地化语义仍由 implement 决定。
23 changes: 23 additions & 0 deletions .agents/lessons/L-20261010-07-json-provider-replaces-culture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# DynamicLocalization JSON 逐文件加载会替换同 culture 的字典

状态:confirmed

来源 Feature:F-20261010-02-runtime-i18n

## 现象与根因

DynamicLocalization.Core 2.1.2 的 `JsonLocalizationProvider.LoadJsonString` 将解析结果赋给该 culture 的整个字典,而非增量合并。对同 provider 依次加载不同 group 的文件,之前文件的独有 key 消失。该版本 provider 的 `TryGetString` 也不递归查询父 culture。

## 证据与发现方式

完整 GameTestCase 的四条语言/路线运行暴露人物名、选项、后续 group 回显 key;单文件重复 key 测试无法发现字典丢失。递归加载测试中 a.json 的独有 fallback key 在 z.json 加载后消失。直接调用安装的 2.1.2 DLL,只有 zh 资源时,查询 zh 成功而 zh-CN 失败。

## 正确做法与适用范围

宿主为每个 JSON 分别建立 provider,保留确定性源顺序;查询时先遍历当前 culture、父 culture,再遍历游戏默认 culture 链,每个 culture 内从后向前查找。库继续解析 JSON,GalNet 不合并文件或重复实现 JSON 展平算法。Sample 两个宿主共享源码适配器,Core/Runtime 不引入库类型。

## 禁止做法

不要把“后文件重复 key 生效”当作“逐文件增量合并”的证据。版本变更必须同时测试重复 key、独有 key 保留和父 culture 回退。

关联文档:`features/F-20261010-02-runtime-i18n/design.md`、`docs/spec/i18n.md`。
28 changes: 28 additions & 0 deletions .agents/lessons/L-20261010-08-render-pass-particle-drain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 粒子耗尽清理不能在渲染调用栈内同步修改场景集合

状态:confirmed(通过 presenter 的延后清理与同 ID 替换回归测试验证)

来源 Feature:F-20261010-02-runtime-i18n 验收期间发现;问题位于既有粒子展示链。

## 现象与发现方式

用户报告游戏结尾附近崩溃。`GameTestCase/.galnet/logs/game-20261010.log` 的 2026-10-10 21:59:32 致命异常为 `Visual was invalidated during the render pass`。该次运行没有记录 Engine Completed;栈明确来自粒子耗尽后的场景集合移除,不是 i18n 解析或 Engine 终态释放。

## 根因

`SceneLayerHost.Render` 同步调用 `ParticleEmitter.Update`;耗尽时触发 `Drained`。`AvaloniaParticlePresenter.OnParticleDrained` 注释称移除被延后,但调用的 `IAvaloniaUiDispatcher.Dispatch` 在 UI 线程立即执行。因此 `StopParticleEmitter` 在 Render 内移除 `SceneRenderables`,集合通知触发 `RebuildPlan` 与 `InvalidateVisual`,Avalonia 抛出异常。

## 正确做法

为渲染回调的清理使用保证异步排队的调度,待本次 Render 返回后再修改场景集合。不要只判断是否处于 UI 线程;UI 线程不代表当前位于安全的渲染阶段。排队清理还应核对 emitter 实例身份,避免替换同 ID 后误删新实例,并覆盖 presenter 释放后的回调。`IAvaloniaUiDispatcher.Post` 明确提供该保证;`Dispatch` 仍可在 UI 线程同步执行,不能用于渲染期清理。

## 适用范围与禁止做法

适用于帧更新中触发的 drained/finished 回调及渲染内的集合或属性变更。不要把“Dispatch 到 UI 线程”视为“延迟执行”;不要仅吞掉 InvalidateVisual 异常而继续在枚举中修改集合。Headless 剧情测试和 Avalonia 启动验证不覆盖真实粒子耗尽的 GUI 帧路径。

## 关联位置

- `src/Presentation/GalNet.Avalonia.Rendering/Scene/SceneLayerHost.cs`
- `src/Presentation/GalNet.Avalonia.Rendering/Scene/ParticleEmitter.cs`
- `src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaParticlePresenter.cs`
- `src/Presentation/GalNet.Avalonia.GameView/Presentation/IAvaloniaUiDispatcher.cs`
34 changes: 28 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,36 @@ jobs:
- 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: 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
run: dotnet format whitespace GalNet.slnx --no-restore --verify-no-changes --verbosity quiet
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
run: dotnet format style GalNet.slnx --no-restore --verify-no-changes --diagnostics IDE0005 --verbosity quiet
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 }
}

desktop-build-and-test:
runs-on: windows-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,5 @@ riderModule.iml
# Project-specific
dev/*
.galnet/
GameTestCase/Output/
GameTestCase/Output-new/
Loading
Loading