Skip to content

Submit vwc ruyoumusic - #2

Merged
star620 merged 34 commits into
mainfrom
submit-vwc-ruyoumusic
Oct 14, 2026
Merged

star620 merged 34 commits into
mainfrom
submit-vwc-ruyoumusic

Conversation

@star620

@star620 star620 commented Oct 14, 2026

Copy link
Copy Markdown
Member

添加插件

  • 项目已加入 Plugin.slnx
  • .csproj 已导入 <Import Project="..\..\template.targets" />
  • 已添加 manifest.json(含插件描述)
  • 已添加 README.md(中文文档)
  • 文件夹名与 AssemblyName 一致
  • 插件可正常编译 (dotnet build Plugin.slnx)

更新插件 / 修复 BUG

  • 已更新版本号
  • 已更新 README.md 中的更新日志
  • 插件可正常编译

注意事项

  • 未提交 bin/、obj/、out/ 目录下的构建产物
  • 未提交 *.dll、*.pdb 文件

协议:Terraria 包 129 探测 / 67 下发(SESSION-PLAY-STOP-VOLUME)/ 15 上报(HELLO-FINISHED-POSITION),固定 260 字节帧,载荷魔数 RYM1,会话随机令牌鉴权。配套管理器面板插件 RuYouMusicPanel;客户端插件待写(README 有完整字节级协议)。

(cherry picked from commit feb89a1af2f605e2cf5cbb19fae87cef4e2ab7d0)
客户端:129 探测回 HELLO、67 命令(SESSION/PLAY/STOP/VOLUME)、MCI 播放 + MusicCache 缓存 + play from 中途对齐、每 2 秒 POSITION、播完 FINISHED;带离线自检桩工程与 pack-rym 打包脚本。服务端:上报需带正确会话令牌(伪造 POSITION/FINISHED 实测被拒)。

(cherry picked from commit 78af50b10182633a799a3115767fb155ebcf74b8)
1) 帧内偏移:载荷偏移 N = 帧偏移 3+N。原先把 PLAY/VOLUME 数据写在 frame[16](少 3 字节),
   客户端按载荷偏移解析导致 trackId/音量/歌名全错位(歌名从 UTF-8 中间开始读),
   也拿不到服务端下发的直链。现统一 FrameDataOffset = 3 + PayloadOffset。
   验证:独立程序内服务端写帧→客户端解析,trackId/偏移/时长/音量/歌名/歌手/直链全部正确。
2) 重复 PLAY:登录探测后未记 ProbeAt,周期探测立刻重探 → 客户端两次 HELLO → 两次 PLAY →
   二次下载同一文件互相覆盖删除,表现为「播几秒后失败」。服务端记 ProbeAt;
   客户端同曲去重 + 只有失败的是当前曲且未在播时才删文件/上报 FINISHED。
3) 直链解析带重试与多候选(同名可播放版本兜底);/music/search?check=1 标注 playable;
   实测 joox 音源 types=url 一律返回空,默认只走 netease。

(cherry picked from commit 93403a3c9bc054b77babe6e11deb0cb60b849f10)
服务端:播放/入队/握手时把队列下一首的 id+直链 以 PREFETCH(6) 帧发给客户端;客户端后台下载进 MusicCache(不碰播放状态,一次只预取一首),切歌时命中缓存直接 MCI 起播。README 记录帧偏移与重复 PLAY 两个坑。

(cherry picked from commit 532ff69f82e6b95650a59aca902882971d37a9e4)
1) OnPreUpdate 原来只要 gameMenu 为真就 StopAll,而进服/切世界时菜单态是暂时的,正好把刚收到的 PLAY 掐掉(玩家刚进服最容易没声音)→ 改为连续 3 秒在菜单才停播。2) 预取改为记账式:先记下待预取曲,等当前曲下载/起播完成后再跑(一次一首),避免和正在播的歌抢带宽。

(cherry picked from commit e002d3dfbc317d37ce50236d6e7f8d9c945596f9)
协议新增 C→S UpEvent(5):kind(开始下载/下载完成/下载失败/播放中/起播失败/预取完成/预取失败) + trackId + 文本原因。事件放在令牌校验之前(只是状态信息,伪造无意义),控制类上报仍必须带令牌。服务端 /music/clients 增加 lastEvent/lastEventFailed/lastEventMs/lastMessage,/music/status 汇总 failures。

(cherry picked from commit ad154b65d477599a4fdca33c943aec53ef0e0fc0)
1) 新增 /music/failures:记录解析失败与客户端上报的下载/起播失败(谁、哪首、哪一步、原因),面板可直接看,不用翻日志;2) 直链解析改用搜索结果的 url_id(部分歌与 id 不同)并依次尝试 320/128 码率;3) 客户端重试 3 次→1 次(原来一首卡住≈8 秒拖死队列),失败立刻上报让服务端跳下一首。

(cherry picked from commit c18943b06dd58f47e00e51284defda7ddc0baae7)
公开接口能力已退化:music-api-cn/hk/us 节点 DNS 已不存在,国际版只剩 netease+joox(joox 直链为空),music.gdstudio.org 同源 API 在 Cloudflare 后且需前端私有签名(非浏览器 401)。因此把取链抽象成可配置:apiBase 指向任意能出直链的服务,apiHeaders 支持带 Cookie/Referer 等头,apiSign=1 按前端约定追加 &s=crc32(...),apiExtra 追加自定义参数。

(cherry picked from commit 6277a8e6787c1b756ea7308ab9f63bdb56bf9122)
1) 面板 check=1 搜索原来一条结果 2 个请求、8 条就 16 个,把公共接口打到 503(实测 12 连发即出现 HTML 错误页)→ 加全局串行节流(300ms)+失败退避+解析缓存(直链 15 分钟/失败 5 分钟),搜索解析过的直链点歌时直接复用,实测重复搜索 5830ms→934ms(6.2x);2) 直链为空时调 types=autosource 让服务端按 歌名/歌手 换源(网页能放所谓 VIP 歌就是靠它),带一次重试;3) 新增 gateway/ 自建取链网关(零依赖 Node):browser 模式用本机 Edge 无头打开站点,在页面里用站点自己的 crc32 与会话调它的同源 api.php,可过 Cloudflare 并拿到 autosource 的 joox 直链。

(cherry picked from commit db787dc11b8e4703265c075969767ca1444f090b)
1) 队列里等久的歌直链会过期(网易云带时间戳、joox vkey 更短命),播放前解析超过 10 分钟就重解析,避免下发失效直链;2) 老客户端不上报事件但会发 FINISHED/POSITION,播放位置远小于总时长即记为「客户端提前结束」,面板最近失败可见。

(cherry picked from commit 3c8c09cb4e42c9395056a2ea03ed06912c236896)
歌名+歌手占掉大半后,260 字节帧里直链只剩约 160 字节,稍长的 URL(如『欸?大狗!』那条 172 字节)被静默截断,客户端请求截断的 URL 就得到 400/403 —— 这就是有些歌能放、有些必然失败的原因。帧扩到 1024;服务端字符串放不下时打警告;客户端丢弃疑似截断的直链并回退自解析。

(cherry picked from commit b7fa845457b4a6c23e43d83ea629b49744b6d2c2)
Music.Update() 只在 PlayerUpdate(游戏内)调用,而 StopAll 只是把停止请求挂进 _pendingStop —— 退回主菜单后没有 PlayerUpdate,MCI 停止永远不执行,音乐就一直响。新增 FlushStop() 并在 PreUpdate 每帧落地;同时区分『在服里的瞬态菜单』(等 3 秒)与『已离开服务器 netMode 回 0』(立即停)。

(cherry picked from commit fbbc6856e7e1a8470bb7ce14ea4e3a486a7f91c7)
PotServer 覆盖 CreateSurfaceConsole() 返回 HeadlessServerSurfaceConsole。

动机(2026-10-13 远端实测):每个世界的控制台窗口会额外拉起一个
UnifierTSL.ConsoleClient 进程,代价为常数约 26.5MB 私有 / 48.3MB 工作集每世界,
与世界尺寸无关;壶世界不需要交互控制台(日志照常进主日志),
6 个壶合计可省约 290MB 工作集。

(cherry picked from commit 9543d0a5313f6dd2fe77200281e7656a7ad83014)
(cherry picked from commit 94186b8b01be5049b62f5501a4c37ed4983546cd)
两个真 bug:
  · 出生平台只在首次播种时创建,之后加载历史世界文件时不再检查 -> 改为每次进入都确保平台存在
  · Prepared 标记记在内存对象上,世界销毁后重进时又是 false -> 把刚加载回来的存档又清空了;
    改为以"本次是否刚刚播种种子文件"为判据

健壮性与能力:
  · OwnerKey:优先账号 UUID;原来用 Guid.TryParse 判断,但 Terraria 的 ClientUUID 是长十六进制串、
    不是 GUID,会被误判丢弃 -> 改为只判非空。世界文件名 = 尘歌壶_<玩家名>_<UUID前12>
  · ShortKey 只保留字母数字(避免冒号等字符进入文件名)
  · File.Copy 失败时回退为流式复制
  · 新增配置「世界目录」:启动时覆盖 Main.WorldPath。某些环境下默认的
    Documents\My Games\Terraria\Worlds 不可写(UnauthorizedAccessException,连临时文件都建不了),
    而 Terraria 原生的 -worldpath= 参数在 UTSL 下不生效(UTSL 自己解析启动参数)-> 只能由插件覆盖
  · 目录可写性自检:写不进去时明确告警(成功时不再刷日志)

实测:/cg 进入 -> 空白平台 -> 摆方块 -> exit -> 闲置销毁 -> 再进 -> 方块与平台均在;
      世界文件 尘歌壶_星梦_acct6F133E23.wld 落在自定义世界目录

(cherry picked from commit a3cdc172344d151ec296f5b9973e2e10f53d8db0)
核心(新服务插件 VirtualWorldCore,0.1.0)只做与玩法无关的不变式:
- 实例注册表(key -> 实例,占用者/最后活跃/玩法状态)
- 生命周期:创建(种子复制)/ 加载(OnLoaded)/ 销毁(存档+悬空清理+Close+RemoveServer+双保险)
- 玩家进出封装:EnterAsync/LeaveAsync + 三条铁律
  (转移生效才记账 / 以权威数据源判人数 / 不对在线玩家误触生命周期钩子)
- 看护与诊断:SelfHeal/Reconcile/StateCheck/转移诊断(瞬间与 +4s)
- 返回路径用栈实现,支持 主世界->自己的壶->他人壶->退出->退出

PocketWorld 只留玩法:访问权限/上限/世界名与文件名/种子选择/出生平台与清空策略
(OnLoaded/OnBeforeEnter)/聊天互通/状态同步/闲置计时与卡死保护。

服务发现:BeforeGlobalInitialize(IPluginContainer[])(DotnetPluginHost.Initialize.cs:26-33),
与初始化顺序无关;PocketWorld 编译期引用核心接口但不复制其 DLL(避免版本地狱)。

实测(2026-10-14 17:17 会话日志,无 Error/Warning/Exception):
- 游客退服后 /cg status 立即显示(空),/cg visit 直接进入
- /cg exit 双段退出、壶内卸载后按需重建、上限/自愈/诊断均正常

(cherry picked from commit f0d58ed202cfc84b41dcd90bdd19937a5aa325f1)
目的:用一个全新玩法验证 VirtualWorldCore 的抽象是否够用。
结论:★ 核心一行未改 —— 只用公开接口就建起来了。

玩法:/ba 进公共场地 / /ba exit 返回 / /ba reset 重置 / /ba status 查看
- 公共实例(OwnerKey 为空),常驻不回收(IdleDestroySeconds=0)
- 模板:BuildSpawnPlatform(60) 地面 + 两侧 2 格短墙(Main.tile 直写)
- 重置时机:OnBeforeEnter 里判断场地是否为空(以世界玩家表为准),空闲/新建即清空重铺

已知待完善(非阻塞,按用户建议放到后续):
- /ba reset 目前只清图格与重铺模板:不清掉落物、不广播、不断线刷新客户端图格
- 用户建议方案:重置时 15s 倒计时(可配)→ 把玩家送回主世界 → 清地形+清掉落 → 广播

(cherry picked from commit 7709cfde089ea20cd0959772ba43f33c04152149)
O1 契约与实现分离:
- 新增 VirtualWorldCore.Abstractions 程序集(只放接口/DTO,无实现、无 [PluginMetadata])
  IVirtualWorldCore / IVirtualWorld / VirtualWorldRequest / IVirtualWorldPolicy / IVirtualWorldCoreProvider
- 玩法插件(PocketWorld/BossArena)改为【只引用 Abstractions】;核心实现升级不再需要重编玩法

O2 稳定服务发现 + 版本校验:
- 玩法发现改为 `c.Plugin is IVirtualWorldCoreProvider`,源码里不再出现核心插件的具体类型名
- 新增 VirtualWorldApi.Version,玩法发现时校验并在不匹配时自禁用(日志给出明确原因)

类型身份(关键):
- VirtualWorldCore 标 [CoreModule];PocketWorld/BossArena 标 [RequiresCoreModule(VirtualWorldCore)]
  → UTSL 把玩法加载进核心的 ALC(ModuleAssemblyLoader.cs:249),保证契约程序集全服唯一身份
- 部署布局:plugins/VirtualWorldCore/{VirtualWorldCore,PocketWorld,BossArena}.dll + lib/VirtualWorldCore.Abstractions.dll
  (ModuleLoadContext.cs:129-134 从模块目录的 lib/ 解析依赖;lib/ 不会被当成模块扫描)

实测:19 插件全部初始化,无 Error/Warning/Exception;两处日志均显示「契约 v1」

(cherry picked from commit 03ec0b80cded89589ae52990d27098e44d40e4d9)
问题(用户实测):
- 在壶里按 /ba → BossArena 只判「是否在某个假世界」,提示写成「请先 /ba exit」(玩家并不在场地里)
- 在 Boss 场地里按 /cg → PocketWorld 只判「是否在某个假世界」,谎报「你已经在自己的壶里了」

根因:两个玩法都用 core.FindByOccupant(idx) != null 判「我在不在假世界」,
      但核心只管占用、不区分归属,玩法必须自己判断「那个世界是不是我的」。

修复:
- PocketWorld: IsMyPot(w) = Pots.ContainsKey(w.Key);进入/离开/聊天转发全部改用该判定
- BossArena:   IsArena(w) = w.Key == arena:boss;进入/离开改用该判定
- 提示改为【指名世界 + 正确退出指令】:如「你当前在「X的尘歌壶」里,请先退出那里再进 Boss 场地」

未做(可作为后续 O 系列的一部分):给契约加 Group/归属字段,
让玩法不必靠自己的字典猜归属,并为「按玩法分组的上限」打基础。

(cherry picked from commit 73098842bf51161dc169972266ea585967c03118)
契约 v2(Abstractions):
- VirtualWorldRequest.Group / IVirtualWorld.Group —— 玩法标识(约定用插件名)
  核心只透传字符串;玩法用它判断世界归属 + 做互通白名单

核心(VirtualWorldCore):
- EnsureCore 写入 Group
- ★ DestroyAsync 新增不变式:某世界若仍挂在任何玩家的【返回栈】上,拒绝销毁
  (否则 壶->/ba 场地 期间,壶因无人被闲置回收 -> /ba exit 回不去)

玩法(PocketWorld / BossArena):
- 各自 settings.json 新增互通配置:
    互通_允许从假世界进入 (默认 true)
    互通_允许的来源 (默认 [],填玩法标识/插件名;空=不限)
- 请求带 Group;新增 IVirtualWorldPolicy(CanJoin 用互通判定)
- 预检提示改为互通感知(不允许时才提示先退出)

实测:契约 v2,19 插件全部初始化,无 Error/Warning/Exception

(cherry picked from commit e2703c9184032e2111f835fcd4b43daa478227c7)
契约 v3(Abstractions):
- 新增 VirtualWorldResult { Success, Reason(VirtualWorldFailure), Message }
- EnterAsync / LeaveAsync / DestroyAsync 由 Task<bool> 改为 Task<VirtualWorldResult>

核心(VirtualWorldService):
- 每个失败点都给出原因码 + 说明,而不是只回 false:
    NotLoaded / NotRunning / SameWorld / PolicyRejected /
    TransferRejected / Occupied / OnReturnPath / Exception

玩法:
- PocketWorld/BossArena 把 bool 判断改为 result.Success,
  失败提示直接带核心给的原因(如「返回失败:世界转移被拒绝(生效复核不通过)」)
- IdleTick 的销毁判定改用 destroy.Success(OnReturnPath/Occupied/PolicyRejected 都能区分)

实测:契约 v3,19 插件全部初始化,无 Error/Warning/Exception

(cherry picked from commit 9eb8efc874b7efd3e62030b6b19caafd22574db1)
契约 v4(Abstractions):
- 新增 VirtualWorldTransferArgs { Player, World, IsEnter, Cancel, CancelReason }
- 新增 VirtualWorldDestroyArgs { World, Cancel, CancelReason }
- IVirtualWorldCore 新增事件:Transferring(转移前,可否决)/ Transferred(转移后)/ Destroying(销毁前,可否决)
- VirtualWorldFailure 新增 EventCanceled

核心(VirtualWorldService):
- EnterAsync / LeaveAsync 在真正搬人之前触发 Transferring;任一监听方置 Cancel 就放弃并返回 EventCanceled
- 转移成功后触发 Transferred(不可否决)
- DestroyAsync 在 Close 之前触发 Destroying;否决则保留实例并重置闲置计时

BossArena:
- 订阅 Transferring 作为演示/自测;由配置 调试_否决进入(默认 false)控制,
  开启后否决所有进入场地的请求 —— 用于真机验证这条路径

实测:契约 v4,19 插件全部初始化,无 Error/Warning/Exception

(cherry picked from commit 0d33490c88eccb5da3796adaf5d50af83df37eaf)
思路来自 HouseRegion(区域/所有者)与 CreateSpawn(模板化),但**只借思路、不搬代码**:
它们的实现绑死 TShock/LazyAPI/MonoMod 钩子/单世界假设,不适合我们的 UTSL。

配置(config/PocketWorld/settings.json):
- 家园_活动区域_启用 / 初始半径(40) / 每级增量(20) / 最大半径(200)
- 检查间隔毫秒(1000) / 回传落点偏移(2)
玩家状态(players.json):活动区域等级

实现(全部用我们自己的核心接口):
- OnBeforeEnter:按当前半径 BuildSpawnPlatform(halfW) + 铺左右边界墙(Main.tile 直写)
- AreaTick(1s 定时):只查「有人的已加载壶」,Post 到该世界线程
- EnforceArea(世界线程):越界即拉回中心 + 本世界发包(13=PlayerUpdate) + 提示 + 日志
- 新增 /cg area(查看)与 /cg area upgrade(升级,立即重铺)

实测:启动无 Error/Warning/Exception;新配置项已生成

(cherry picked from commit 103519c041c6688853de4b1c704394914f80e43b)
按用户要求重做:
- 默认基础范围 150x150(边长 = 配置数组 第0项)
- 圈墙用石板(图格ID 273)+ 反色漆(油漆ID 30),两者都可配置
- 越界立即传送(检查间隔默认 300ms),不再有任何提前预警
- 破坏立即恢复:每轮校验圈墙(材质+漆),缺/错就地补回,并只把修复格同步给本世界玩家
- 升级消耗资源:升级消耗物品ID / 基础数量 / 每级递增(0 = 不消耗),不足则拒绝并提示
- 每级大小由服主自定义(各级边长数组);加载时强制非递减(下一级不得小于上一级)

实现要点:
- 圈墙 = 左右竖墙 + 天花板;地面由 BuildSpawnPlatform 负责
- 全部在本世界线程上执行(Dispatcher.Post)
- 图格漆用 tile.color(byte);修复同步用 TSPlayer.SendTileSquareCentered(只发本世界玩家)
- 扣除物品后用 ctx.NetMessage.SendData(5=PlayerSlot) 同步背包

实测:启动无 Error/Warning/Exception;配置项正确生成;壶存档未被破坏

(cherry picked from commit b905d1f92146a7c99c13c97e3da40d379b603987)
用户实测反馈逐条修复:
1) 地板不是石板 -> EnumerateAreaWalls 现在把地板行也算进圈墙(石板+反色漆)
2) 破坏即修复会掉物块刷物品 -> 新增 NetPacketHandler.Register<TileChange>:
   属于活动区域边界的图格改动直接 HandleMode=Cancel + 回滚客户端图格,
   物块根本不破、不掉落(即「没权限破坏」的效果)
3) 越界不传送(日志却说回传了)-> 根因:只发 13 号 PlayerUpdate,客户端不认;
   改用 TSPlayer.Teleport()(内部 CheckSection + 发 65 号 Teleport 包,TSPlayer.cs:1452-1466)
4) /cg area upgrade 视角不变、旧墙不消失 -> 新增 RebuildArea:
   清掉旧圈中不再属于新圈的格子 + 铺新圈 + 逐格 SendTileSquareCentered 同步给本世界玩家
5) 定时器不优雅 -> 改用 UnifierApi.EventHub.Game.PreUpdate(每世界每帧、世界线程上直接执行,
   不再需要 Dispatcher.Post;参考 TShockAPI ItemBans.cs:53)

说明:150x150 的语义是【边长】(中心左右各 75 格),即直径 150,与用户要求一致。
配置项 家园_活动区域_检查间隔毫秒 已不再使用(保留字段,不破坏既有配置)。

实测:启动无 Error/Warning/Exception,钩子注册成功。

(cherry picked from commit 6872418329059b74fb4549952f3bd3adbdd9d5a7)
① 改名(避免撞名;显示名仍为「尘歌壶」):
   - 项目 src/PocketWorld -> src/MyPotWorld;程序集/命名空间/类型 PocketWorld* -> MyPotWorld*
   - [PluginMetadata] 名称 -> MyPotWorld;VirtualWorldRequest.Group -> MyPotWorld
   - Plugin.slnx、部署 DLL 名同步;配置目录 config/PocketWorld -> config/MyPotWorld(已迁移)
   - 配置目录是按 DLL 文件名取的(DotnetPluginHost.Initialize.cs:90),故必须迁移

② 家园搬到地表(#4):
   - 新增配置 家园_地面Y(默认 500,约等于主世界地表;按区域大小自动夹紧)
   - AreaRect 不再用 maxTilesY/2(那在主世界就是洞穴深度,即用户看到的洞穴位置)

③ 出生点改竖直中部(#3):
   - BuildAreaWalls 里把 Main.spawnTileX/Y 设为(区域水平中心, 地面Y - 边长/2)
   - 后续接入建筑模板后会改为:在活动区域内自适应找 4x4 空地

实测:19 插件全部初始化;宿主报告 插件 MyPotWorld v0.5.0;无 Error/Warning/Exception

(cherry picked from commit 68f311e3a386d3497191752f6a8e7c25aa4b9190)
1) 越界回传落点:改为【活动区域中心】(= 出生点),与 #3 一致;
   不再用 回传落点偏移(那是地面上方 2 格,所以才出现"回到底部")

2) 新增按壶独立的两个开关(存壶主 players.json,默认都开):
   - 禁止刷怪:按世界设 ctx.NPC.defaultSpawnRate = 1000000(TShock 自己也是用这个字段,
     NpcManagementCommands.cs:676);关闭时还原开之前的值。天然只影响该壶世界。
   - 无敌:TSPlayer.GodMode(journey godmode,TSPlayer.cs:940-949);
     只对"本插件开启过的"负责关闭(_grantedGod 记账),不碰玩家自己开的无敌;
     离开壶回到普通世界时自动清除(在每帧事件里对非壶世界做清理)
   - 命令:/cg mob(禁止刷怪)、/cg god(无敌);访客按【壶主】配置生效

3) 说明:所谓"重置世界"之前只删了壶存档,没有重置 活动区域等级(在 players.json 里),
   所以用户测试升级后仍是 330x330。本次已一并重置为 0(150x150)。

实测:19 插件全部初始化;宿主报告 插件 MyPotWorld v0.5.0;无 Error/Warning/Exception

(cherry picked from commit 904e8f3669862f5450bac887a10cdbe1f94e1f72)
之前只拦了活动区域边界那一圈(TileChange);现在扩到【区域外全部】:
- 判定从 IsOnAreaBoundary 改为 !IsInsideArea(严格内部才允许动土)
- 新增 LiquidUpdate 包拦截(NetPacketHandler.Register<LiquidUpdate>):
  区域外倒液体同样 Cancel + 回滚客户端;字段 TileX/TileY 见 TShockAPI Bouncer.cs:2130-2138
- 取消时补上 StopPropagation,并回滚客户端图格(客户端表现=没权限,且不掉落)

说明:内部(严格在 minX..maxX / minY..cy 之内)仍可正常挖放,只有边界与外边被拦。

实测:19 插件全部初始化;无 Error/Warning/Exception

(cherry picked from commit 2db204aea6b32aaa46118cfe17454bf0fd9782a8)
A(核心轮询):EnsureAsync 等 ctx.IsRunning 用的是 Thread.Sleep(500),
  每次加载平均白等约 250ms(最坏 500ms)-> 改成 25ms。

B(模板基线,核心层,契约 v5):
  - VirtualWorldRequest 新增 TemplateKey(空 = 不启用,行为不变)
  - 建世界时若目标文件不存在:优先复制【同尺寸模板基线】(很小的文件),
    没有才回落到原种子路径(复制完整主世界 Dev.wld 6.9MB);
  - 首次创建的实例在 OnLoaded 之后 SaveWorld 并缓存为基线(按 TemplateKey + 尺寸区分,
    放在 Worlds/_vwc_templates/<key>_<W>x<H>.wld);
  - 好处:不再复制 6.9MB 主世界,也不再需要"加载后再全图清空";
    对将来的【模板玩法】同样有效(模板世界也能用基线复用)。
  - 实测依据:日志显示首次建壶比首次重载多花约 2 秒,正是因为多解析了一遍完整主世界。

MyPotWorld 已启用 TemplateKey = pot_blank。
实测:4 个工程 0 错误;19 插件全部初始化;两个玩法均识别 契约 v5;无 Error/Warning/Exception

(cherry picked from commit b354f2569080263198fb519ee0eda12a11c557ee)
缓存模板基线时我调用了一次 WorldFile.SaveWorld,但玩法的 OnLoaded 里
ClearCurrentWorld() 内部已经 SaveWorld 过(ServerContext.World.cs:130-135),
重复存盘要序列化整张图(10M 格,实测 ~0.8s)→ 删掉。

实测(请求→世界已加载):
  旧版首次建壶(复制 Dev.wld 6.9MB + 解析全图 + 清空): 4.708s
  本次改动前 + 多余 SaveWorld                        : 5.661s
  走模板基线(复制 15KB 模板)                       : 2.920s   <- B 的收益,省 ~2.7s

(cherry picked from commit 05244bef505a28ee7526c6464ccc087cb4947dd8)
1) 配置 闲置销毁_秒: 120 -> 300(5 分钟)。
   离开壶后 5 分钟内再回来,世界仍在内存 -> 进壶 ~0.43s(实测)。

2) 更正一个我先前的错误结论:我说"闲置销毁=0 会每 tick 疯狂销毁"是【错的】。
   实际上 IdleTick 只由 Timer 触发(第 120 行),而该 Timer 只在 IdleDestroySeconds > 0 时才创建
   -> 0 本来就已经是常驻。本次只是补一个防御判断 neverIdleDestroy(防将来有人改了定时器条件),
   并把启动日志改成:>0 显示秒数、<=0 显示"常驻"。

3) 配置注释里的内存估算 100~170MB 改为实测值:每个常驻壶 4200x2400 约 220~260MB。

实测依据(22:04-22:07 采样):单个壶 RSS +222MB / 私有 +257MB;销毁后私有 2181.9->1925.2MB,
线程 29->25、句柄无累积 -> 无泄漏。

(cherry picked from commit 236777c366f482baf5cd1dfca3c1760eeae7e684)
@star620
star620 merged commit dce7c3d into main Oct 14, 2026
4 checks passed
@star620
star620 deleted the submit-vwc-ruyoumusic branch October 14, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant