DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
+55
@@ -0,0 +1,55 @@
|
||||
# ===== Python (launcher / gmtool) =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
|
||||
# ===== Go (server / pvf) =====
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
/dist/
|
||||
/bin/
|
||||
vendor/
|
||||
|
||||
# ===== PyInstaller 打包产物 =====
|
||||
launcher/build/
|
||||
launcher/dist/
|
||||
*.spec.bak
|
||||
|
||||
# ===== C# (loadout-tool) =====
|
||||
loadout-tool/bin/
|
||||
loadout-tool/obj/
|
||||
loadout-tool/publish/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# ===== C++ (client-patch) =====
|
||||
client-patch/Release/
|
||||
client-patch/Debug/
|
||||
client-patch/x64/
|
||||
client-patch/.vs/
|
||||
*.vcxproj.user
|
||||
|
||||
# ===== 运行时数据 / 生成物 =====
|
||||
# 物品名称表(由 pvfexport 从 Script.pvf 导出,不提交)
|
||||
launcher/dnf_items.json
|
||||
*.pvf
|
||||
*.log
|
||||
|
||||
# ===== IDE / 编辑器 =====
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,119 @@
|
||||
# DNF90 单机模拟器 · 源码仓库
|
||||
|
||||
一个 DNF90 单机模拟器(非私服)的完整源码 monorepo,包含**服务端(Go)**、**客户端补丁(C++)**、**GM 管理工具 / 启动器(Python)**、**一键换装工具(C#)**,以及**本地 PVF 解析套件(Go)**。
|
||||
|
||||
> 本仓库只含源码,不含编译产物(exe/dll)与美术/PVF 资源。clone 后需按各自子目录的说明编译。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
DNF90-Source/
|
||||
├── server/ DNF90 服务端(Go,模块 longheng.io/server)
|
||||
├── client-patch/ 客户端补丁(C++,90CN.dll 源码)
|
||||
├── loadout-tool/ 一键换装工具(C# / .NET)
|
||||
├── launcher/ 整合启动器 + GM 管理工具(Python + pywebview)
|
||||
├── gmtool/ GM 工具旧版(Python + FastAPI,已被 launcher 取代,保留参考)
|
||||
├── pvf/ PVF 解析套件(Go,模块 pvfine,GPL-3.0)
|
||||
│ └── cmd/pvfexport/ 物品名称表导出工具
|
||||
└── docs/ 开发文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 子项目速览
|
||||
|
||||
| 目录 | 语言 | 模块/技术栈 | 说明 |
|
||||
|------|------|------------|------|
|
||||
| `server/` | Go 1.26 | `longheng.io/server` | DNF90 服务端,含 GM 拍卖行接口、自动补足上架、背包管理 |
|
||||
| `client-patch/` | C++ | MSVC / vcxproj | 90CN 客户端补丁,含安图恩兼容、蛇腹剑修复等 |
|
||||
| `loadout-tool/` | C# | .NET | 一键换装工具,通过 Admin API 操作 |
|
||||
| `launcher/` | Python 3.15 | pywebview + PyInstaller | 单 EXE 启动器 + 内置 GM 工具(双数据源) |
|
||||
| `gmtool/` | Python | FastAPI | GM 工具旧版(独立服务 + 网页) |
|
||||
| `pvf/` | Go 1.26 | `pvfine`(GPL-3.0) | PVF 解析库 + `pvfexport` 物品名导出工具 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 服务端(server/)
|
||||
|
||||
要求 Go ≥ 1.26(`go.mod` 声明 1.26.5)。建议配置国内镜像:
|
||||
|
||||
```bash
|
||||
go env -w GOPROXY=https://goproxy.cn,direct
|
||||
go env -w GOSUMDB=sum.golang.google.cn
|
||||
|
||||
cd server
|
||||
go build -buildvcs=false -trimpath -o DNF90Server.exe ./cmd/server/dnf90
|
||||
go build -buildvcs=false -trimpath -o DNF90Control.exe ./cmd/server/control
|
||||
go build -buildvcs=false -trimpath -o DNF90Doctor.exe ./cmd/server/doctor
|
||||
```
|
||||
|
||||
详见 `server/README.md`。
|
||||
|
||||
### 2. GM 启动器(launcher/)
|
||||
|
||||
要求 Python 3.15(32 位)+ Windows。打包:
|
||||
|
||||
```bash
|
||||
cd launcher
|
||||
python -m pip install pywebview pythonnet clr-loader pyinstaller
|
||||
python -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
|
||||
```
|
||||
|
||||
产物在 `launcher/dist/DNF90启动器.exe`。详见 `docs/开发文档.md`。
|
||||
|
||||
### 3. PVF 解析(pvf/)
|
||||
|
||||
```bash
|
||||
cd pvf
|
||||
go build ./cmd/pvfexport/ # 物品名称表导出工具
|
||||
pvfexport -pvf Script.pvf -out dnf_items.json -compact
|
||||
```
|
||||
|
||||
详见 `pvf/README.md`。
|
||||
|
||||
### 4. 客户端补丁(client-patch/)与换装工具(loadout-tool/)
|
||||
|
||||
详见各自说明,编译命令在 `docs/开发文档.md` 和原 README 中。
|
||||
|
||||
---
|
||||
|
||||
## 关键设计说明
|
||||
|
||||
### GM 工具双数据源
|
||||
|
||||
GM 工具(launcher)支持两种数据访问方式,服务端在线时走 Native API(事务一致),离线时走 SQLite 直读:
|
||||
|
||||
- **Native API**:`127.0.0.1:18111` + `X-Admin-Token`,实时操作
|
||||
- **SQLite 直读**:直接读写 `Server/runtime/data/dnf90.db`
|
||||
|
||||
### 物品名称离线显示
|
||||
|
||||
`pvf/cmd/pvfexport` 从 `Script.pvf` 提取 `item_id → 名称` 全量映射,导出 `dnf_items.json`。GM 工具放在 `GM配置/` 目录,启动时对比 PVF 与名称表的修改时间,PVF 更新则自动重新导出,实现离线也能显示物品中文名。
|
||||
|
||||
### GM 拍卖行
|
||||
|
||||
服务端已实现 GM 拍卖行接口(`/local/dnf-gm/auction/*`),支持:
|
||||
- **免押金上架**(`Deposit=0`)
|
||||
- **自动补足上架**(背包不足时凭空发放差额再上架)
|
||||
- **列背包 / 删除背包行**(含穿戴装备)
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
- `server/`、`client-patch/`、`loadout-tool/`、`launcher/`、`gmtool/`:**私有/闭源**,请勿对外公开。
|
||||
- `pvf/`:来自开源项目 [pvfine](https://github.com/) ,遵循 **GPL-3.0**(见 `pvf/LICENSE`)。若需合并/分发,注意 GPL 传染性。
|
||||
|
||||
> ⚠️ 对外发布服务端时,不要附带本机测试用的深渊掉落修改;远程换装工具不要直接公开 Admin 端口。
|
||||
|
||||
---
|
||||
|
||||
## 贡献与开发
|
||||
|
||||
1. 各子项目独立编译,无跨项目构建依赖。
|
||||
2. 提交时请遵守各自目录的 `.gitignore`,勿提交编译产物与 `dnf_items.json`。
|
||||
3. 服务端改动涉及 GM 接口时,同步更新 `docs/开发文档.md` 的 API 契约章节。
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>16.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{A6828B55-5DD9-42F6-8B67-E1B036BE0727}</ProjectGuid>
|
||||
<RootNamespace>DebugCompat90CNSmoke</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v142</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
<TargetName>90CN-debug-smoke</TargetName>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<OutDir>$(ProjectDir)$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)$(Configuration)\90CN-debug-smoke\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<BufferSecurityCheck>false</BufferSecurityCheck>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="debug-compat\smoke_test.cpp" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>16.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{24B08AC0-5F6E-4B0C-A3E2-11DCE7554C8A}</ProjectGuid>
|
||||
<RootNamespace>DebugCompat90CN</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v142</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
<TargetName>90CN-debug</TargetName>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v142</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
<TargetName>90CN-debug</TargetName>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<OutDir>$(ProjectDir)$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)$(Configuration)\90CN-debug\</IntDir>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<OutDir>$(ProjectDir)$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)$(Configuration)\90CN-debug\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;_DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<BufferSecurityCheck>false</BufferSecurityCheck>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
<ModuleDefinitionFile>debug-compat\90CN-debug.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<BufferSecurityCheck>false</BufferSecurityCheck>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
<ModuleDefinitionFile>debug-compat\90CN-debug.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="debug-compat\dllmain.cpp" />
|
||||
<ClCompile Include="debug-compat\debug_compat.cpp" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
+16316
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,306 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
// Starts the one-time current-EXE cipher proxy after 86JP.dll is loaded by
|
||||
// the normal ijl15 plugin chain.
|
||||
void Start90CNPatch();
|
||||
|
||||
// Returns true when the loaded executable is the separately supported
|
||||
// 2026-10 new client. That executable has a different native layout and
|
||||
// must enter the narrow channel bootstrap worker instead of the production
|
||||
// hook set built for the current NoPack client.
|
||||
bool IsNewClientCompatibilityMode();
|
||||
|
||||
// Starts the new-client channel selector/connector worker. It waits for the
|
||||
// native directory object, selects the configured local channel, and invokes
|
||||
// the client's own endpoint handler; it does not synthesize packets.
|
||||
void StartNewClientChannelCompatibility();
|
||||
|
||||
// 接口名称:Queue90CNClientNotice
|
||||
// 中文说明:把一条 UTF-16 游戏提示复制到 90CN 主线程队列,并唤醒游戏窗口线程。
|
||||
// 参数:text 为不包含内嵌 NUL 的文本;length 为字符数,不包含结尾 NUL,最大 511。
|
||||
// 返回值:TRUE 表示已经安全入队;FALSE 表示接口未就绪、参数无效或队列已满。
|
||||
// 线程约束:调用者不会直接执行游戏 UI;实际提示只在 DNF 主窗口线程中显示。
|
||||
EXTERN_C BOOL WINAPI Queue90CNClientNotice(
|
||||
const wchar_t* text, unsigned int length);
|
||||
|
||||
// 客户端事件类型:只表示已经由当前 EXE 的窗口或场景切换边界可靠确认的状态。
|
||||
enum DNF90ClientEventType : unsigned int
|
||||
{
|
||||
DNF90_CLIENT_EVENT_UI_READY = 1,
|
||||
DNF90_CLIENT_EVENT_UI_CLOSED = 2,
|
||||
DNF90_CLIENT_EVENT_ENTER_TOWN = 3,
|
||||
// 事件名称:enter_dungeon
|
||||
// 中文说明:当前 EXE 完成副本信息、首张地图和当前角色激活后产生一次。
|
||||
DNF90_CLIENT_EVENT_ENTER_DUNGEON = 4,
|
||||
// 事件名称:dungeon_room_changed
|
||||
// 中文说明:副本活动态接受一个与上次不同的完整 op29 房间后产生一次。
|
||||
DNF90_CLIENT_EVENT_DUNGEON_ROOM_CHANGED = 5,
|
||||
// 事件名称:leave_dungeon
|
||||
// 中文说明:副本活动态接受最终回城 op24 时,在 enter_town 前产生一次。
|
||||
DNF90_CLIENT_EVENT_LEAVE_DUNGEON = 6,
|
||||
};
|
||||
|
||||
// 上下文标志名称:DNF90ClientEventContextFlag
|
||||
// 中文说明:说明追加字段中哪些值已经由当前 EXE 的完整 typed 包可靠确认。
|
||||
enum DNF90ClientEventContextFlag : unsigned int
|
||||
{
|
||||
DNF90_CLIENT_EVENT_CONTEXT_DUNGEON_VALID = 1u << 0,
|
||||
DNF90_CLIENT_EVENT_CONTEXT_ROOM_VALID = 1u << 1,
|
||||
DNF90_CLIENT_EVENT_CONTEXT_PREVIOUS_ROOM_VALID = 1u << 2,
|
||||
DNF90_CLIENT_EVENT_CONTEXT_BOSS_ROOM = 1u << 3,
|
||||
};
|
||||
|
||||
// 兼容常量名称:DNF90_CLIENT_EVENT_V1_SIZE
|
||||
// 中文说明:旧版事件只有前五个字段;新旧 DLL 通过该固定前缀双向兼容。
|
||||
constexpr unsigned int DNF90_CLIENT_EVENT_V1_SIZE =
|
||||
5u * sizeof(unsigned int);
|
||||
|
||||
// 结构名称:DNF90ClientEvent
|
||||
// 中文说明:90CN.dll 复制到固定容量队列中的客户端生命周期事件。
|
||||
// 字段:size 为结构大小;type 为 DNF90ClientEventType;sequence 为进程内递增编号;
|
||||
// processID 为客户端进程;uiThreadID 为确认该事件的 DNF UI 线程;
|
||||
// contextFlags 决定后续副本字段是否有效;previousRoom* 只用于换房事件。
|
||||
struct DNF90ClientEvent
|
||||
{
|
||||
unsigned int size;
|
||||
unsigned int type;
|
||||
unsigned int sequence;
|
||||
unsigned int processID;
|
||||
unsigned int uiThreadID;
|
||||
unsigned int contextFlags;
|
||||
unsigned int dungeonID;
|
||||
unsigned int roomX;
|
||||
unsigned int roomY;
|
||||
unsigned int roomLayerFlag;
|
||||
unsigned int mapID;
|
||||
unsigned int previousRoomX;
|
||||
unsigned int previousRoomY;
|
||||
unsigned int previousRoomLayerFlag;
|
||||
unsigned int previousMapID;
|
||||
};
|
||||
|
||||
static_assert(sizeof(DNF90ClientEvent) == 60,
|
||||
"DNF90ClientEvent append-only ABI size changed unexpectedly");
|
||||
|
||||
// 接口名称:Dequeue90CNClientEvent
|
||||
// 中文说明:从 90CN.dll 的固定容量队列中取出一条客户端生命周期事件。
|
||||
// 参数:output 指向调用者提供的可写结构;outputSize 至少为 DNF90_CLIENT_EVENT_V1_SIZE。
|
||||
// 旧调用者只取得 20 字节稳定前缀;当前调用者取得完整追加字段。
|
||||
// 返回值:TRUE 表示取出一条完整事件;FALSE 表示参数无效或当前队列为空。
|
||||
// 线程约束:本接口只复制事件数据,不调用 Lua,也不执行游戏 UI。
|
||||
EXTERN_C BOOL WINAPI Dequeue90CNClientEvent(
|
||||
DNF90ClientEvent* output, unsigned int outputSize);
|
||||
|
||||
// 有效标志名称:DNF90CharacterStatSnapshotFlag
|
||||
// 中文说明:标记角色属性快照中已经由当前 EXE 的完整 class0/op2 包可靠取得的数据。
|
||||
enum DNF90CharacterStatSnapshotFlag : unsigned int
|
||||
{
|
||||
DNF90_CHARACTER_STATS_BASE_VALID = 1u << 0,
|
||||
DNF90_CHARACTER_STATS_SPEED_VALID = 1u << 1,
|
||||
};
|
||||
|
||||
// 结构名称:DNF90CharacterStatSnapshot
|
||||
// 中文说明:从当前 EXE 已成功处理的 92 字节角色状态块中复制出的只读快照。
|
||||
// 字段:generation 每次收到有效 mode1/mode3 状态块时递增;四维已经还原为游戏数值,
|
||||
// 不再保留线上协议使用的十倍缩放;速度字段保持当前协议中的原始整数单位。
|
||||
struct DNF90CharacterStatSnapshot
|
||||
{
|
||||
unsigned int size;
|
||||
unsigned int generation;
|
||||
unsigned int validFlags;
|
||||
unsigned int hpMax;
|
||||
unsigned int mpMax;
|
||||
int strength;
|
||||
int vitality;
|
||||
int intelligence;
|
||||
int spirit;
|
||||
unsigned int moveSpeed;
|
||||
unsigned int attackSpeed;
|
||||
unsigned int castSpeed;
|
||||
};
|
||||
|
||||
static_assert(sizeof(DNF90CharacterStatSnapshot) == 48,
|
||||
"DNF90CharacterStatSnapshot append-only ABI size changed unexpectedly");
|
||||
|
||||
// 接口名称:Query90CNCharacterStatSnapshot
|
||||
// 中文说明:读取最近一次可靠角色属性快照,供独立 Lua DLL 计算战斗力。
|
||||
// 参数:output 指向调用者提供的可写结构;outputSize 必须能容纳当前完整结构。
|
||||
// 返回值:TRUE 表示已复制有效快照;FALSE 表示尚未收到角色数据或参数无效。
|
||||
// 线程约束:只在 SRW 锁内复制普通数据,不读取游戏对象,也不执行游戏 UI。
|
||||
EXTERN_C BOOL WINAPI Query90CNCharacterStatSnapshot(
|
||||
DNF90CharacterStatSnapshot* output, unsigned int outputSize);
|
||||
|
||||
// 装备快照常量名称:DNF90_EQUIPMENT_SNAPSHOT_MAX_ITEMS
|
||||
// 中文说明:当前 EXE 的穿戴对象位图覆盖槽位 0~32;快照按已出现对象保存,
|
||||
// 因而最多需要 33 条,不会读取或虚构这个范围以外的槽位。
|
||||
enum : unsigned int
|
||||
{
|
||||
DNF90_EQUIPMENT_SNAPSHOT_MAX_ITEMS = 33,
|
||||
};
|
||||
|
||||
// 有效标志名称:DNF90EquipmentSnapshotFlag
|
||||
// 中文说明:标记装备快照已经由当前 EXE 完整接受的 class0/op2 mode1/mode3
|
||||
// 穿戴对象创建列表可靠取得。
|
||||
enum DNF90EquipmentSnapshotFlag : unsigned int
|
||||
{
|
||||
DNF90_EQUIPMENT_SNAPSHOT_ROWS_VALID = 1u << 0,
|
||||
};
|
||||
|
||||
// 结构名称:DNF90EquippedItemSnapshot
|
||||
// 中文说明:一件已穿戴对象的只读线协议摘要。
|
||||
// 字段:actorSlot 是当前 EXE 的运行时穿戴槽;upgradeLevel 来自 extData 低五位;
|
||||
// amplifyType 与 amplifyValue 来自同一条已验证 0x77 状态投影。
|
||||
// 边界:itemID/qualitySeed 只作为身份与换装变化证据;PVF 词条由服务端权威投影提供,
|
||||
// Lua 不凭线协议 ID 猜稀有度或词条。
|
||||
struct DNF90EquippedItemSnapshot
|
||||
{
|
||||
unsigned int itemID;
|
||||
unsigned int qualitySeed;
|
||||
unsigned short durability;
|
||||
unsigned short amplifyValue;
|
||||
unsigned char actorSlot;
|
||||
unsigned char upgradeLevel;
|
||||
unsigned char amplifyType;
|
||||
unsigned char reserved;
|
||||
};
|
||||
|
||||
static_assert(sizeof(DNF90EquippedItemSnapshot) == 16,
|
||||
"DNF90EquippedItemSnapshot append-only ABI size changed unexpectedly");
|
||||
|
||||
// 结构名称:DNF90EquipmentSnapshot
|
||||
// 中文说明:最近一次本地角色完整穿戴对象创建列表的固定容量只读副本。
|
||||
// 字段:generation 每次接受完整列表时递增;sourceStatGeneration 对应同包属性版本;
|
||||
// itemCount 仅说明 items 前多少条有效,允许为零以表示确实没有穿戴对象。
|
||||
struct DNF90EquipmentSnapshot
|
||||
{
|
||||
unsigned int size;
|
||||
unsigned int generation;
|
||||
unsigned int sourceStatGeneration;
|
||||
unsigned int validFlags;
|
||||
unsigned int itemCount;
|
||||
DNF90EquippedItemSnapshot items[DNF90_EQUIPMENT_SNAPSHOT_MAX_ITEMS];
|
||||
};
|
||||
|
||||
static_assert(sizeof(DNF90EquipmentSnapshot) == 548,
|
||||
"DNF90EquipmentSnapshot append-only ABI size changed unexpectedly");
|
||||
|
||||
// 接口名称:Query90CNEquipmentSnapshot
|
||||
// 中文说明:读取最近一次可靠装备快照,供独立 Lua DLL 计算装备加成。
|
||||
// 参数:output 指向调用者提供的可写结构;outputSize 必须能容纳当前完整结构。
|
||||
// 返回值:TRUE 表示已复制有效快照;FALSE 表示尚未收到完整列表或参数无效。
|
||||
// 线程约束:只在 SRW 锁内复制普通数据,不读取游戏对象,也不执行游戏 UI。
|
||||
EXTERN_C BOOL WINAPI Query90CNEquipmentSnapshot(
|
||||
DNF90EquipmentSnapshot* output, unsigned int outputSize);
|
||||
|
||||
// 有效标志名称:DNF90DamageAffixSnapshotFlag
|
||||
// 中文说明:标记伤害词条快照已由服务端按当前 Script.pvf 与完整穿戴位图计算完成。
|
||||
enum DNF90DamageAffixSnapshotFlag : unsigned int
|
||||
{
|
||||
DNF90_DAMAGE_AFFIX_SNAPSHOT_VALUES_VALID = 1u << 0,
|
||||
DNF90_DAMAGE_AFFIX_SNAPSHOT_IDENTITY_VALID = 1u << 1,
|
||||
DNF90_DAMAGE_AFFIX_SNAPSHOT_THREE_ATTACKS_VALID = 1u << 2,
|
||||
DNF90_DAMAGE_AFFIX_SNAPSHOT_EQUIPMENT_SCORE_VALID = 1u << 3,
|
||||
};
|
||||
|
||||
// 结构名称:DNF90DamageAffixSnapshot
|
||||
// 中文说明:服务端从当前 Script.pvf 汇总的穿戴词条只读快照;所有百分比以 0.1% 为单位。
|
||||
// 字段说明:whiteDamageTenths=白字(附加伤害),yellowDamageTenths=黄字(对敌人伤害增加),
|
||||
// criticalDamageTenths=爆伤(暴击伤害);黄追和爆追始终单独保存,不能冒充前三项;
|
||||
// allAttackTenths=所有攻击力/最终伤害,只并入三攻加成;
|
||||
// pvfEquipmentScore=服务端按 PVF 等级、品级和稀有度计算的装备基础分。
|
||||
struct DNF90DamageAffixSnapshot
|
||||
{
|
||||
unsigned int size;
|
||||
unsigned int generation;
|
||||
unsigned int validFlags;
|
||||
unsigned int version;
|
||||
unsigned int whiteDamageTenths;
|
||||
unsigned int yellowDamageTenths;
|
||||
unsigned int criticalDamageTenths;
|
||||
unsigned int yellowAdditionalTenths;
|
||||
unsigned int criticalAdditionalTenths;
|
||||
unsigned int allAttackTenths;
|
||||
unsigned int equippedItemCount;
|
||||
unsigned int activeSetCount;
|
||||
unsigned int job;
|
||||
unsigned int growType;
|
||||
unsigned int level;
|
||||
unsigned int physicalAttack;
|
||||
unsigned int magicalAttack;
|
||||
unsigned int independentAttack;
|
||||
char professionUtf8[32];
|
||||
unsigned int pvfEquipmentScore;
|
||||
};
|
||||
|
||||
static_assert(sizeof(DNF90DamageAffixSnapshot) == 108,
|
||||
"DNF90DamageAffixSnapshot append-only ABI size changed unexpectedly");
|
||||
|
||||
// 接口名称:Query90CNDamageAffixSnapshot
|
||||
// 中文说明:读取服务端按 PVF 汇总的完整穿戴词条,供独立 Lua DLL 计算并显示战斗力。
|
||||
// 参数:output 为调用方可写结构;outputSize 必须容纳完整结构。
|
||||
// 返回值:TRUE 表示已有有效快照;FALSE 表示尚未收到投影或参数无效。
|
||||
EXTERN_C BOOL WINAPI Query90CNDamageAffixSnapshot(
|
||||
DNF90DamageAffixSnapshot* output, unsigned int outputSize);
|
||||
|
||||
// 有效标志名称:DNF90CombatPanelStateFlag
|
||||
// 中文说明:说明 Lua 提交的战斗力面板中哪些分项已经有真实数据来源。
|
||||
enum DNF90CombatPanelStateFlag : unsigned int
|
||||
{
|
||||
DNF90_COMBAT_PANEL_ENABLED = 1u << 0,
|
||||
DNF90_COMBAT_PANEL_BASE_SCORE_VALID = 1u << 1,
|
||||
DNF90_COMBAT_PANEL_EQUIPMENT_SCORE_VALID = 1u << 2,
|
||||
DNF90_COMBAT_PANEL_DAMAGE_AFFIXES_VALID = 1u << 3,
|
||||
DNF90_COMBAT_PANEL_IDENTITY_VALID = 1u << 4,
|
||||
DNF90_COMBAT_PANEL_THREE_ATTACKS_VALID = 1u << 5,
|
||||
};
|
||||
|
||||
// 结构名称:DNF90CombatPanelState
|
||||
// 中文说明:Lua 完成公式计算后交给主 DLL 绘制的纯数据状态。
|
||||
// 字段:sourceGeneration 对应属性快照版本;formulaVersion 用于区分将来的公式升级;
|
||||
// equipmentScore 只有在对应有效标志存在时才允许显示为数值。
|
||||
struct DNF90CombatPanelState
|
||||
{
|
||||
unsigned int size;
|
||||
unsigned int revision;
|
||||
unsigned int sourceGeneration;
|
||||
unsigned int validFlags;
|
||||
unsigned int formulaVersion;
|
||||
unsigned int totalScore;
|
||||
unsigned int baseAttributeScore;
|
||||
unsigned int equipmentScore;
|
||||
unsigned int hpMax;
|
||||
unsigned int mpMax;
|
||||
int strength;
|
||||
int vitality;
|
||||
int intelligence;
|
||||
int spirit;
|
||||
unsigned int affixGeneration;
|
||||
unsigned int whiteDamageTenths;
|
||||
unsigned int yellowDamageTenths;
|
||||
unsigned int criticalDamageTenths;
|
||||
unsigned int yellowAdditionalTenths;
|
||||
unsigned int criticalAdditionalTenths;
|
||||
unsigned int allAttackTenths;
|
||||
unsigned int equippedItemCount;
|
||||
unsigned int activeSetCount;
|
||||
unsigned int job;
|
||||
unsigned int growType;
|
||||
unsigned int level;
|
||||
unsigned int physicalAttack;
|
||||
unsigned int magicalAttack;
|
||||
unsigned int independentAttack;
|
||||
char professionUtf8[32];
|
||||
};
|
||||
|
||||
static_assert(sizeof(DNF90CombatPanelState) == 148,
|
||||
"DNF90CombatPanelState append-only ABI size changed unexpectedly");
|
||||
|
||||
// 接口名称:Update90CNCombatPanel
|
||||
// 中文说明:复制 Lua 已计算好的面板状态,并唤醒 DNF UI 线程刷新侧栏。
|
||||
// 参数:state 指向只读状态;stateSize 必须等于当前完整结构大小。
|
||||
// 返回值:TRUE 表示已安全接收;FALSE 表示接口未就绪、参数无效或数值越界。
|
||||
// 线程约束:调用者不直接绘制;所有窗口显示、隐藏和重绘均在 DNF UI 线程执行。
|
||||
EXTERN_C BOOL WINAPI Update90CNCombatPanel(
|
||||
const DNF90CombatPanelState* state, unsigned int stateSize);
|
||||
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{bb1b2c5d-c76c-46e2-8ac6-1336cab9c90b}</ProjectGuid>
|
||||
<RootNamespace>My90CN</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v142</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v142</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
<TargetName>90CN</TargetName>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<OutDir>$(ProjectDir)$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)$(Configuration)\90CN\</IntDir>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<OutDir>$(ProjectDir)$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)$(Configuration)\90CN\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;_DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<BufferSecurityCheck>false</BufferSecurityCheck>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<BufferSecurityCheck>false</BufferSecurityCheck>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="dllmain.cpp" />
|
||||
<ClCompile Include="90CN.cpp" />
|
||||
<ClCompile Include="90CN_digest_trace.cpp" />
|
||||
<ClCompile Include="90CN_companion_p0.cpp" />
|
||||
<ClCompile Include="90CN_seria_minimap.cpp" />
|
||||
<ClCompile Include="90CN_op3_parent_fix.cpp" />
|
||||
<ClCompile Include="90CN_selected_page_fix.cpp" />
|
||||
<ClCompile Include="90CN_dungeon_skill_gate_fix.cpp" />
|
||||
<ClCompile Include="90CN_weapon_skill_relogin_fix.cpp" />
|
||||
<ClCompile Include="90CN_3149_writer_resync_fix.cpp" />
|
||||
<ClCompile Include="raid_probe.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ResourceCompile Include="client_assets.rc" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="90CN.h" />
|
||||
<ClInclude Include="90CN_digest_trace.h" />
|
||||
<ClInclude Include="90CN_companion_p0.h" />
|
||||
<ClInclude Include="90CN_companion_native_bindings.h" />
|
||||
<ClInclude Include="90CN_companion_new_native.h" />
|
||||
<ClInclude Include="90CN_expert_job_projection.h" />
|
||||
<ClInclude Include="90CN_premium_clock_policy.h" />
|
||||
<ClInclude Include="90CN_first_login_recovery_policy.h" />
|
||||
<ClInclude Include="90CN_seria_minimap.h" />
|
||||
<ClInclude Include="90CN_seria_room_catalog.h" />
|
||||
<ClInclude Include="90CN_seria_room_policy.h" />
|
||||
<ClInclude Include="90CN_op3_parent_fix.h" />
|
||||
<ClInclude Include="90CN_selected_page_fix.h" />
|
||||
<ClInclude Include="90CN_dungeon_skill_gate_fix.h" />
|
||||
<ClInclude Include="90CN_weapon_skill_relogin_fix.h" />
|
||||
<ClInclude Include="90CN_3149_writer_resync_fix.h" />
|
||||
<ClInclude Include="client_assets.h" />
|
||||
<ClInclude Include="XLog.h" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,282 @@
|
||||
#include "90CN_3149_writer_resync_fix.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
// Runtime-verified BAD path:
|
||||
// 01B947C0 self-removal branch
|
||||
// 01B947D6 push 0
|
||||
// 01B947D8 call [vtable+6AC]
|
||||
// return 01B947DA
|
||||
// On the affected retained transition, that virtual call resolves to
|
||||
// 01B9A1A0(0), which directly clears both tier1 +3D1 bytes. The last tier1
|
||||
// +0x28 update has already happened by then, so manager mode remains 0.
|
||||
constexpr uintptr_t kSelfRemovalWriterCallsiteRva = 0x017947D6; // VA 01B947D6
|
||||
constexpr uintptr_t kSelfRemovalWriterContinueRva = 0x017947DC; // VA 01B947DC
|
||||
|
||||
// 01B9A1A0 direct +3D1 writes for slot1 and slot0.
|
||||
constexpr uintptr_t kWriterBSlot1WriteRva = 0x0179A275; // VA 01B9A275
|
||||
constexpr uintptr_t kWriterBSlot0WriteRva = 0x0179A35D; // VA 01B9A35D
|
||||
constexpr uintptr_t kWriterBSlot1ContinueRva = 0x0179A27B;
|
||||
constexpr uintptr_t kWriterBSlot0ContinueRva = 0x0179A363;
|
||||
|
||||
constexpr uintptr_t kSceneRootPointerRva = 0x04DB2764;
|
||||
constexpr uintptr_t kSceneUiManagerRva = 0x01490EE0;
|
||||
constexpr size_t kD1Offset = 0x3D1;
|
||||
constexpr size_t kModeOffset = 0x719;
|
||||
|
||||
uintptr_t g_exeBase = 0;
|
||||
uintptr_t g_callsiteContinue = 0;
|
||||
uintptr_t g_slot1Continue = 0;
|
||||
uintptr_t g_slot0Continue = 0;
|
||||
|
||||
volatile LONG g_captureActive = 0;
|
||||
volatile LONG g_captureThreadId = 0;
|
||||
uintptr_t g_captureFirst = 0;
|
||||
uintptr_t g_captureSecond = 0;
|
||||
|
||||
typedef void* (__thiscall* SceneUiManagerFn)(void*);
|
||||
typedef void (__thiscall* Tier1UpdateFn)(void*);
|
||||
|
||||
unsigned int ReadManagerMode(bool* fault)
|
||||
{
|
||||
if (fault) *fault = false;
|
||||
unsigned int mode = 0xFFFFFFFFu;
|
||||
__try {
|
||||
void* scene = *reinterpret_cast<void**>(g_exeBase + kSceneRootPointerRva);
|
||||
if (!scene) return mode;
|
||||
SceneUiManagerFn getManager = reinterpret_cast<SceneUiManagerFn>(
|
||||
g_exeBase + kSceneUiManagerRva);
|
||||
void* manager = getManager ? getManager(scene) : nullptr;
|
||||
if (!manager) return mode;
|
||||
mode = *reinterpret_cast<unsigned char*>(
|
||||
reinterpret_cast<uintptr_t>(manager) + kModeOffset);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
if (fault) *fault = true;
|
||||
mode = 0xFFFFFFFFu;
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
unsigned int ReadD1(uintptr_t object, bool* fault)
|
||||
{
|
||||
if (fault) *fault = false;
|
||||
if (!object) return 0xFFFFFFFFu;
|
||||
unsigned int value = 0xFFFFFFFFu;
|
||||
__try {
|
||||
value = *reinterpret_cast<unsigned char*>(object + kD1Offset);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
if (fault) *fault = true;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool PatchJumpExact(uintptr_t target, const unsigned char* expected, size_t patchLength,
|
||||
void* detour, uintptr_t continuation)
|
||||
{
|
||||
if (!target || !expected || patchLength < 5 || !detour || !continuation) return false;
|
||||
__try {
|
||||
if (memcmp(reinterpret_cast<const void*>(target), expected, patchLength) != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(reinterpret_cast<void*>(target), patchLength,
|
||||
PAGE_EXECUTE_READWRITE, &oldProtection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unsigned char* site = reinterpret_cast<unsigned char*>(target);
|
||||
site[0] = 0xE9;
|
||||
*reinterpret_cast<LONG*>(site + 1) = static_cast<LONG>(
|
||||
reinterpret_cast<uintptr_t>(detour) - (target + 5));
|
||||
for (size_t i = 5; i < patchLength; ++i) site[i] = 0x90;
|
||||
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(site, patchLength, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), site, patchLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
void __stdcall BeginTier1Capture()
|
||||
{
|
||||
g_captureFirst = 0;
|
||||
g_captureSecond = 0;
|
||||
InterlockedExchange(&g_captureThreadId, static_cast<LONG>(GetCurrentThreadId()));
|
||||
InterlockedExchange(&g_captureActive, 1);
|
||||
}
|
||||
|
||||
void __stdcall CaptureTier1Object(void* object)
|
||||
{
|
||||
if (!object) return;
|
||||
if (InterlockedCompareExchange(&g_captureActive, 1, 1) != 1) return;
|
||||
if (static_cast<DWORD>(InterlockedCompareExchange(&g_captureThreadId, 0, 0)) !=
|
||||
GetCurrentThreadId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
uintptr_t address = reinterpret_cast<uintptr_t>(object);
|
||||
if (!g_captureFirst) {
|
||||
g_captureFirst = address;
|
||||
}
|
||||
else if (g_captureFirst != address && !g_captureSecond) {
|
||||
g_captureSecond = address;
|
||||
}
|
||||
}
|
||||
|
||||
bool CallTier1Virtual28(uintptr_t object)
|
||||
{
|
||||
if (!object) return false;
|
||||
bool ok = false;
|
||||
__try {
|
||||
uintptr_t* vtable = *reinterpret_cast<uintptr_t**>(object);
|
||||
if (!vtable) return false;
|
||||
uintptr_t target = vtable[0x28 / sizeof(uintptr_t)];
|
||||
if (!target) return false;
|
||||
Tier1UpdateFn update = reinterpret_cast<Tier1UpdateFn>(target);
|
||||
update(reinterpret_cast<void*>(object));
|
||||
ok = true;
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
ok = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void __stdcall FinishTier1CaptureAndResync()
|
||||
{
|
||||
if (static_cast<DWORD>(InterlockedCompareExchange(&g_captureThreadId, 0, 0)) !=
|
||||
GetCurrentThreadId()) {
|
||||
return;
|
||||
}
|
||||
if (InterlockedExchange(&g_captureActive, 0) != 1) return;
|
||||
|
||||
uintptr_t first = g_captureFirst;
|
||||
uintptr_t second = g_captureSecond;
|
||||
g_captureFirst = 0;
|
||||
g_captureSecond = 0;
|
||||
|
||||
bool modeFault = false;
|
||||
unsigned int mode = ReadManagerMode(&modeFault);
|
||||
bool d1FaultFirst = false;
|
||||
bool d1FaultSecond = false;
|
||||
unsigned int firstD1 = ReadD1(first, &d1FaultFirst);
|
||||
unsigned int secondD1 = ReadD1(second, &d1FaultSecond);
|
||||
|
||||
// Scope the behavior change to the exact stale-HUD state observed live:
|
||||
// manager mode is 0 after the writer has cleared at least one captured
|
||||
// tier1 object to +3D1 == 0. If mode is already 1, do nothing.
|
||||
if (!modeFault && mode == 0) {
|
||||
if (first && !d1FaultFirst && firstD1 == 0) {
|
||||
(void)CallTier1Virtual28(first);
|
||||
}
|
||||
if (second && second != first && !d1FaultSecond && secondD1 == 0) {
|
||||
(void)CallTier1Virtual28(second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
__declspec(naked) void ProxyWriterBSlot1Write()
|
||||
{
|
||||
__asm {
|
||||
mov byte ptr [esi + 3D1h], bl
|
||||
pushfd
|
||||
pushad
|
||||
push esi
|
||||
call CaptureTier1Object
|
||||
popad
|
||||
popfd
|
||||
jmp dword ptr [g_slot1Continue]
|
||||
}
|
||||
}
|
||||
|
||||
__declspec(naked) void ProxyWriterBSlot0Write()
|
||||
{
|
||||
__asm {
|
||||
mov byte ptr [esi + 3D1h], bl
|
||||
pushfd
|
||||
pushad
|
||||
push esi
|
||||
call CaptureTier1Object
|
||||
popad
|
||||
popfd
|
||||
jmp dword ptr [g_slot0Continue]
|
||||
}
|
||||
}
|
||||
|
||||
__declspec(naked) void ProxySelfRemovalWriterZero()
|
||||
{
|
||||
__asm {
|
||||
// Reproduce 01B947D6..01B947DA exactly around the native virtual call:
|
||||
// push 0; call edx; [post-call resync]; push 1; continue at 01B947DC.
|
||||
pushfd
|
||||
pushad
|
||||
call BeginTier1Capture
|
||||
popad
|
||||
popfd
|
||||
|
||||
push 0
|
||||
call edx
|
||||
|
||||
pushfd
|
||||
pushad
|
||||
call FinishTier1CaptureAndResync
|
||||
popad
|
||||
popfd
|
||||
|
||||
push 1
|
||||
jmp dword ptr [g_callsiteContinue]
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI Install3149WriterResyncFix(LPVOID)
|
||||
{
|
||||
g_exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
|
||||
if (!g_exeBase) return 0;
|
||||
|
||||
g_callsiteContinue = g_exeBase + kSelfRemovalWriterContinueRva;
|
||||
g_slot1Continue = g_exeBase + kWriterBSlot1ContinueRva;
|
||||
g_slot0Continue = g_exeBase + kWriterBSlot0ContinueRva;
|
||||
|
||||
static const unsigned char kWriterWriteExpected[] = {
|
||||
0x88, 0x9E, 0xD1, 0x03, 0x00, 0x00
|
||||
};
|
||||
static const unsigned char kSelfRemovalExpected[] = {
|
||||
0x6A, 0x00, 0xFF, 0xD2, 0x6A, 0x01
|
||||
};
|
||||
|
||||
bool slot1Hook = PatchJumpExact(
|
||||
g_exeBase + kWriterBSlot1WriteRva,
|
||||
kWriterWriteExpected, sizeof(kWriterWriteExpected),
|
||||
reinterpret_cast<void*>(&ProxyWriterBSlot1Write), g_slot1Continue);
|
||||
bool slot0Hook = PatchJumpExact(
|
||||
g_exeBase + kWriterBSlot0WriteRva,
|
||||
kWriterWriteExpected, sizeof(kWriterWriteExpected),
|
||||
reinterpret_cast<void*>(&ProxyWriterBSlot0Write), g_slot0Continue);
|
||||
|
||||
// Only enable the behavior-changing callsite hook if both read/capture
|
||||
// hooks are present. Partial capture must never drive a resync.
|
||||
if (slot1Hook && slot0Hook) {
|
||||
(void)PatchJumpExact(
|
||||
g_exeBase + kSelfRemovalWriterCallsiteRva,
|
||||
kSelfRemovalExpected, sizeof(kSelfRemovalExpected),
|
||||
reinterpret_cast<void*>(&ProxySelfRemovalWriterZero), g_callsiteContinue);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void Start3149WriterResyncFix(HMODULE module)
|
||||
{
|
||||
(void)module;
|
||||
HANDLE worker = CreateThread(nullptr, 0, Install3149WriterResyncFix, nullptr, 0, nullptr);
|
||||
if (worker) CloseHandle(worker);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
void Start3149WriterResyncFix(HMODULE module);
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
|
||||
namespace dnf90_combat_panel
|
||||
{
|
||||
// Current EXE sub_10DC420 transforms a point around the live UI pivot.
|
||||
using NativeTransformPoint = void (__thiscall*)(void*, float*, float*, float, float);
|
||||
|
||||
struct Position
|
||||
{
|
||||
int x;
|
||||
int y;
|
||||
};
|
||||
|
||||
inline bool PositionSidecar(
|
||||
int nativeX, int nativeY, int nativeWidth, int nativeHeight,
|
||||
int viewportWidth, int viewportHeight, int clientWidth, int clientHeight,
|
||||
int sidecarWidth, int sidecarHeight, int frameRight, int frameTop,
|
||||
void* transformOwner, NativeTransformPoint transform, Position* result)
|
||||
{
|
||||
if (!result || !transformOwner || !transform ||
|
||||
viewportWidth < 320 || viewportWidth > 16384 ||
|
||||
viewportHeight < 200 || viewportHeight > 16384 ||
|
||||
clientWidth < 8 || clientWidth > 16384 ||
|
||||
clientHeight < 8 || clientHeight > 16384 ||
|
||||
nativeX < -64 || nativeY < -64 ||
|
||||
nativeX > viewportWidth || nativeY > viewportHeight ||
|
||||
nativeWidth < 160 || nativeWidth > 640 ||
|
||||
nativeHeight < 200 || nativeHeight > 760 ||
|
||||
sidecarWidth <= 0 || sidecarHeight <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float right = static_cast<float>(nativeX + nativeWidth + frameRight);
|
||||
float top = static_cast<float>(nativeY - frameTop);
|
||||
float left = static_cast<float>(nativeX);
|
||||
float unusedY = static_cast<float>(nativeY);
|
||||
transform(transformOwner, &right, &top, 0.0f, 0.0f);
|
||||
transform(transformOwner, &left, &unusedY, 0.0f, 0.0f);
|
||||
// Comparisons also reject NaN, before any float-to-int conversion.
|
||||
if (!(right >= -65536.0f && right <= 65536.0f &&
|
||||
top >= -65536.0f && top <= 65536.0f &&
|
||||
left >= -65536.0f && left <= 65536.0f)) {
|
||||
return false;
|
||||
}
|
||||
int x = static_cast<int>(right * clientWidth / viewportWidth);
|
||||
int y = static_cast<int>(top * clientHeight / viewportHeight);
|
||||
if (x + sidecarWidth > clientWidth - 4) {
|
||||
x = static_cast<int>(left * clientWidth / viewportWidth) - sidecarWidth;
|
||||
}
|
||||
if (x + sidecarWidth > clientWidth - 4) x = clientWidth - sidecarWidth - 4;
|
||||
if (x < 4) x = 4;
|
||||
if (y + sidecarHeight > clientHeight - 4) y = clientHeight - sidecarHeight - 4;
|
||||
if (y < 4) y = 4;
|
||||
result->x = x;
|
||||
result->y = y;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
#pragma once
|
||||
// Client-side execution coordinator. Not installed into 90CN.dll.
|
||||
// NativeDriver is deliberately an interface: no current native implementation
|
||||
// has passed creation/pause/retirement verification. Offline tests inject a fake.
|
||||
#include "90CN_companion_native_contract.h"
|
||||
#include <thread>
|
||||
|
||||
namespace dnf90_companion {
|
||||
struct ActorHandle {
|
||||
std::uint32_t address32 = 0, object_key = 0, generation = 0;
|
||||
};
|
||||
inline bool SameActor(const ActorHandle& a, const ActorHandle& b) {
|
||||
return a.address32 == b.address32 && a.object_key == b.object_key &&
|
||||
a.generation == b.generation;
|
||||
}
|
||||
inline bool ValidActor(const ActorHandle& h) {
|
||||
return h.address32 && h.object_key && h.object_key < 65535 && h.generation;
|
||||
}
|
||||
struct ClientPlan {
|
||||
SpawnIdentity identity{};
|
||||
std::uint16_t source_character = 0;
|
||||
RequestValues values{};
|
||||
};
|
||||
enum class ActorPresence { Unavailable, Present, Gone, IdentityMismatch };
|
||||
struct ActorObservation {
|
||||
ActorPresence presence = ActorPresence::Unavailable;
|
||||
bool registered = false, paused = false, alive = false;
|
||||
std::uint64_t room_generation = 0;
|
||||
};
|
||||
enum class Submission { NotSubmitted, Pending };
|
||||
|
||||
// Every method executes on the verified game thread and MUST NOT wait for IO.
|
||||
// Implementations resolve handles afresh before touching native memory, and
|
||||
// validate provenance against the authenticated server/session/run and profile.
|
||||
// BeginPaused must guarantee no AI attack before the creation observer; a later
|
||||
// best-effort pause is insufficient. Pending also covers uncertain submission.
|
||||
// Gone means proven engine retirement, not lookup error or missing room manager.
|
||||
class NativeDriver {
|
||||
public:
|
||||
virtual ~NativeDriver() = default;
|
||||
virtual bool VerifiedFor(const ClientPlan&) const noexcept = 0;
|
||||
virtual bool RoomReady(const SpawnIdentity&, std::uint64_t) const noexcept = 0;
|
||||
virtual Submission BeginPaused(const ClientPlan&, std::uint64_t) noexcept = 0;
|
||||
virtual bool BelongsToRequest(const ClientPlan&, const ActorHandle&) const noexcept = 0;
|
||||
virtual ActorObservation Inspect(const SpawnIdentity&, const ActorHandle&) const noexcept = 0;
|
||||
virtual bool SetPaused(const SpawnIdentity&, const ActorHandle&, bool) noexcept = 0;
|
||||
virtual bool RequestRetirement(const SpawnIdentity&, const ActorHandle&) noexcept = 0;
|
||||
// true means the pending operation AND its callback queue are drained,
|
||||
// with no undisclosed live object. false does not authorize another spawn.
|
||||
virtual bool CancelAndDrain(const SpawnIdentity&) noexcept = 0;
|
||||
virtual bool CreationDrained(const SpawnIdentity&) const noexcept = 0;
|
||||
};
|
||||
|
||||
enum class ClientPhase {
|
||||
Idle, Prepared, Creating, AwaitOwner, Bound, Active, Transitioning, Dead,
|
||||
Retiring, CleanupBlocked, Closed
|
||||
};
|
||||
enum class ClientResult {
|
||||
Ok, WrongThread, InvalidPlan, StaleRun, Busy, Unsupported, RoomNotReady,
|
||||
WrongPhase, WrongActor, ObservationUnavailable, AINotPaused, NativeFailure,
|
||||
Timeout, CleanupPending, ClockRegression
|
||||
};
|
||||
struct ClientStatus {
|
||||
ClientPhase phase = ClientPhase::Idle;
|
||||
ClientResult reason = ClientResult::Ok;
|
||||
SpawnIdentity identity{};
|
||||
ActorHandle actor{};
|
||||
std::uint64_t room_generation = 0;
|
||||
bool owner_bound = false, creation_pending = false;
|
||||
};
|
||||
struct ClientTimeouts {
|
||||
std::uint64_t preparation_ms = 10000, creation_ms = 5000,
|
||||
binding_ms = 5000, transition_ms = 10000, retirement_ms = 5000;
|
||||
};
|
||||
|
||||
// All mutating methods, including observations, are game-thread only. The
|
||||
// future channel must queue immutable messages there, never call this object
|
||||
// from its reader thread. The coordinator creates no thread, hook or timer.
|
||||
class ClientRuntime {
|
||||
public:
|
||||
ClientRuntime(NativeDriver& driver, std::uint64_t session_generation,
|
||||
std::uint16_t owner_character, std::uint16_t player_object_key,
|
||||
ClientTimeouts timeouts = {})
|
||||
: driver_(driver), thread_(std::this_thread::get_id()),
|
||||
session_(session_generation), owner_(owner_character),
|
||||
player_key_(player_object_key), timeouts_(timeouts) {}
|
||||
ClientRuntime(const ClientRuntime&) = delete;
|
||||
ClientRuntime& operator=(const ClientRuntime&) = delete;
|
||||
bool TryStatus(ClientStatus& out) const {
|
||||
if (std::this_thread::get_id() != thread_) return false;
|
||||
out = state_; return true;
|
||||
}
|
||||
ClientResult Prepare(const ClientPlan& plan, std::uint64_t now) {
|
||||
auto guard = TimeGuard(now); if (guard != ClientResult::Ok) return guard;
|
||||
if (state_.phase != ClientPhase::Idle && state_.phase != ClientPhase::Closed)
|
||||
return ClientResult::Busy;
|
||||
const auto& id = plan.identity;
|
||||
NativeApcRequest32 request{};
|
||||
if (!session_ || !owner_ || owner_ == 65535 || !player_key_ || player_key_ == 65535 ||
|
||||
id.session_generation != session_ || id.owner_character != owner_ ||
|
||||
!id.run_token || !id.request_sequence || !plan.source_character ||
|
||||
plan.source_character == owner_ || plan.source_character == 65535 ||
|
||||
BuildRequest(plan.values, &request) != RequestError::None)
|
||||
return ClientResult::InvalidPlan;
|
||||
if (id.request_sequence <= last_request_) return ClientResult::StaleRun;
|
||||
if (!driver_.VerifiedFor(plan)) return ClientResult::Unsupported;
|
||||
plan_ = plan; state_ = {}; state_.identity = id;
|
||||
state_.phase = ClientPhase::Prepared; since_ = now;
|
||||
last_request_ = id.request_sequence;
|
||||
retire_sent_ = cancel_sent_ = false;
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
ClientResult RoomReady(const SpawnIdentity& id, std::uint64_t room, std::uint64_t now) {
|
||||
auto guard = Guard(id, now); if (guard != ClientResult::Ok) return guard;
|
||||
if (!room) return ClientResult::RoomNotReady;
|
||||
if (room == state_.room_generation &&
|
||||
(state_.phase == ClientPhase::Creating || state_.phase == ClientPhase::AwaitOwner ||
|
||||
state_.phase == ClientPhase::Bound || state_.phase == ClientPhase::Active))
|
||||
return ClientResult::Ok; // repeated load callback never resubmits
|
||||
if (room <= state_.room_generation || !driver_.RoomReady(id, room))
|
||||
return ClientResult::RoomNotReady;
|
||||
if (state_.phase == ClientPhase::Transitioning) {
|
||||
const auto o = driver_.Inspect(id, state_.actor);
|
||||
if (o.presence != ActorPresence::Present) return ClientResult::ObservationUnavailable;
|
||||
if (!o.alive) { state_.phase = ClientPhase::Dead; return ClientResult::Ok; }
|
||||
if (!o.registered || !o.paused || o.room_generation != room || !state_.owner_bound)
|
||||
return ClientResult::RoomNotReady;
|
||||
state_.room_generation = room;
|
||||
return Resume(now);
|
||||
}
|
||||
if (state_.phase != ClientPhase::Prepared) return ClientResult::WrongPhase;
|
||||
if (!driver_.VerifiedFor(plan_)) return Close(ClientResult::Unsupported);
|
||||
state_.room_generation = room; state_.phase = ClientPhase::Creating;
|
||||
state_.creation_pending = true; since_ = now;
|
||||
// State is installed before submitting. The driver must enqueue its
|
||||
// observer callbacks until this call has returned (no re-entrancy).
|
||||
if (driver_.BeginPaused(plan_, room) == Submission::NotSubmitted) {
|
||||
state_.creation_pending = false;
|
||||
return Close(ClientResult::NativeFailure);
|
||||
}
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
ClientResult NativeCreated(const SpawnIdentity& id, ActorHandle h, std::uint64_t now) {
|
||||
auto guard = Guard(id, now); if (guard != ClientResult::Ok) return guard;
|
||||
if (!ValidActor(h) || h.object_key == player_key_ || !driver_.BelongsToRequest(plan_, h))
|
||||
return ClientResult::WrongActor;
|
||||
if (ValidActor(state_.actor)) {
|
||||
if (!SameActor(state_.actor, h)) return Block(ClientResult::WrongActor);
|
||||
// A duplicate cannot reset a terminal/dead/active lifecycle.
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
if (!state_.creation_pending ||
|
||||
(state_.phase != ClientPhase::Creating && state_.phase != ClientPhase::Retiring &&
|
||||
state_.phase != ClientPhase::CleanupBlocked)) return ClientResult::WrongPhase;
|
||||
const auto o = driver_.Inspect(id, h);
|
||||
if (o.presence != ActorPresence::Present || !o.registered)
|
||||
return ClientResult::ObservationUnavailable;
|
||||
state_.actor = h; state_.creation_pending = false;
|
||||
if (state_.phase == ClientPhase::Retiring || state_.phase == ClientPhase::CleanupBlocked) {
|
||||
state_.phase = ClientPhase::Retiring; since_ = now;
|
||||
return SendRetirement(); // late result after cancel: never bind or activate
|
||||
}
|
||||
if (!o.paused || !o.alive || o.room_generation != state_.room_generation) {
|
||||
return StartRetirement(now, ClientResult::AINotPaused);
|
||||
}
|
||||
state_.phase = ClientPhase::AwaitOwner; since_ = now;
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
ClientResult OwnerBound(const SpawnIdentity& id, ActorHandle h, std::uint64_t now) {
|
||||
auto guard = Guard(id, now); if (guard != ClientResult::Ok) return guard;
|
||||
if (!SameActor(h, state_.actor) || !ValidActor(h)) return ClientResult::WrongActor;
|
||||
if ((state_.phase == ClientPhase::Bound || state_.phase == ClientPhase::Active) &&
|
||||
state_.owner_bound) return ClientResult::Ok;
|
||||
if (state_.phase != ClientPhase::AwaitOwner) return ClientResult::WrongPhase;
|
||||
const auto o = driver_.Inspect(id, h);
|
||||
if (o.presence != ActorPresence::Present || !o.registered)
|
||||
return ClientResult::ObservationUnavailable;
|
||||
if (!o.paused || !o.alive || o.room_generation != state_.room_generation)
|
||||
return StartRetirement(now, ClientResult::AINotPaused);
|
||||
state_.owner_bound = true; state_.phase = ClientPhase::Bound;
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
ClientResult Activate(const SpawnIdentity& id, ActorHandle h, std::uint64_t now) {
|
||||
auto guard = Guard(id, now); if (guard != ClientResult::Ok) return guard;
|
||||
if (!SameActor(h, state_.actor) || !ValidActor(h)) return ClientResult::WrongActor;
|
||||
if (state_.phase == ClientPhase::Active) return ClientResult::Ok;
|
||||
if (state_.phase != ClientPhase::Bound || !state_.owner_bound) return ClientResult::WrongPhase;
|
||||
return Resume(now);
|
||||
}
|
||||
ClientResult RoomLeaving(const SpawnIdentity& id, std::uint64_t room, std::uint64_t now) {
|
||||
auto guard = Guard(id, now); if (guard != ClientResult::Ok) return guard;
|
||||
if (room != state_.room_generation) return ClientResult::RoomNotReady;
|
||||
if (state_.phase == ClientPhase::Transitioning || state_.phase == ClientPhase::Dead)
|
||||
return ClientResult::Ok;
|
||||
if (state_.phase != ClientPhase::Active) return ClientResult::WrongPhase;
|
||||
auto o = driver_.Inspect(id, state_.actor);
|
||||
if (o.presence != ActorPresence::Present) return Block(ClientResult::ObservationUnavailable);
|
||||
if (!o.alive) { state_.phase = ClientPhase::Dead; return ClientResult::Ok; }
|
||||
if (!driver_.SetPaused(id, state_.actor, true)) return StartRetirement(now, ClientResult::NativeFailure);
|
||||
o = driver_.Inspect(id, state_.actor);
|
||||
if (o.presence != ActorPresence::Present || !o.paused)
|
||||
return StartRetirement(now, ClientResult::AINotPaused);
|
||||
state_.phase = ClientPhase::Transitioning; since_ = now;
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
ClientResult Retire(const SpawnIdentity& id, std::uint64_t now) {
|
||||
auto guard = Guard(id, now); if (guard != ClientResult::Ok) return guard;
|
||||
if (state_.phase == ClientPhase::Closed) return ClientResult::Ok;
|
||||
if (state_.phase == ClientPhase::Retiring || state_.phase == ClientPhase::CleanupBlocked)
|
||||
return ClientResult::CleanupPending;
|
||||
return StartRetirement(now, ClientResult::Ok);
|
||||
}
|
||||
ClientResult Poll(std::uint64_t now) {
|
||||
auto guard = TimeGuard(now); if (guard != ClientResult::Ok) return guard;
|
||||
const auto phase = state_.phase;
|
||||
if (phase == ClientPhase::Idle || phase == ClientPhase::Closed) return ClientResult::Ok;
|
||||
if (phase == ClientPhase::Retiring || phase == ClientPhase::CleanupBlocked) {
|
||||
if (state_.creation_pending && driver_.CreationDrained(state_.identity))
|
||||
state_.creation_pending = false;
|
||||
if (!ValidActor(state_.actor)) {
|
||||
if (!state_.creation_pending) return Close(state_.reason);
|
||||
} else {
|
||||
const auto o = driver_.Inspect(state_.identity, state_.actor);
|
||||
if (o.presence == ActorPresence::Gone && !state_.creation_pending)
|
||||
return Close(state_.reason);
|
||||
if (o.presence == ActorPresence::IdentityMismatch) return Block(ClientResult::WrongActor);
|
||||
}
|
||||
if (Elapsed(now, timeouts_.retirement_ms)) return Block(ClientResult::Timeout);
|
||||
return ClientResult::CleanupPending;
|
||||
}
|
||||
if (phase == ClientPhase::Active || phase == ClientPhase::Transitioning || phase == ClientPhase::Dead) {
|
||||
const auto o = driver_.Inspect(state_.identity, state_.actor);
|
||||
if (o.presence == ActorPresence::IdentityMismatch) return Block(ClientResult::WrongActor);
|
||||
if (o.presence == ActorPresence::Gone) return Close(ClientResult::NativeFailure);
|
||||
if (o.presence == ActorPresence::Unavailable) return ClientResult::ObservationUnavailable;
|
||||
if (!o.alive) { state_.phase = ClientPhase::Dead; return ClientResult::Ok; }
|
||||
}
|
||||
std::uint64_t timeout = 0;
|
||||
if (phase == ClientPhase::Prepared) timeout = timeouts_.preparation_ms;
|
||||
if (phase == ClientPhase::Creating) timeout = timeouts_.creation_ms;
|
||||
if (phase == ClientPhase::AwaitOwner || phase == ClientPhase::Bound) timeout = timeouts_.binding_ms;
|
||||
if (phase == ClientPhase::Transitioning) timeout = timeouts_.transition_ms;
|
||||
if (timeout && Elapsed(now, timeout)) return StartRetirement(now, ClientResult::Timeout);
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
private:
|
||||
ClientResult TimeGuard(std::uint64_t now) {
|
||||
if (std::this_thread::get_id() != thread_) return ClientResult::WrongThread;
|
||||
if (now < last_now_) return ClientResult::ClockRegression;
|
||||
last_now_ = now; return ClientResult::Ok;
|
||||
}
|
||||
ClientResult Guard(const SpawnIdentity& id, std::uint64_t now) {
|
||||
auto result = TimeGuard(now); if (result != ClientResult::Ok) return result;
|
||||
return SameSpawn(id, state_.identity) ? ClientResult::Ok : ClientResult::StaleRun;
|
||||
}
|
||||
bool Elapsed(std::uint64_t now, std::uint64_t limit) const { return now - since_ >= limit; }
|
||||
ClientResult Block(ClientResult reason) {
|
||||
state_.phase = ClientPhase::CleanupBlocked; state_.reason = reason; return reason;
|
||||
}
|
||||
ClientResult Close(ClientResult reason) {
|
||||
state_.phase = ClientPhase::Closed; state_.reason = reason;
|
||||
state_.owner_bound = false; state_.creation_pending = false;
|
||||
return reason;
|
||||
}
|
||||
ClientResult SendRetirement() {
|
||||
if (retire_sent_) return ClientResult::CleanupPending;
|
||||
const auto o = driver_.Inspect(state_.identity, state_.actor);
|
||||
if (o.presence == ActorPresence::Gone) return Close(state_.reason);
|
||||
if (o.presence != ActorPresence::Present) return Block(ClientResult::ObservationUnavailable);
|
||||
retire_sent_ = true; // no uncertain native operation is blindly retried
|
||||
if (!driver_.RequestRetirement(state_.identity, state_.actor)) return Block(ClientResult::NativeFailure);
|
||||
return ClientResult::CleanupPending;
|
||||
}
|
||||
ClientResult StartRetirement(std::uint64_t now, ClientResult reason) {
|
||||
state_.phase = ClientPhase::Retiring; state_.reason = reason; since_ = now;
|
||||
if (ValidActor(state_.actor)) return SendRetirement();
|
||||
if (state_.creation_pending && !cancel_sent_) {
|
||||
cancel_sent_ = true;
|
||||
if (driver_.CancelAndDrain(state_.identity)) state_.creation_pending = false;
|
||||
}
|
||||
if (!state_.creation_pending) return Close(reason);
|
||||
return ClientResult::CleanupPending;
|
||||
}
|
||||
ClientResult Resume(std::uint64_t now) {
|
||||
const auto o = driver_.Inspect(state_.identity, state_.actor);
|
||||
if (o.presence != ActorPresence::Present || !o.registered || !o.paused || !o.alive ||
|
||||
o.room_generation != state_.room_generation || !state_.owner_bound)
|
||||
return StartRetirement(now, ClientResult::AINotPaused);
|
||||
if (!driver_.SetPaused(state_.identity, state_.actor, false))
|
||||
return StartRetirement(now, ClientResult::NativeFailure);
|
||||
const auto after = driver_.Inspect(state_.identity, state_.actor);
|
||||
if (after.presence != ActorPresence::Present || !after.registered || after.paused ||
|
||||
!after.alive || after.room_generation != state_.room_generation)
|
||||
return StartRetirement(now, ClientResult::NativeFailure);
|
||||
state_.phase = ClientPhase::Active; since_ = now;
|
||||
return ClientResult::Ok;
|
||||
}
|
||||
NativeDriver& driver_;
|
||||
const std::thread::id thread_;
|
||||
const std::uint64_t session_;
|
||||
const std::uint16_t owner_, player_key_;
|
||||
const ClientTimeouts timeouts_;
|
||||
ClientPlan plan_{};
|
||||
ClientStatus state_{};
|
||||
std::uint64_t since_ = 0, last_now_ = 0, last_request_ = 0;
|
||||
bool retire_sent_ = false, cancel_sent_ = false;
|
||||
};
|
||||
} // namespace dnf90_companion
|
||||
@@ -0,0 +1,169 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dnf90_companion_p0 {
|
||||
// EXE-specific native ABI. Projection policy, queue, AIC ownership and AI
|
||||
// remain shared. These bindings are selected once, before installing hooks.
|
||||
struct CompanionNativeBindings {
|
||||
size_t kUserInfoKeyOffset = 0xC;
|
||||
size_t kUserInfoLevelOffset = 0x2C;
|
||||
uintptr_t kAIControlledVmVtableRva = 0x0;
|
||||
bool newNative = false;
|
||||
uintptr_t kExpectedReceiveRva = 0x02BBB690;
|
||||
uintptr_t kPayloadEncodeReturnRva = 0x02BB76E0;
|
||||
uintptr_t kWriterPointerRva = 0x04DB275C;
|
||||
uintptr_t kDispatcherPointerRva = 0x04EC12B8;
|
||||
size_t kPacketBufferOffset = 0x20;
|
||||
size_t kWriterCursorOffset = 0x100020;
|
||||
uintptr_t kNativeSummonApcRva = 0x01F134A0;
|
||||
uintptr_t kNativeReadyRva = 0x01FB0BF0;
|
||||
uintptr_t kSceneContextGetterRva = 0x018F2A70;
|
||||
uintptr_t kSceneModeRva = 0x01E64FD0;
|
||||
uintptr_t kNativeRefStringAllocRva = 0x03714DA7;
|
||||
uintptr_t kNativeRefStringFreeRva = 0x037146A3;
|
||||
uintptr_t kNativeRefStringIncIatRva = 0x03DA2348;
|
||||
uintptr_t kNativeRefStringDecIatRva = 0x03DA2344;
|
||||
uintptr_t kNativeVectorFreeRva = 0x0370DEB4;
|
||||
uintptr_t kNativeEquipmentVectorAllocateRva = 0x01CF1340;
|
||||
uintptr_t kNativeSkillVectorAllocateRva = 0x020B2420;
|
||||
size_t kActorDisplayNameOffset = 0x400;
|
||||
size_t kAicEquipmentVectorObjectOffset = 0x49C;
|
||||
size_t kAicQuickSkillVectorObjectOffset = 0x4C4;
|
||||
size_t kAicSkillVectorObjectOffset = 0x4D8;
|
||||
size_t kAicEquipmentVectorOffset = 0x4A0;
|
||||
size_t kAicQuickSkillVectorOffset = 0x4C8;
|
||||
size_t kAicSkillVectorOffset = 0x4DC;
|
||||
size_t kActorAicOffset = 0x6C28;
|
||||
size_t kAicRuntimeKeyProgramVectorOffset = 0xFE0;
|
||||
size_t kRuntimeKeyProgramBytecodeBeginOffset = 0x08;
|
||||
size_t kRuntimeKeyProgramBytecodeEndOffset = 0x0C;
|
||||
size_t kRuntimeKeyProgramBytecodeCapacityOffset = 0x10;
|
||||
size_t kRuntimeKeySkillCodeOffset = 0x100;
|
||||
size_t kRuntimeDirectKeyBytecodeSize = 0x180;
|
||||
uintptr_t kExpectedSerializerRva = 0x02136B60;
|
||||
uintptr_t kControlledActorPointerRva = 0x04EB5BB0;
|
||||
size_t kSerializerVtableOffset = 0x4F0;
|
||||
size_t kActorOwnerContextOffset = 0xBC;
|
||||
size_t kOwnerRuntimeOffset = 0x110;
|
||||
uintptr_t kProjectionReaderStateRva = 0x04EF8500;
|
||||
uintptr_t kProjectionReaderErrorCopyRva = 0x04EF8528;
|
||||
uintptr_t kProjectionReaderSetCursorRva = 0x030E2280;
|
||||
uintptr_t kProjectionReaderSetLengthRva = 0x030E2290;
|
||||
uintptr_t kProjectionReaderSetPacketRva = 0x030E3430;
|
||||
uintptr_t kControlledActorGetterRva = 0;
|
||||
uintptr_t kAIParameterRva = 0x0222A2A0;
|
||||
uintptr_t kAICooldownRva = 0x0222B040;
|
||||
uintptr_t kAIKeyReadyRva = 0x0222B330;
|
||||
uintptr_t kActorClockRva = 0x0222EB60;
|
||||
uintptr_t kClockReadRva = 0x03108170;
|
||||
size_t kActorVmOffset = 0x6F54;
|
||||
size_t kActorKeyVtableOffset = 0x50;
|
||||
size_t kActorAbilityVtableOffset = 0x648;
|
||||
size_t kActorUserInfoVtableOffset = 0xCF4;
|
||||
size_t kAicUserInfoOffset = 0x100C;
|
||||
size_t kActorSpawnLevelOffset = 0x6F1C;
|
||||
size_t kActorExpVtableOffset = 0xA9C;
|
||||
size_t kActorLearnVtableOffset = 0xB60;
|
||||
size_t kActorStatsVtableOffset = 0xAC4;
|
||||
size_t kActorRecalculateVtableOffset = 0xACC;
|
||||
};
|
||||
|
||||
inline CompanionNativeBindings NewNativeCompanionBindings() {
|
||||
CompanionNativeBindings b;
|
||||
b.newNative = true;
|
||||
b.kExpectedReceiveRva = 0x2E9BDC0;
|
||||
b.kPayloadEncodeReturnRva = 0x2E97D90;
|
||||
b.kNativeSummonApcRva = 0x21D7580;
|
||||
b.kNativeReadyRva = 0x2276330;
|
||||
b.kSceneContextGetterRva = 0x1BA2790;
|
||||
b.kSceneModeRva = 0x2126B50;
|
||||
b.kNativeRefStringAllocRva = 0x3A77C68;
|
||||
b.kNativeRefStringFreeRva = 0x3A777CB;
|
||||
b.kNativeRefStringIncIatRva = 0x6A85BA8;
|
||||
b.kNativeRefStringDecIatRva = 0x6A85A3C;
|
||||
b.kNativeVectorFreeRva = 0x3A70888;
|
||||
b.kNativeEquipmentVectorAllocateRva = 0x1094240;
|
||||
b.kNativeSkillVectorAllocateRva = 0x10E8E10;
|
||||
b.kActorDisplayNameOffset = 0x404;
|
||||
b.kAicEquipmentVectorObjectOffset = 0x4B4;
|
||||
b.kAicQuickSkillVectorObjectOffset = 0x4DC;
|
||||
b.kAicSkillVectorObjectOffset = 0x4F0;
|
||||
b.kAicEquipmentVectorOffset = 0x4B8;
|
||||
b.kAicQuickSkillVectorOffset = 0x4E0;
|
||||
b.kAicSkillVectorOffset = 0x4F4;
|
||||
b.kActorAicOffset = 0x6CA8;
|
||||
b.kAicRuntimeKeyProgramVectorOffset = 0xFF8;
|
||||
b.kExpectedSerializerRva = 0x2402950;
|
||||
b.kControlledActorGetterRva = 0x25610E0;
|
||||
b.kSerializerVtableOffset = 0x510;
|
||||
b.kActorOwnerContextOffset = 0xC8;
|
||||
b.kProjectionReaderStateRva = 0x53F9AD0;
|
||||
b.kProjectionReaderErrorCopyRva = 0x53F9AF8;
|
||||
b.kProjectionReaderSetCursorRva = 0x33E6970;
|
||||
b.kProjectionReaderSetLengthRva = 0x33E6980;
|
||||
b.kProjectionReaderSetPacketRva = 0x33E7DA0;
|
||||
b.kAIParameterRva = 0x24EDD90;
|
||||
b.kAICooldownRva = 0x24FA120;
|
||||
b.kAIKeyReadyRva = 0x24FA520;
|
||||
b.kActorClockRva = 0x351BA30;
|
||||
b.kClockReadRva = 0x3416310;
|
||||
b.kActorVmOffset = 0x6FE0;
|
||||
b.kActorKeyVtableOffset = 0x58;
|
||||
b.kAicUserInfoOffset = 0x1024;
|
||||
b.kWriterPointerRva = 0x52A3790;
|
||||
b.kDispatcherPointerRva = 0x53C2DDC;
|
||||
b.kActorAbilityVtableOffset = 0x670;
|
||||
b.kActorUserInfoVtableOffset = 0xD20;
|
||||
b.kActorSpawnLevelOffset = 0x6FA8;
|
||||
b.kActorExpVtableOffset = 0xAC8;
|
||||
b.kActorLearnVtableOffset = 0xB8C;
|
||||
b.kActorStatsVtableOffset = 0xAF0;
|
||||
b.kActorRecalculateVtableOffset = 0xAF8;
|
||||
b.kUserInfoKeyOffset = 0x20;
|
||||
b.kUserInfoLevelOffset = 0x44;
|
||||
b.kRuntimeKeySkillCodeOffset = 0x128;
|
||||
b.kRuntimeDirectKeyBytecodeSize = 0x1BC;
|
||||
b.kAIControlledVmVtableRva = 0x42CFA14;
|
||||
return b;
|
||||
}
|
||||
|
||||
// Locked to DB4ABD49...D2. The controller validates the complete EXE hash;
|
||||
// the DLL also checks PE identity and every entry below before installation.
|
||||
struct CompanionNativeAnchor { uintptr_t rva; unsigned char bytes[16]; };
|
||||
constexpr CompanionNativeAnchor kNewNativeCompanionAnchors[] = {
|
||||
{0x02E9BDC0, {0x55, 0x8B, 0xEC, 0x6A, 0xFF, 0x68, 0x12, 0x85, 0x25, 0x04, 0x64, 0xA1, 0x00, 0x00, 0x00, 0x00}}, // kExpectedReceiveRva
|
||||
{0x021D7580, {0xE9, 0x21, 0x52, 0x06, 0x00, 0xE4, 0xCC, 0x9A, 0xA9, 0x09, 0x2E, 0x91, 0x44, 0xCC, 0x03, 0xB9}}, // kNativeSummonApcRva
|
||||
{0x02276330, {0x56, 0x8B, 0xF1, 0xE8, 0xA8, 0x8E, 0x3A, 0x00, 0x85, 0xC0, 0x74, 0x14, 0xE8, 0x9F, 0x8E, 0x3A}}, // kNativeReadyRva
|
||||
{0x01BA2790, {0xE9, 0x7B, 0xC0, 0x57, 0x00, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC}}, // kSceneContextGetterRva
|
||||
{0x02126B50, {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x08, 0x56, 0x8B, 0xF1, 0x8B, 0x0D, 0x88, 0x30, 0x6A, 0x05, 0x85}}, // kSceneModeRva
|
||||
{0x03A77C68, {0x8B, 0xFF, 0x55, 0x8B, 0xEC, 0x53, 0x8B, 0x5D, 0x08, 0x83, 0xFB, 0xE0, 0x77, 0x6F, 0x56, 0x57}}, // kNativeRefStringAllocRva
|
||||
{0x03A777CB, {0x8B, 0xFF, 0x55, 0x8B, 0xEC, 0x83, 0x7D, 0x08, 0x00, 0x74, 0x2D, 0xFF, 0x75, 0x08, 0x6A, 0x00}}, // kNativeRefStringFreeRva
|
||||
{0x03A70888, {0xFF, 0x25, 0xC0, 0x88, 0x58, 0x04, 0xFF, 0x25, 0xBC, 0x88, 0x58, 0x04, 0x8B, 0xFF, 0x56, 0x8B}}, // kNativeVectorFreeRva
|
||||
{0x01094240, {0x55, 0x8B, 0xEC, 0x56, 0x33, 0xC0, 0x57, 0x8B, 0x7D, 0x08, 0x8B, 0xF1, 0x89, 0x46, 0x04, 0x89}}, // kNativeEquipmentVectorAllocateRva
|
||||
{0x010E8E10, {0x55, 0x8B, 0xEC, 0x56, 0x33, 0xC0, 0x57, 0x8B, 0x7D, 0x08, 0x8B, 0xF1, 0x89, 0x46, 0x04, 0x89}}, // kNativeSkillVectorAllocateRva
|
||||
{0x02402950, {0x55, 0x8B, 0xEC, 0x57, 0x8B, 0xF9, 0x8B, 0x87, 0xC8, 0x00, 0x00, 0x00, 0x85, 0xC0, 0x0F, 0x84}}, // kExpectedSerializerRva
|
||||
{0x025610E0, {0xA1, 0xB4, 0x6F, 0x7B, 0x05, 0x85, 0xC0, 0x75, 0x05, 0xA1, 0xB0, 0x6F, 0x7B, 0x05, 0xC3, 0xCC}}, // kControlledActorGetterRva
|
||||
{0x033E6970, {0xE9, 0x72, 0x59, 0x66, 0x03, 0x8D, 0x64, 0x24, 0x04, 0x57, 0xE9, 0xD8, 0x98, 0x62, 0x03, 0x75}}, // kProjectionReaderSetCursorRva
|
||||
{0x033E6980, {0x55, 0x8B, 0xEC, 0x8B, 0x45, 0x08, 0xA3, 0xD4, 0x9A, 0x7F, 0x05, 0x5D, 0xC3, 0xCC, 0xCC, 0xCC}}, // kProjectionReaderSetLengthRva
|
||||
{0x033E7DA0, {0x55, 0x8B, 0xEC, 0x8B, 0x45, 0x08, 0x8B, 0x4D, 0x0C, 0xA3, 0xD8, 0x9A, 0x7F, 0x05, 0xA1, 0xF8}}, // kProjectionReaderSetPacketRva
|
||||
{0x024EDD90, {0x55, 0x8B, 0xEC, 0x6A, 0xFF, 0x68, 0x58, 0x9D, 0x0D, 0x04, 0x64, 0xA1, 0x00, 0x00, 0x00, 0x00}}, // kAIParameterRva
|
||||
{0x024FA120, {0x56, 0x8B, 0xF1, 0x80, 0x7E, 0x6D, 0x00, 0x75, 0x27, 0x83, 0x7E, 0x64, 0x00, 0x74, 0x21, 0x8B}}, // kAICooldownRva
|
||||
{0x024FA520, {0x55, 0x8B, 0xEC, 0x53, 0x56, 0x8B, 0xF1, 0x83, 0x7E, 0x4C, 0x00, 0x57, 0x0F, 0x84, 0xEB, 0x00}}, // kAIKeyReadyRva
|
||||
{0x0351BA30, {0x8D, 0x41, 0x68, 0xC3, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC}}, // kActorClockRva
|
||||
{0x03416310, {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x10, 0x56, 0x8B, 0xF1, 0x8B, 0x46, 0x0C, 0x8D, 0x4D, 0xF8, 0x51}}, // kClockReadRva
|
||||
{0x0211E340, {0x55, 0x8B, 0xEC, 0x53, 0x8B, 0x5D, 0x0C, 0x57, 0xC6, 0x05, 0x48, 0x27, 0x68, 0x05, 0x00, 0x0F}}, // kClass0Dispatch
|
||||
{0x02123C8A, {0x8A, 0x45, 0xA3, 0xC7, 0x45, 0xAC, 0x01, 0x00, 0x00, 0x00, 0x3C, 0x01, 0x0F, 0x85, 0xAB, 0x02}}, // kDecodedProjectionCapture
|
||||
{0x02364160, {0x55, 0x8B, 0xEC, 0x6A, 0xFF, 0x68, 0x68, 0xB3, 0xF7, 0x03, 0x64, 0xA1, 0x00, 0x00, 0x00, 0x00}}, // kAicCopyHook
|
||||
{0x0248A260, {0x55, 0x8B, 0xEC, 0x6A, 0xFF, 0x68, 0x0D, 0x30, 0x1B, 0x04, 0x64, 0xA1, 0x00, 0x00, 0x00, 0x00}}, // kRegistrationHook
|
||||
{0x024F98B0, {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x14, 0x53, 0x56, 0x8B, 0xF1, 0x8B, 0x46, 0x1C, 0x57, 0x83, 0xF8}}, // kAIConditionHook
|
||||
{0x034FB0E0, {0xE9, 0x00, 0x11, 0x52, 0x03, 0x8D, 0x64, 0x24, 0x04, 0x53, 0x50, 0x56, 0x9C, 0x55, 0x57, 0xE9}}, // kPeerDeliveryHook
|
||||
{0x02E83610, {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x08, 0x53, 0x56, 0x8B, 0x75, 0x10, 0x57, 0xBB, 0xB5, 0x3E, 0x45}}, // kPayloadEncoderHook
|
||||
{0x0256D730, {0x55, 0x8B, 0xEC, 0xA1, 0x58, 0x37, 0x6A, 0x05, 0x83, 0xEC, 0x1C, 0x53, 0x66, 0x8B, 0x5D, 0x08}}, // kActorLookupHook
|
||||
{0x033DF130, {0x55, 0x8B, 0xEC, 0xA1, 0x78, 0x8A, 0x7F, 0x05, 0x40, 0x8B, 0xD0, 0xC1, 0xEA, 0x08, 0x0F, 0xB6}}, // levelWriter0x33df130
|
||||
{0x033DF1B0, {0x55, 0x8B, 0xEC, 0xA1, 0x78, 0x8A, 0x7F, 0x05, 0x40, 0x8B, 0xD0, 0xC1, 0xEA, 0x08, 0x0F, 0xB6}}, // levelWriter0x33df1b0
|
||||
{0x033DF230, {0x55, 0x8B, 0xEC, 0xA1, 0x78, 0x8A, 0x7F, 0x05, 0x40, 0x8B, 0xD0, 0xC1, 0xEA, 0x08, 0x0F, 0xB6}}, // levelWriter0x33df230
|
||||
{0x033DF2B0, {0x55, 0x8B, 0xEC, 0xA1, 0x78, 0x8A, 0x7F, 0x05, 0x40, 0x8B, 0xD0, 0xC1, 0xEA, 0x08, 0x0F, 0xB6}}, // levelWriter0x33df2b0
|
||||
{0x033DF010, {0x55, 0x8B, 0xEC, 0xA1, 0x78, 0x8A, 0x7F, 0x05, 0x40, 0x8B, 0xD0, 0xC1, 0xEA, 0x08, 0x0F, 0xB6}}, // levelWriter0x33df010
|
||||
};
|
||||
} // namespace dnf90_companion_p0
|
||||
@@ -0,0 +1,189 @@
|
||||
#pragma once
|
||||
// Offline-tested 32-bit layouts from current 90CN EXE 8a7af614... .
|
||||
// NOT a network packet builder, callable native adapter, or deployment switch.
|
||||
// See docs/evidence/companion-v1-native-create-20260923.md.
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <type_traits>
|
||||
|
||||
namespace dnf90_companion {
|
||||
constexpr std::size_t kNativeRequestSize = 0x54;
|
||||
constexpr std::size_t kNativeTailSize = 0x29;
|
||||
constexpr std::uint32_t kAllocateNativeObjectKey = 0xffff;
|
||||
constexpr std::uint32_t kProtocolVersion = 1;
|
||||
|
||||
// Keep target addresses 32-bit even when tests run in a 64-bit host process.
|
||||
// Unresolved fields are named by offset, not assigned speculative semantics.
|
||||
#pragma pack(push, 4)
|
||||
struct NativeApcRequest32 {
|
||||
std::uint32_t template_id; // 00
|
||||
std::uint32_t source_value04; // 04: owner vtable+370 result
|
||||
std::uint32_t level; // 08: narrowed to u8 by serializer
|
||||
std::uint32_t x, y, z, direction; // 0c..18
|
||||
std::uint32_t mode1c; // 1c: mode 2 rejected by sender
|
||||
std::uint32_t extra_address32, extra_size; // 20..24
|
||||
std::uint8_t flag28, padding29[3];
|
||||
std::uint32_t object_key; // 2c: ffff asks native allocator
|
||||
std::uint8_t flag30, flag31, padding32[2];
|
||||
std::uint32_t tail_address32; // 34: optional 29-byte native tail
|
||||
std::uint8_t flag38, padding39[3];
|
||||
std::int32_t auxiliary3c;
|
||||
std::uint8_t flag40, padding41[3];
|
||||
std::uint32_t byte_list_address32; // 44: native container, NOT byte[]
|
||||
std::uint8_t flag48, padding49[3];
|
||||
std::uint32_t auxiliary4c;
|
||||
std::uint8_t flag50, padding51[3];
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
// Native initializer 037E6B30 has unaligned fields. Do not use a host ABI
|
||||
// structure without packing, or memcpy these bytes into an upper game packet.
|
||||
#pragma pack(push, 1)
|
||||
struct NativeSummonTail32 {
|
||||
std::uint8_t flag00, flag01, flag02;
|
||||
std::uint32_t duration_value; // 03: item description's fourth int
|
||||
std::uint16_t owner07, owner09;
|
||||
std::uint8_t flag0b, reserved0c;
|
||||
float rate0d;
|
||||
std::uint8_t flag11;
|
||||
std::uint16_t value12;
|
||||
std::uint32_t value14, value18, value1c, value20;
|
||||
std::uint8_t flag24;
|
||||
std::int32_t value25;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
static_assert(sizeof(NativeApcRequest32) == kNativeRequestSize, "request ABI");
|
||||
static_assert(alignof(NativeApcRequest32) == 4, "request alignment");
|
||||
static_assert(offsetof(NativeApcRequest32, source_value04) == 0x04, "field04");
|
||||
static_assert(offsetof(NativeApcRequest32, mode1c) == 0x1c, "field1c");
|
||||
static_assert(offsetof(NativeApcRequest32, flag28) == 0x28, "field28");
|
||||
static_assert(offsetof(NativeApcRequest32, object_key) == 0x2c, "object key");
|
||||
static_assert(offsetof(NativeApcRequest32, flag31) == 0x31, "field31");
|
||||
static_assert(offsetof(NativeApcRequest32, tail_address32) == 0x34, "tail pointer");
|
||||
static_assert(offsetof(NativeApcRequest32, auxiliary3c) == 0x3c, "field3c");
|
||||
static_assert(offsetof(NativeApcRequest32, byte_list_address32) == 0x44, "field44");
|
||||
static_assert(offsetof(NativeApcRequest32, auxiliary4c) == 0x4c, "field4c");
|
||||
static_assert(offsetof(NativeApcRequest32, flag50) == 0x50, "field50");
|
||||
static_assert(sizeof(NativeSummonTail32) == kNativeTailSize, "tail ABI");
|
||||
static_assert(offsetof(NativeSummonTail32, duration_value) == 3, "tail duration");
|
||||
static_assert(offsetof(NativeSummonTail32, rate0d) == 0x0d, "tail rate");
|
||||
static_assert(offsetof(NativeSummonTail32, value25) == 0x25, "tail ending");
|
||||
static_assert(sizeof(float) == 4 && std::numeric_limits<float>::is_iec559,
|
||||
"native float representation");
|
||||
static_assert(std::is_trivially_copyable<NativeApcRequest32>::value, "request POD");
|
||||
|
||||
inline NativeApcRequest32 DefaultRequest() {
|
||||
NativeApcRequest32 value;
|
||||
// Native padding is not fully initialized; zero it to avoid leaked stack data.
|
||||
std::memset(&value, 0, sizeof(value));
|
||||
value.direction = 4;
|
||||
value.object_key = kAllocateNativeObjectKey;
|
||||
value.auxiliary3c = -1;
|
||||
value.auxiliary4c = 5;
|
||||
return value;
|
||||
}
|
||||
inline NativeSummonTail32 DefaultTail() {
|
||||
NativeSummonTail32 value;
|
||||
std::memset(&value, 0, sizeof(value));
|
||||
value.owner07 = value.owner09 = 0xffff;
|
||||
value.flag0b = 1;
|
||||
value.rate0d = 1.0f; // actual constant at preferred VA 04B99848
|
||||
value.value25 = -1;
|
||||
return value;
|
||||
}
|
||||
|
||||
struct RequestValues {
|
||||
std::uint32_t template_id;
|
||||
std::uint32_t source_value04;
|
||||
std::uint32_t level;
|
||||
std::int64_t x, y, z;
|
||||
std::uint32_t direction;
|
||||
};
|
||||
enum class RequestError {
|
||||
None, MissingOutput, TemplateRange, SourceValueRange, LevelRange,
|
||||
CoordinateRange, DirectionRange
|
||||
};
|
||||
inline bool FitsCoordinate(std::int64_t value) { return value >= 0 && value <= 65535; }
|
||||
inline RequestError BuildRequest(const RequestValues& values, NativeApcRequest32* out) {
|
||||
if (!out) return RequestError::MissingOutput;
|
||||
// Factory 02DE5CA0 supports positive ids <= 80000, but catalog existence
|
||||
// and job/profile compatibility MUST still be proved separately.
|
||||
if (values.template_id == 0 || values.template_id > 80000)
|
||||
return RequestError::TemplateRange;
|
||||
if (values.source_value04 > 65535) return RequestError::SourceValueRange;
|
||||
if (values.level == 0 || values.level > 255) return RequestError::LevelRange;
|
||||
if (!FitsCoordinate(values.x) || !FitsCoordinate(values.y) || !FitsCoordinate(values.z))
|
||||
return RequestError::CoordinateRange;
|
||||
// Native initializer uses 4; the item route uses the actor facing result.
|
||||
// Only the scalar width is proved here, not a complete direction enum.
|
||||
if (values.direction > 255) return RequestError::DirectionRange;
|
||||
auto result = DefaultRequest();
|
||||
result.template_id = values.template_id;
|
||||
result.source_value04 = values.source_value04;
|
||||
result.level = values.level;
|
||||
result.x = static_cast<std::uint32_t>(values.x);
|
||||
result.y = static_cast<std::uint32_t>(values.y);
|
||||
result.z = static_cast<std::uint32_t>(values.z);
|
||||
result.direction = values.direction;
|
||||
*out = result; // errors leave output unchanged; used on the game thread
|
||||
return RequestError::None;
|
||||
}
|
||||
|
||||
// Pure receipt matcher for the future game-thread adapter. A sender returning
|
||||
// does not prove creation. The actual receiver must independently observe
|
||||
// registration, per-instance state and the engine's safe AI pause boundary.
|
||||
struct SpawnIdentity {
|
||||
std::uint64_t session_generation, run_token, request_sequence;
|
||||
std::uint16_t owner_character;
|
||||
};
|
||||
inline bool SameSpawn(const SpawnIdentity& a, const SpawnIdentity& b) {
|
||||
return a.session_generation == b.session_generation && a.run_token == b.run_token &&
|
||||
a.request_sequence == b.request_sequence && a.owner_character == b.owner_character;
|
||||
}
|
||||
struct NativeReceipt {
|
||||
SpawnIdentity identity;
|
||||
std::uint32_t template_id;
|
||||
std::uint32_t actor_address32;
|
||||
std::uint32_t object_key;
|
||||
std::uint32_t actor_generation;
|
||||
bool registered;
|
||||
bool inactive_confirmed;
|
||||
};
|
||||
enum class ReceiptError {
|
||||
None, StaleSpawn, InvalidIdentity, WrongTemplate, InvalidActor,
|
||||
InvalidKey, KeyCollision, NotRegistered, AINotPaused, ConflictingDuplicate
|
||||
};
|
||||
class PendingSpawn {
|
||||
public:
|
||||
PendingSpawn(SpawnIdentity identity, std::uint32_t template_id)
|
||||
: identity_(identity), template_id_(template_id) {}
|
||||
ReceiptError Observe(const NativeReceipt& receipt, bool key_collision) {
|
||||
if (!SameSpawn(identity_, receipt.identity)) return ReceiptError::StaleSpawn;
|
||||
if (!identity_.session_generation || !identity_.run_token ||
|
||||
!identity_.request_sequence || !identity_.owner_character ||
|
||||
!template_id_ || template_id_ > 80000) return ReceiptError::InvalidIdentity;
|
||||
if (receipt.template_id != template_id_) return ReceiptError::WrongTemplate;
|
||||
if (!receipt.actor_address32 || !receipt.actor_generation) return ReceiptError::InvalidActor;
|
||||
if (!receipt.object_key || receipt.object_key >= 65535) return ReceiptError::InvalidKey;
|
||||
if (key_collision) return ReceiptError::KeyCollision;
|
||||
if (!receipt.registered) return ReceiptError::NotRegistered;
|
||||
if (!receipt.inactive_confirmed) return ReceiptError::AINotPaused;
|
||||
if (has_receipt_ && (receipt_.actor_address32 != receipt.actor_address32 ||
|
||||
receipt_.object_key != receipt.object_key ||
|
||||
receipt_.actor_generation != receipt.actor_generation))
|
||||
return ReceiptError::ConflictingDuplicate;
|
||||
receipt_ = receipt;
|
||||
has_receipt_ = true;
|
||||
return ReceiptError::None;
|
||||
}
|
||||
bool CanBindOwner() const { return has_receipt_; }
|
||||
private:
|
||||
SpawnIdentity identity_;
|
||||
std::uint32_t template_id_;
|
||||
bool has_receipt_ = false;
|
||||
NativeReceipt receipt_{};
|
||||
};
|
||||
} // namespace dnf90_companion
|
||||
@@ -0,0 +1,335 @@
|
||||
#pragma once
|
||||
|
||||
#include "90CN_companion_p0.h"
|
||||
#include "90CN_companion_projection.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
// DB4 client only. The installer owns exact-image/signature checks. These
|
||||
// adapters preserve the shared companion parser, queue and lifecycle owner.
|
||||
namespace dnf90_companion_new_native {
|
||||
|
||||
using NewNativeClass0DispatchFn = int (__stdcall*)(int, int);
|
||||
using NewNativeCaptureLogFn = void (*)(const char*, ...);
|
||||
|
||||
static std::uintptr_t g_captureBase = 0;
|
||||
static std::uintptr_t g_captureResume = 0;
|
||||
static std::uintptr_t g_captureConsumed = 0;
|
||||
static NewNativeClass0DispatchFn g_originalClass0Dispatch = nullptr;
|
||||
static NewNativeCaptureLogFn g_captureLog = nullptr;
|
||||
static DWORD g_dispatchThread = 0;
|
||||
static HWND g_safePointWindow = nullptr;
|
||||
constexpr UINT_PTR kSafePointTimer = 0x90C07F41u;
|
||||
|
||||
constexpr std::size_t kCapturePacketCapacity =
|
||||
dnf90_companion_projection::kPacketHeaderSize +
|
||||
dnf90_companion_projection::kBodyHeaderSize +
|
||||
dnf90_companion_projection::kMaxDynamicAicBytes +
|
||||
2 * dnf90_companion_projection::kMaxProjectionBody;
|
||||
constexpr std::uintptr_t kReaderStateRva = 0x053F9AD0;
|
||||
|
||||
struct NewNativeReaderState {
|
||||
const unsigned char* cursor;
|
||||
int remaining;
|
||||
const unsigned char* packet;
|
||||
int packetLength;
|
||||
};
|
||||
static_assert(sizeof(NewNativeReaderState) == 16, "DB4 x86 native reader ABI");
|
||||
|
||||
// SetTimer's callback is dispatched by the window's own message thread. Only
|
||||
// the native packet thread is allowed to own this timer or call shared APC code.
|
||||
static void CALLBACK NewNativeSafePointTimer(HWND window, UINT, UINT_PTR id, DWORD) {
|
||||
if (id != kSafePointTimer || window != g_safePointWindow ||
|
||||
GetCurrentThreadId() != g_dispatchThread) return;
|
||||
dnf90_companion_p0::NewNativeSafePoint(-2);
|
||||
}
|
||||
|
||||
static BOOL CALLBACK FindNewNativeThreadWindow(HWND window, LPARAM parameter) {
|
||||
DWORD process = 0;
|
||||
const DWORD thread = GetWindowThreadProcessId(window, &process);
|
||||
if (process != GetCurrentProcessId() || thread != g_dispatchThread ||
|
||||
!IsWindowVisible(window) || GetWindow(window, GW_OWNER)) return TRUE;
|
||||
*reinterpret_cast<HWND*>(parameter) = window;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void EnsureNewNativeSafePointTimer() {
|
||||
if (!g_dispatchThread) g_dispatchThread = GetCurrentThreadId();
|
||||
if (GetCurrentThreadId() != g_dispatchThread) return;
|
||||
if (g_safePointWindow && IsWindow(g_safePointWindow)) return;
|
||||
HWND window = nullptr;
|
||||
EnumThreadWindows(g_dispatchThread, FindNewNativeThreadWindow,
|
||||
reinterpret_cast<LPARAM>(&window));
|
||||
if (!window) return;
|
||||
if (SetTimer(window, kSafePointTimer, 100, NewNativeSafePointTimer)) {
|
||||
g_safePointWindow = window;
|
||||
if (g_captureLog) g_captureLog(
|
||||
"new-native companion UI safe-point timer installed window=%p thread=%lu interval_ms=100",
|
||||
window, g_dispatchThread);
|
||||
}
|
||||
}
|
||||
|
||||
struct NewNativeTownSnapshot {
|
||||
const unsigned char* start = nullptr;
|
||||
std::size_t bodyLength = 0;
|
||||
std::size_t consumedMinimum = 0;
|
||||
std::uint16_t localKey = 0;
|
||||
};
|
||||
|
||||
struct NewNativeDungeonInfoSnapshot {
|
||||
const unsigned char* start = nullptr;
|
||||
std::size_t bodyLength = 0;
|
||||
std::uint32_t dungeon = 0;
|
||||
};
|
||||
|
||||
// Current class0 op28 is registered to 01FF5050 at 02001F73. This is a
|
||||
// per-dungeon notification, while op29 is also used for ordinary room changes.
|
||||
// Only the former opens a new run; no duplicate companion lifecycle is stored.
|
||||
static NewNativeDungeonInfoSnapshot SnapshotNewNativeDungeonInfo(
|
||||
int opcode, const unsigned char* header) {
|
||||
NewNativeDungeonInfoSnapshot result{};
|
||||
if (opcode != 28 || !g_captureBase || !header) return result;
|
||||
__try {
|
||||
const std::uint32_t total = dnf90_companion_projection::Read32(header + 3);
|
||||
if (total < 52 || total > kCapturePacketCapacity) return result;
|
||||
const NewNativeReaderState reader = *reinterpret_cast<const NewNativeReaderState*>(
|
||||
g_captureBase + kReaderStateRva);
|
||||
const std::size_t bodyLength = total - 16;
|
||||
if (!reader.cursor || reader.remaining < 0 ||
|
||||
static_cast<std::size_t>(reader.remaining) < bodyLength) return result;
|
||||
const std::uint32_t dungeon = dnf90_companion_projection::Read32(reader.cursor);
|
||||
if (!dungeon) return result;
|
||||
result.start = reader.cursor;
|
||||
result.bodyLength = bodyLength;
|
||||
result.dungeon = dungeon;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
result = {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool NewNativeBodyWasConsumed(
|
||||
const unsigned char* startPointer, std::size_t bodyLength, std::size_t minimum) {
|
||||
if (!startPointer || !g_captureBase) return false;
|
||||
__try {
|
||||
const NewNativeReaderState reader = *reinterpret_cast<const NewNativeReaderState*>(
|
||||
g_captureBase + kReaderStateRva);
|
||||
const std::uintptr_t start = reinterpret_cast<std::uintptr_t>(startPointer);
|
||||
const std::uintptr_t cursor = reinterpret_cast<std::uintptr_t>(reader.cursor);
|
||||
return reader.remaining >= 0 && cursor >= start + minimum &&
|
||||
cursor <= start + bodyLength;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// DB4 class0 initializer 02524320 calls 020460E0, which registers op24 to
|
||||
// 02042900 at 020461A3. Its row reads at 020432C2..02043312 are 3 WORDs and
|
||||
// 3 BYTEs (nine bytes, versus eight in the old client). Early-return gates at
|
||||
// 02042948/02042956 run before any reader consumption.
|
||||
static NewNativeTownSnapshot SnapshotNewNativeTown(int opcode, const unsigned char* header) {
|
||||
NewNativeTownSnapshot result{};
|
||||
if (opcode != 24 || !g_captureBase || !header) return result;
|
||||
__try {
|
||||
const std::uint32_t total = dnf90_companion_projection::Read32(header + 3);
|
||||
if (total < 20 || total > kCapturePacketCapacity) return result;
|
||||
const NewNativeReaderState reader = *reinterpret_cast<const NewNativeReaderState*>(
|
||||
g_captureBase + kReaderStateRva);
|
||||
const std::size_t bodyLength = total - 16;
|
||||
if (!reader.cursor || reader.remaining < 0 ||
|
||||
static_cast<std::size_t>(reader.remaining) < bodyLength) return result;
|
||||
const std::size_t rows = dnf90_companion_projection::Read16(reader.cursor + 2);
|
||||
if (!rows || rows > (bodyLength - 4) / 9) return result;
|
||||
const std::uint16_t key = dnf90_companion_projection::Read16(
|
||||
reader.cursor + 4 + (rows - 1) * 9);
|
||||
if (!key) return result;
|
||||
result.start = reader.cursor;
|
||||
result.bodyLength = bodyLength;
|
||||
result.consumedMinimum = 4 + rows * 9;
|
||||
result.localKey = key;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
result = {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool NewNativeTownWasAccepted(const NewNativeTownSnapshot& snapshot) {
|
||||
if (!snapshot.start || !snapshot.localKey || !g_captureBase) return false;
|
||||
__try {
|
||||
if (!NewNativeBodyWasConsumed(snapshot.start, snapshot.bodyLength,
|
||||
snapshot.consumedMinimum)) return false;
|
||||
using CurrentActor = void* (__cdecl*)();
|
||||
using ActorByKey = void* (__cdecl*)(unsigned short);
|
||||
void* current = reinterpret_cast<CurrentActor>(g_captureBase + 0x025610E0)();
|
||||
void* selected = reinterpret_cast<ActorByKey>(g_captureBase + 0x0256D730)(snapshot.localKey);
|
||||
return current && current == selected;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureNewNativeCaptureResume(
|
||||
std::uintptr_t base,
|
||||
NewNativeCaptureLogFn logger = nullptr)
|
||||
{
|
||||
g_captureBase = base;
|
||||
g_captureResume = base ? base + 0x02123C94 : 0;
|
||||
g_captureConsumed = base ? base + 0x02123F50 : 0;
|
||||
g_captureLog = logger;
|
||||
}
|
||||
|
||||
static void SetOriginalNewNativeClass0Dispatch(void* original) {
|
||||
g_originalClass0Dispatch =
|
||||
reinterpret_cast<NewNativeClass0DispatchFn>(original);
|
||||
}
|
||||
|
||||
// Called only at VA 02523C8A, after native decoding/decompression and reader
|
||||
// binding, before admission is enforced. EDI may still be the protected outer
|
||||
// packet, so its header is copied but plaintext always comes from reader.cursor.
|
||||
// The native decompressor at 02523820 updates header+3 to decoded body size+16;
|
||||
// the enclosing router then binds that body size as reader.remaining. Using the
|
||||
// original socket receive length here would truncate compressed private frames.
|
||||
// Return true for every recognized private frame, including malformed frames:
|
||||
// the native router must never index its opcode tables with private opcode 7F41.
|
||||
static bool __cdecl CaptureNewNativeDecodedProjection(
|
||||
int sourceContext,
|
||||
const unsigned char* header)
|
||||
{
|
||||
bool privatePacket = false;
|
||||
bool captured = false;
|
||||
unsigned char* ownedFrame = nullptr;
|
||||
std::uint32_t packetLength = 0;
|
||||
int remaining = -1;
|
||||
unsigned long exceptionCode = 0;
|
||||
__try {
|
||||
do {
|
||||
if (!g_captureBase || !header || header[0] != 0 ||
|
||||
dnf90_companion_projection::Read16(header + 1) !=
|
||||
dnf90_companion_projection::kPrivateOpcode) {
|
||||
break;
|
||||
}
|
||||
privatePacket = true;
|
||||
packetLength = dnf90_companion_projection::Read32(header + 3);
|
||||
if (packetLength <
|
||||
dnf90_companion_projection::kPacketHeaderSize +
|
||||
dnf90_companion_projection::kBodyHeaderSize ||
|
||||
packetLength > kCapturePacketCapacity) {
|
||||
break;
|
||||
}
|
||||
const NewNativeReaderState reader =
|
||||
*reinterpret_cast<const NewNativeReaderState*>(
|
||||
g_captureBase + kReaderStateRva);
|
||||
remaining = reader.remaining;
|
||||
const std::size_t bodyLength = packetLength -
|
||||
dnf90_companion_projection::kPacketHeaderSize;
|
||||
if (!reader.cursor || reader.remaining < 0 ||
|
||||
static_cast<std::size_t>(reader.remaining) < bodyLength) {
|
||||
break;
|
||||
}
|
||||
ownedFrame = static_cast<unsigned char*>(
|
||||
HeapAlloc(GetProcessHeap(), 0, packetLength));
|
||||
if (!ownedFrame) break;
|
||||
std::memcpy(ownedFrame, header,
|
||||
dnf90_companion_projection::kPacketHeaderSize);
|
||||
std::memcpy(ownedFrame +
|
||||
dnf90_companion_projection::kPacketHeaderSize,
|
||||
reader.cursor, bodyLength);
|
||||
|
||||
// Lifecycle work can reenter native readers. The complete private
|
||||
// frame is owned before calling it, so no borrowed body survives.
|
||||
dnf90_companion_p0::NewNativeSafePoint(-1);
|
||||
captured = dnf90_companion_p0::CaptureProjectionClass0Packet(
|
||||
sourceContext, reinterpret_cast<int>(ownedFrame));
|
||||
if (captured) {
|
||||
dnf90_companion_p0::NewNativeSafePoint(
|
||||
dnf90_companion_projection::kPrivateOpcode);
|
||||
} else {
|
||||
dnf90_companion_p0::LogProjectionDecodeRejected(
|
||||
ownedFrame, packetLength, "new-native-decoded-boundary");
|
||||
}
|
||||
} while (false);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
exceptionCode = GetExceptionCode();
|
||||
}
|
||||
if (ownedFrame) HeapFree(GetProcessHeap(), 0, ownedFrame);
|
||||
if (privatePacket && g_captureLog) {
|
||||
g_captureLog("new-native companion decoded capture context=%d length=%u remaining=%d captured=%u exception=0x%08lX",
|
||||
sourceContext, static_cast<unsigned int>(packetLength), remaining,
|
||||
captured ? 1u : 0u, exceptionCode);
|
||||
}
|
||||
return privatePacket;
|
||||
}
|
||||
|
||||
// The displaced 10 bytes contain no relative instructions:
|
||||
// 8A 45 A3 mov al,[ebp-5D]
|
||||
// C7 45 AC 01 00 00 00 mov dword ptr [ebp-54],1
|
||||
// Resuming at 02523C94 preserves admission for all non-private packets. The
|
||||
// private branch enters the router's normal consumed epilogue at 02523F50.
|
||||
// That epilogue writes EAX=1 itself and never reads [EBP-54], so the private
|
||||
// branch deliberately does not initialize the displaced dispatch-result local.
|
||||
__declspec(naked) static void ProxyNewNativeProjectionCapture() {
|
||||
__asm {
|
||||
pushfd
|
||||
pushad
|
||||
push edi
|
||||
push dword ptr [ebp - 70h]
|
||||
call CaptureNewNativeDecodedProjection
|
||||
add esp, 8
|
||||
test al, al
|
||||
jnz private_consumed
|
||||
popad
|
||||
popfd
|
||||
mov al, byte ptr [ebp - 5Dh]
|
||||
mov dword ptr [ebp - 54h], 1
|
||||
jmp dword ptr [g_captureResume]
|
||||
private_consumed:
|
||||
popad
|
||||
popfd
|
||||
jmp dword ptr [g_captureConsumed]
|
||||
}
|
||||
}
|
||||
|
||||
// Current VA 0251E340 has the same two-argument stdcall ABI as the shared
|
||||
// legacy owner. Its callbacks consume global reader state before this point.
|
||||
static int __stdcall ProxyNewNativeClass0Dispatch(int sourceContext, int packet) {
|
||||
NewNativeClass0DispatchFn original = g_originalClass0Dispatch;
|
||||
if (!original) return 1;
|
||||
int opcode = -1;
|
||||
__try {
|
||||
const unsigned char* bytes =
|
||||
reinterpret_cast<const unsigned char*>(packet);
|
||||
if (bytes && bytes[0] == 0) {
|
||||
opcode = dnf90_companion_projection::Read16(bytes + 1);
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
opcode = -1;
|
||||
}
|
||||
const NewNativeTownSnapshot town = SnapshotNewNativeTown(opcode,
|
||||
reinterpret_cast<const unsigned char*>(packet));
|
||||
const NewNativeDungeonInfoSnapshot dungeon = SnapshotNewNativeDungeonInfo(opcode,
|
||||
reinterpret_cast<const unsigned char*>(packet));
|
||||
const int result = original(sourceContext, packet);
|
||||
EnsureNewNativeSafePointTimer();
|
||||
if (NewNativeTownWasAccepted(town)) {
|
||||
dnf90_companion_p0::CancelFixedApc5405();
|
||||
if (g_captureLog) g_captureLog(
|
||||
"new-native companion lifecycle cancelled reason=accepted-town opcode=24 key=%u consumed_min=%zu",
|
||||
static_cast<unsigned int>(town.localKey), town.consumedMinimum);
|
||||
return result;
|
||||
}
|
||||
if (dungeon.dungeon && NewNativeBodyWasConsumed(dungeon.start, dungeon.bodyLength, 36)) {
|
||||
dnf90_companion_p0::CancelFixedApc5405();
|
||||
if (g_captureLog) g_captureLog(
|
||||
"new-native companion lifecycle reset reason=accepted-dungeon-info opcode=28 dungeon=%u",
|
||||
static_cast<unsigned int>(dungeon.dungeon));
|
||||
}
|
||||
if (opcode >= 0) {
|
||||
dnf90_companion_p0::NewNativeSafePoint(opcode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace dnf90_companion_new_native
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,493 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
namespace dnf90_companion_p0 {
|
||||
|
||||
struct FixedApcDescriptor {
|
||||
std::int32_t apcCode;
|
||||
std::int32_t minimumLevel;
|
||||
std::int32_t maximumLevel;
|
||||
std::int32_t lifetimeValue;
|
||||
};
|
||||
|
||||
constexpr std::int32_t kCompanionLifetimeValue = 0x7FFFFFFF;
|
||||
|
||||
constexpr FixedApcDescriptor FixedApc5405Descriptor() {
|
||||
return {5405, 25, 25, kCompanionLifetimeValue};
|
||||
}
|
||||
|
||||
constexpr FixedApcDescriptor FixedApc56901Descriptor() {
|
||||
return {56901, 70, 70, kCompanionLifetimeValue};
|
||||
}
|
||||
|
||||
struct ProjectionBaseline {
|
||||
FixedApcDescriptor descriptor{};
|
||||
std::uint8_t sourceJob = 0;
|
||||
std::uint8_t sourceGrow = 0;
|
||||
std::uint8_t aicJob = 0;
|
||||
std::uint8_t aicGrow = 0;
|
||||
bool bridged = false;
|
||||
};
|
||||
|
||||
struct ProjectionBaselineEntry {
|
||||
std::uint8_t sourceJob;
|
||||
std::uint8_t sourceGrow;
|
||||
std::int32_t apcCode;
|
||||
std::int32_t baselineLevel;
|
||||
std::uint8_t aicJob;
|
||||
std::uint8_t aicGrow;
|
||||
};
|
||||
|
||||
struct ProjectionBaselineContract {
|
||||
static const ProjectionBaselineEntry* Entries(std::size_t* count) {
|
||||
static const ProjectionBaselineEntry entries[] = {
|
||||
{0,0,900,70,0,0},{0,1,1201,86,0,1},{0,2,1202,86,0,2},{0,3,1203,86,0,3},{0,4,1204,86,0,4},{0,5,900,70,0,0},
|
||||
{1,0,5405,25,1,0},{1,1,6200,86,1,1},{1,2,6201,86,1,2},{1,3,6202,86,1,3},{1,4,6203,86,1,4},
|
||||
{2,0,10903,70,2,0},{2,1,11201,86,2,1},{2,2,11202,86,2,2},{2,3,11203,86,2,3},{2,4,11204,86,2,4},{2,5,10903,70,2,0},
|
||||
{3,0,15902,70,3,0},{3,1,15121,86,3,1},{3,2,15122,86,3,2},{3,3,15123,86,3,3},{3,4,15124,86,3,4},{3,5,15902,70,3,0},
|
||||
{4,0,20902,70,4,0},{4,1,21201,86,4,1},{4,2,21202,86,4,2},{4,3,21203,86,4,3},{4,4,21204,86,4,4},{4,5,20902,70,4,0},
|
||||
{5,0,25900,70,5,0},{5,1,26201,86,5,1},{5,2,26202,86,5,2},{5,3,26203,86,5,3},{5,4,26204,86,5,4},{5,5,25900,70,5,0},
|
||||
{6,0,30901,70,6,0},{6,1,31200,86,6,1},{6,2,31201,86,6,2},{6,3,31202,86,6,3},{6,4,31203,86,6,4},{6,5,30901,70,6,0},
|
||||
{7,0,35903,70,7,0},{7,1,36200,86,7,1},{7,2,36201,86,7,2},{7,3,36202,86,7,3},{7,4,36203,86,7,4},
|
||||
{8,0,40800,75,8,0},{8,1,41201,86,8,1},{8,2,41202,86,8,2},{8,3,41210,86,8,3},{8,4,41213,86,8,4},{8,5,41216,86,8,5},
|
||||
{9,0,902,70,0,0},
|
||||
{10,0,15903,70,3,0},
|
||||
{11,0,56901,70,11,0},{11,1,56200,86,11,1},{11,2,56201,86,11,2},{11,3,56202,86,11,3},{11,4,56203,86,11,4},{11,5,56901,70,11,0},
|
||||
{12,0,60901,70,12,0},{12,1,61200,86,12,1},{12,2,61209,86,12,2},{12,3,61203,86,12,3},{12,4,61206,86,12,4},
|
||||
{13,0,65001,70,13,0},{13,1,65121,86,13,1},{13,2,65122,86,13,2},{13,3,65001,70,13,0},{13,4,65001,70,13,0},
|
||||
{14,0,70003,70,14,0},{14,1,71200,86,14,1},{14,2,71203,86,14,2},{14,3,71206,86,14,3},{14,4,71209,86,14,4},
|
||||
{15,0,75001,70,15,0},{15,1,75001,70,15,0},{15,2,75001,70,15,0},{15,3,75001,70,15,0},{15,4,75001,70,15,0},
|
||||
};
|
||||
if (count) {
|
||||
*count = sizeof(entries) / sizeof(entries[0]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
static std::size_t EntryCount() {
|
||||
std::size_t count = 0;
|
||||
Entries(&count);
|
||||
return count;
|
||||
}
|
||||
|
||||
static bool EntryAt(
|
||||
std::size_t index,
|
||||
ProjectionBaselineEntry* output)
|
||||
{
|
||||
if (!output) return false;
|
||||
std::size_t count = 0;
|
||||
const ProjectionBaselineEntry* entries =
|
||||
Entries(&count);
|
||||
if (index >= count) return false;
|
||||
*output = entries[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TrySelect(
|
||||
std::uint8_t job,
|
||||
std::uint8_t packedGrow,
|
||||
ProjectionBaseline* output)
|
||||
{
|
||||
if (!output) return false;
|
||||
const std::uint8_t firstGrow =
|
||||
static_cast<std::uint8_t>(packedGrow & 0x0Fu);
|
||||
const std::uint8_t awakening =
|
||||
static_cast<std::uint8_t>((packedGrow >> 4) & 0x0Fu);
|
||||
if (awakening > 2) return false;
|
||||
|
||||
std::size_t count = 0;
|
||||
const ProjectionBaselineEntry* entries =
|
||||
Entries(&count);
|
||||
const ProjectionBaselineEntry* exact = nullptr;
|
||||
const ProjectionBaselineEntry* stable = nullptr;
|
||||
for (std::size_t index = 0;
|
||||
index < count;
|
||||
++index) {
|
||||
const ProjectionBaselineEntry& entry =
|
||||
entries[index];
|
||||
if (entry.sourceJob != job) {
|
||||
continue;
|
||||
}
|
||||
if (!exact && entry.sourceGrow == firstGrow) {
|
||||
exact = &entry;
|
||||
}
|
||||
if (!stable &&
|
||||
entry.sourceGrow == 0 &&
|
||||
entry.aicJob == job &&
|
||||
entry.aicGrow == 0) {
|
||||
stable = &entry;
|
||||
}
|
||||
}
|
||||
if (!exact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// PVP/branch-specific APCs often carry their own movement/FSM policy.
|
||||
// Prefer one same-job grow=0 PVE shell and project the selected
|
||||
// character's grow/awakening/skills onto the per-instance AIC copy.
|
||||
// Jobs without a same-job stable shell keep their exact legacy entry.
|
||||
const ProjectionBaselineEntry& selected =
|
||||
stable ? *stable : *exact;
|
||||
output->descriptor = {
|
||||
selected.apcCode,
|
||||
selected.baselineLevel,
|
||||
selected.baselineLevel,
|
||||
kCompanionLifetimeValue
|
||||
};
|
||||
output->sourceJob = job;
|
||||
output->sourceGrow = firstGrow;
|
||||
output->aicJob = selected.aicJob;
|
||||
output->aicGrow = selected.aicGrow;
|
||||
output->bridged =
|
||||
selected.aicJob != job ||
|
||||
selected.aicGrow != firstGrow;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
struct ProjectionQuickSkillContract {
|
||||
static bool PairValid(
|
||||
std::uint32_t id,
|
||||
std::uint32_t level)
|
||||
{
|
||||
return (id == 0) == (level == 0);
|
||||
}
|
||||
};
|
||||
|
||||
struct ProjectionGrowBridgeContract {
|
||||
static std::uint8_t FirstGrow(
|
||||
std::uint8_t packedGrow)
|
||||
{
|
||||
return static_cast<std::uint8_t>(
|
||||
packedGrow & 0x0Fu);
|
||||
}
|
||||
|
||||
static std::uint8_t AwakeningStage(
|
||||
std::uint8_t packedGrow)
|
||||
{
|
||||
return static_cast<std::uint8_t>(
|
||||
(packedGrow >> 4) & 0x0Fu);
|
||||
}
|
||||
|
||||
static bool SameJob(
|
||||
const ProjectionBaseline& baseline)
|
||||
{
|
||||
return baseline.sourceJob == baseline.aicJob;
|
||||
}
|
||||
|
||||
static bool ShouldApplyFirstGrow(
|
||||
const ProjectionBaseline& baseline)
|
||||
{
|
||||
return SameJob(baseline) &&
|
||||
baseline.sourceGrow != baseline.aicGrow;
|
||||
}
|
||||
|
||||
static bool ShouldApplyAwakening(
|
||||
const ProjectionBaseline& baseline,
|
||||
std::uint8_t packedGrow)
|
||||
{
|
||||
return SameJob(baseline) &&
|
||||
AwakeningStage(packedGrow) != 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct ProjectionRuntimeKeyContract {
|
||||
static constexpr std::uint32_t kQuickSlotCount = 12;
|
||||
static constexpr std::uint32_t kDirectPoolBase = 12;
|
||||
static constexpr std::uint32_t kBasicAttackProgramIndex = 24;
|
||||
static constexpr std::uint32_t kRequiredProgramCount = 25;
|
||||
// Native AIC loaders are not uniform: some job shells expose one
|
||||
// additional trailing runtime program. The companion-owned programs
|
||||
// remain indices 0..24, so accept the proven 25/26 shapes and validate
|
||||
// every mutable direct-pool slot before writing anything.
|
||||
static constexpr std::uint32_t kMaxCompatibleProgramCount = 26;
|
||||
static constexpr std::uint32_t kPlaceholderSkillId = 511;
|
||||
static constexpr std::uint32_t kSkillCodeBias = 0x100;
|
||||
|
||||
static bool EncodeSkill(
|
||||
std::uint32_t skillId,
|
||||
std::uint32_t* encoded)
|
||||
{
|
||||
if (!encoded || skillId == 0 ||
|
||||
skillId > 0xFFFFu) {
|
||||
return false;
|
||||
}
|
||||
*encoded = kSkillCodeBias + skillId;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ProgramCountValid(
|
||||
std::size_t count)
|
||||
{
|
||||
return count >= kRequiredProgramCount &&
|
||||
count <= kMaxCompatibleProgramCount;
|
||||
}
|
||||
};
|
||||
|
||||
static_assert(sizeof(FixedApcDescriptor) == 16,
|
||||
"native helper reads exactly four dwords");
|
||||
|
||||
class FixedApcRetryPolicy {
|
||||
public:
|
||||
void Arm() {
|
||||
pending_ = true;
|
||||
submitted_ = false;
|
||||
}
|
||||
|
||||
void Cancel() {
|
||||
pending_ = false;
|
||||
submitted_ = false;
|
||||
}
|
||||
|
||||
bool ShouldSubmit(
|
||||
bool routeReady,
|
||||
bool nativeReady,
|
||||
bool sceneReady,
|
||||
bool projectionReady) {
|
||||
if (!pending_ || submitted_ ||
|
||||
!routeReady || !nativeReady || !sceneReady ||
|
||||
!projectionReady) {
|
||||
return false;
|
||||
}
|
||||
submitted_ = true;
|
||||
pending_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Pending() const { return pending_; }
|
||||
bool Submitted() const { return submitted_; }
|
||||
|
||||
private:
|
||||
bool pending_ = false;
|
||||
bool submitted_ = false;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
struct DynamicAicApplyContract {
|
||||
static bool Matches(
|
||||
bool projectionPending,
|
||||
bool insideP0Receive,
|
||||
bool alreadyApplied,
|
||||
std::uint32_t currentThread,
|
||||
std::uint32_t armedThread,
|
||||
const void* destination,
|
||||
const void* source)
|
||||
{
|
||||
return projectionPending &&
|
||||
insideP0Receive &&
|
||||
!alreadyApplied &&
|
||||
armedThread != 0 &&
|
||||
currentThread == armedThread &&
|
||||
destination != nullptr &&
|
||||
source != nullptr &&
|
||||
destination != source;
|
||||
}
|
||||
|
||||
static bool VectorFits(
|
||||
std::uintptr_t begin,
|
||||
std::uintptr_t end,
|
||||
std::uintptr_t capacityEnd,
|
||||
std::size_t count,
|
||||
std::size_t elementSize)
|
||||
{
|
||||
if (!begin || elementSize == 0 ||
|
||||
end < begin || capacityEnd < end) return false;
|
||||
if (((end - begin) % elementSize) != 0 ||
|
||||
((capacityEnd - begin) % elementSize) != 0) return false;
|
||||
return count <= (capacityEnd - begin) / elementSize;
|
||||
}
|
||||
|
||||
static bool EquipmentFits(
|
||||
std::uintptr_t begin,
|
||||
std::uintptr_t end,
|
||||
std::uintptr_t capacityEnd,
|
||||
std::size_t count)
|
||||
{
|
||||
return VectorFits(begin, end, capacityEnd, count, 12u);
|
||||
}
|
||||
|
||||
static bool SkillFits(
|
||||
std::uintptr_t begin,
|
||||
std::uintptr_t end,
|
||||
std::uintptr_t capacityEnd,
|
||||
std::size_t count)
|
||||
{
|
||||
return VectorFits(begin, end, capacityEnd, count, 8u);
|
||||
}
|
||||
};
|
||||
|
||||
struct ProjectionLookupContract {
|
||||
static bool Matches(
|
||||
bool applyActive,
|
||||
std::uint16_t requestedKey,
|
||||
std::uint16_t apcKey,
|
||||
const void* apcActor)
|
||||
{
|
||||
return applyActive &&
|
||||
requestedKey != 0 &&
|
||||
requestedKey != 0xFFFF &&
|
||||
requestedKey == apcKey &&
|
||||
apcActor != nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
struct SceneModeEntryContract {
|
||||
static bool IsRelativeJump(std::uint8_t opcode) {
|
||||
return opcode == 0xE9;
|
||||
}
|
||||
|
||||
static bool JumpTargetInsideModule(
|
||||
std::uintptr_t entry,
|
||||
std::int32_t relative,
|
||||
std::uintptr_t moduleBase,
|
||||
std::size_t moduleSize)
|
||||
{
|
||||
if (!entry || !moduleBase || moduleSize == 0) return false;
|
||||
const std::uintptr_t target =
|
||||
entry + 5u + static_cast<std::intptr_t>(relative);
|
||||
return target >= moduleBase &&
|
||||
target < moduleBase + moduleSize;
|
||||
}
|
||||
};
|
||||
|
||||
struct ProjectionRegistrationContract {
|
||||
static bool Matches(
|
||||
bool p0Armed,
|
||||
bool insideP0Receive,
|
||||
std::uint32_t currentThread,
|
||||
std::uint32_t armedThread,
|
||||
std::uint32_t objectKey,
|
||||
const void* actor,
|
||||
unsigned char nativeResult)
|
||||
{
|
||||
return p0Armed &&
|
||||
insideP0Receive &&
|
||||
armedThread != 0 &&
|
||||
currentThread == armedThread &&
|
||||
objectKey != 0 &&
|
||||
objectKey < 0xFFFFu &&
|
||||
actor != nullptr &&
|
||||
nativeResult != 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct DirectOp40SendContract {
|
||||
static bool Matches(
|
||||
std::uint32_t routeKind,
|
||||
std::int32_t targetSlot,
|
||||
const void* actor,
|
||||
const void* armedActor,
|
||||
std::uint32_t mode)
|
||||
{
|
||||
return routeKind == 1 &&
|
||||
targetSlot == -1 &&
|
||||
actor != nullptr &&
|
||||
actor == armedActor &&
|
||||
mode == 3;
|
||||
}
|
||||
};
|
||||
|
||||
struct DirectOp40PacketContract {
|
||||
static constexpr std::size_t kHeaderSize = 7;
|
||||
static constexpr std::uint8_t kClassification = 2;
|
||||
static constexpr std::uint16_t kMessageID = 40;
|
||||
|
||||
static bool Matches(const std::uint8_t* packet, std::size_t length) {
|
||||
if (!packet || length < kHeaderSize) return false;
|
||||
const std::uint16_t messageID =
|
||||
static_cast<std::uint16_t>(packet[1]) |
|
||||
static_cast<std::uint16_t>(packet[2] << 8);
|
||||
return packet[0] == kClassification &&
|
||||
messageID == kMessageID;
|
||||
}
|
||||
};
|
||||
|
||||
struct DirectOp40PeerDeliveryContract {
|
||||
static bool Matches(
|
||||
bool armed,
|
||||
bool handled,
|
||||
bool inReceive,
|
||||
std::uint32_t currentThread,
|
||||
std::uint32_t armedThread,
|
||||
const std::uint8_t* packet,
|
||||
std::size_t length,
|
||||
std::uint32_t routeKind,
|
||||
std::uint32_t mode)
|
||||
{
|
||||
return armed &&
|
||||
handled &&
|
||||
!inReceive &&
|
||||
armedThread != 0 &&
|
||||
currentThread == armedThread &&
|
||||
routeKind == 1 &&
|
||||
mode == 3 &&
|
||||
DirectOp40PacketContract::Matches(packet, length);
|
||||
}
|
||||
};
|
||||
|
||||
bool DirectOp40Enabled();
|
||||
bool ConfigureNewNativeBindings();
|
||||
void SetNewNativeHooksReady(bool ready);
|
||||
void NewNativeSafePoint(int opcode);
|
||||
void SetOriginalPayloadEncode(void* original);
|
||||
void __cdecl ProxyPayloadEncode(
|
||||
std::uint8_t* payload,
|
||||
std::int32_t length,
|
||||
void* keyState);
|
||||
void SetOriginalPeerDelivery(void* original);
|
||||
void SetOriginalNetworkRouter(void* original);
|
||||
int __fastcall ProxyNetworkRouter(
|
||||
void* self,
|
||||
void* unusedEdx,
|
||||
std::int32_t sourceContext,
|
||||
std::uint8_t* packet,
|
||||
std::int32_t totalLength);
|
||||
void SetOriginalAicCopy(void* original);
|
||||
void* __fastcall ProxyAicCopy(
|
||||
void* destination,
|
||||
void* unusedEdx,
|
||||
const void* source);
|
||||
void SetOriginalApcRegistration(void* original);
|
||||
void SetOriginalAICondition(void* original);
|
||||
unsigned char __fastcall ProxyAICondition(void* condition, void* unusedEdx);
|
||||
unsigned char __fastcall ProxyApcRegistration(
|
||||
void* self,
|
||||
void* unusedEdx,
|
||||
void* ownerActor,
|
||||
std::uint32_t flags,
|
||||
std::uint32_t objectKey,
|
||||
void* actor,
|
||||
std::uint32_t value5,
|
||||
std::uint32_t value6,
|
||||
std::uint32_t value7,
|
||||
std::uint32_t value8);
|
||||
void SetProjectionClass0Dispatch(void* original);
|
||||
bool CaptureProjectionClass0Packet(int dispatcherArg, int packet);
|
||||
void LogProjectionDecodeRejected(
|
||||
const unsigned char* bytes,
|
||||
std::size_t packetLength,
|
||||
const char* stage);
|
||||
bool ApplyPendingProjection();
|
||||
void* ProjectionActorOverrideForObjectKey(std::uint16_t objectKey);
|
||||
bool ProjectionMode0OwnerContextOverride();
|
||||
|
||||
void __fastcall ProxyPeerDelivery(
|
||||
void* self,
|
||||
void* unusedEdx,
|
||||
std::uint8_t* packet,
|
||||
std::int32_t length,
|
||||
std::int32_t slot,
|
||||
std::uint32_t routeKind,
|
||||
std::uint32_t mode);
|
||||
|
||||
// Test-only P0 lifecycle. Arm on confirmed dungeon entry, poll from subsequent
|
||||
// native dispatch callbacks, cancel on accepted return-to-town.
|
||||
void ArmFixedApc5405();
|
||||
bool PollFixedApc5405();
|
||||
void CancelFixedApc5405();
|
||||
|
||||
} // namespace dnf90_companion_p0
|
||||
@@ -0,0 +1,240 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace dnf90_companion_projection {
|
||||
|
||||
constexpr std::uint16_t kPrivateOpcode = 0x7F41;
|
||||
constexpr std::uint32_t kMagic = 0x314A5250u; // "PRJ1" little-endian
|
||||
constexpr std::uint8_t kVersion = 3;
|
||||
constexpr std::uint8_t kFlagPresent = 0x01;
|
||||
constexpr std::uint8_t kFlagProgression = 0x02;
|
||||
constexpr std::uint8_t kFlagAiPlan = 0x04;
|
||||
constexpr std::size_t kPacketHeaderSize = 16; // network router/class0 dispatch packet
|
||||
constexpr std::size_t kBodyHeaderSize = 72;
|
||||
constexpr std::size_t kMode0ObjectKeyOffset = 0x4C;
|
||||
constexpr std::size_t kMode1ObjectKeyOffset = 0x15;
|
||||
constexpr std::size_t kMaxProjectionBody = 32768;
|
||||
constexpr std::size_t kMaxDynamicAicBytes = 2048;
|
||||
constexpr std::size_t kMaxAicNameUnits = 32;
|
||||
constexpr std::size_t kMaxAicEquipment = 32;
|
||||
constexpr std::size_t kMaxAicSkills = 128;
|
||||
constexpr std::size_t kMaxAicQuickSkills = 16;
|
||||
constexpr std::uint16_t kPlaceholderObjectKey = 0xFFFF;
|
||||
|
||||
inline std::uint16_t Read16(const std::uint8_t* p) {
|
||||
return static_cast<std::uint16_t>(p[0]) |
|
||||
static_cast<std::uint16_t>(p[1] << 8);
|
||||
}
|
||||
inline std::uint32_t Read32(const std::uint8_t* p) {
|
||||
return static_cast<std::uint32_t>(p[0]) |
|
||||
(static_cast<std::uint32_t>(p[1]) << 8) |
|
||||
(static_cast<std::uint32_t>(p[2]) << 16) |
|
||||
(static_cast<std::uint32_t>(p[3]) << 24);
|
||||
}
|
||||
inline std::uint64_t Read64(const std::uint8_t* p) {
|
||||
return static_cast<std::uint64_t>(Read32(p)) |
|
||||
(static_cast<std::uint64_t>(Read32(p + 4)) << 32);
|
||||
}
|
||||
inline void Write16(std::uint8_t* p, std::uint16_t v) {
|
||||
p[0] = static_cast<std::uint8_t>(v);
|
||||
p[1] = static_cast<std::uint8_t>(v >> 8);
|
||||
}
|
||||
|
||||
struct View {
|
||||
bool present = false;
|
||||
bool progression = false;
|
||||
std::uint32_t experience = 0;
|
||||
std::uint8_t aiBehavior = 0;
|
||||
std::uint8_t aiComboPresetHash = 0;
|
||||
std::uint16_t aiClass = 0;
|
||||
bool aiComboEnabled = false;
|
||||
std::uint16_t sourceCharacter = 0;
|
||||
std::uint64_t revision = 0;
|
||||
const std::uint8_t* fingerprint = nullptr;
|
||||
std::uint8_t job = 0;
|
||||
std::uint8_t grow = 0;
|
||||
std::uint16_t level = 0;
|
||||
const std::uint8_t* nameUtf16 = nullptr;
|
||||
std::size_t nameUnits = 0;
|
||||
const std::uint8_t* equipmentIds = nullptr;
|
||||
std::size_t equipmentCount = 0;
|
||||
const std::uint8_t* skills = nullptr;
|
||||
std::size_t skillCount = 0;
|
||||
const std::uint8_t* quickSkills = nullptr;
|
||||
std::size_t quickSkillCount = 0;
|
||||
const std::uint8_t* mode0 = nullptr;
|
||||
std::size_t mode0Length = 0;
|
||||
const std::uint8_t* mode1 = nullptr;
|
||||
std::size_t mode1Length = 0;
|
||||
};
|
||||
|
||||
inline bool BodiesHavePlaceholder(
|
||||
const std::uint8_t* mode0, std::size_t mode0Length,
|
||||
const std::uint8_t* mode1, std::size_t mode1Length)
|
||||
{
|
||||
if (!mode0 ||
|
||||
mode0Length <= kMode0ObjectKeyOffset + 1 ||
|
||||
mode0[0] != 0 ||
|
||||
Read16(mode0 + 1) != 1 ||
|
||||
Read16(mode0 + kMode0ObjectKeyOffset) !=
|
||||
kPlaceholderObjectKey) {
|
||||
return false;
|
||||
}
|
||||
if (mode1Length == 0) {
|
||||
return mode1 == nullptr;
|
||||
}
|
||||
return mode1 &&
|
||||
mode1Length > kMode1ObjectKeyOffset + 1 &&
|
||||
mode1[0] == 1 &&
|
||||
Read16(mode1 + 1) == 1 &&
|
||||
Read16(mode1 + kMode1ObjectKeyOffset) ==
|
||||
kPlaceholderObjectKey;
|
||||
}
|
||||
|
||||
inline bool DecodeBody(
|
||||
const std::uint8_t* body,
|
||||
std::size_t bodyLength,
|
||||
View* out)
|
||||
{
|
||||
if (!body || !out || bodyLength < kBodyHeaderSize) {
|
||||
return false;
|
||||
}
|
||||
if (Read32(body) != kMagic || body[4] != kVersion ||
|
||||
(body[5] & ~(kFlagPresent | kFlagProgression | kFlagAiPlan)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
View value{};
|
||||
value.present = (body[5] & kFlagPresent) != 0;
|
||||
value.progression = (body[5] & kFlagProgression) != 0;
|
||||
value.experience = Read32(body + 68);
|
||||
const bool hasAiPlan = (body[5] & kFlagAiPlan) != 0;
|
||||
if (hasAiPlan && value.progression) return false;
|
||||
if (hasAiPlan) {
|
||||
value.aiBehavior = static_cast<std::uint8_t>(value.experience & 0x03);
|
||||
value.aiComboEnabled = (value.experience & 0x04) != 0;
|
||||
value.aiComboPresetHash = static_cast<std::uint8_t>((value.experience >> 8) & 0xFF);
|
||||
value.aiClass = static_cast<std::uint16_t>((value.experience >> 16) & 0xFFFF);
|
||||
if (value.aiBehavior < 1 || value.aiBehavior > 3) return false;
|
||||
}
|
||||
value.sourceCharacter = Read16(body + 6);
|
||||
value.revision = Read64(body + 8);
|
||||
value.mode0Length = Read32(body + 16);
|
||||
value.mode1Length = Read32(body + 20);
|
||||
value.fingerprint = body + 24;
|
||||
value.job = body[56];
|
||||
value.grow = body[57];
|
||||
value.nameUnits = Read16(body + 58);
|
||||
value.equipmentCount = Read16(body + 60);
|
||||
value.level = Read16(body + 62);
|
||||
value.skillCount = Read16(body + 64);
|
||||
value.quickSkillCount = Read16(body + 66);
|
||||
|
||||
if (value.revision == 0 || (!value.progression && !hasAiPlan && value.experience != 0)) return false;
|
||||
if (!value.present) {
|
||||
if (value.progression || value.sourceCharacter != 0 ||
|
||||
value.mode0Length != 0 || value.mode1Length != 0 ||
|
||||
value.nameUnits != 0 || value.equipmentCount != 0 ||
|
||||
value.level != 0 || value.skillCount != 0 ||
|
||||
value.quickSkillCount != 0 ||
|
||||
bodyLength != kBodyHeaderSize || hasAiPlan) {
|
||||
return false;
|
||||
}
|
||||
*out = value;
|
||||
return true;
|
||||
}
|
||||
if (value.sourceCharacter == 0 ||
|
||||
value.mode0Length == 0 ||
|
||||
value.mode0Length > kMaxProjectionBody ||
|
||||
value.mode1Length > kMaxProjectionBody ||
|
||||
value.level == 0 || value.level > 90 ||
|
||||
value.nameUnits == 0 ||
|
||||
value.nameUnits > kMaxAicNameUnits ||
|
||||
value.equipmentCount > kMaxAicEquipment ||
|
||||
value.skillCount > kMaxAicSkills ||
|
||||
value.quickSkillCount > kMaxAicQuickSkills) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t nameBytes = value.nameUnits * 2;
|
||||
const std::size_t equipmentBytes = value.equipmentCount * 4;
|
||||
const std::size_t skillBytes = value.skillCount * 8;
|
||||
const std::size_t quickSkillBytes = value.quickSkillCount * 8;
|
||||
const std::size_t dynamicBytes =
|
||||
nameBytes + equipmentBytes + skillBytes + quickSkillBytes;
|
||||
if (dynamicBytes > kMaxDynamicAicBytes) return false;
|
||||
const std::size_t wanted =
|
||||
kBodyHeaderSize + dynamicBytes +
|
||||
value.mode0Length + value.mode1Length;
|
||||
if (wanted != bodyLength) return false;
|
||||
value.nameUtf16 = body + kBodyHeaderSize;
|
||||
value.equipmentIds = value.nameUtf16 + nameBytes;
|
||||
value.skills = value.equipmentIds + equipmentBytes;
|
||||
value.quickSkills = value.skills + skillBytes;
|
||||
value.mode0 = value.quickSkills + quickSkillBytes;
|
||||
value.mode1 = value.mode1Length != 0
|
||||
? value.mode0 + value.mode0Length
|
||||
: nullptr;
|
||||
if (!BodiesHavePlaceholder(
|
||||
value.mode0, value.mode0Length,
|
||||
value.mode1, value.mode1Length)) {
|
||||
return false;
|
||||
}
|
||||
// Live progression carries the existing native 92-byte base-stat block,
|
||||
// but never dispatches mode0/mode1 (which can reconstruct actor resources).
|
||||
if (value.progression && (value.mode1Length < 123 ||
|
||||
Read32(value.mode1 + 27) != 92)) return false;
|
||||
for (std::size_t i = 0; value.progression && i < value.skillCount; ++i) {
|
||||
if (Read32(value.skills + i * 8) == 0 || Read32(value.skills + i * 8) >= 512 ||
|
||||
Read32(value.skills + i * 8 + 4) == 0 || Read32(value.skills + i * 8 + 4) > 255) return false;
|
||||
}
|
||||
*out = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool IsPrivateNetworkPacketHeader(
|
||||
const std::uint8_t* packet,
|
||||
std::size_t totalLength)
|
||||
{
|
||||
if (!packet ||
|
||||
totalLength < kPacketHeaderSize + kBodyHeaderSize) {
|
||||
return false;
|
||||
}
|
||||
const std::uint32_t packetLength = Read32(packet + 3);
|
||||
return packet[0] == 0 &&
|
||||
Read16(packet + 1) == kPrivateOpcode &&
|
||||
packetLength == totalLength;
|
||||
}
|
||||
|
||||
inline bool DecodePacket(
|
||||
const std::uint8_t* packet,
|
||||
std::size_t available,
|
||||
View* out)
|
||||
{
|
||||
if (!IsPrivateNetworkPacketHeader(packet, available) || !out) {
|
||||
return false;
|
||||
}
|
||||
return DecodeBody(
|
||||
packet + kPacketHeaderSize,
|
||||
available - kPacketHeaderSize,
|
||||
out);
|
||||
}
|
||||
|
||||
inline bool PatchTargetObjectKey(
|
||||
std::uint8_t* mode0, std::size_t mode0Length,
|
||||
std::uint8_t* mode1, std::size_t mode1Length,
|
||||
std::uint16_t objectKey)
|
||||
{
|
||||
if (objectKey == 0 || objectKey == kPlaceholderObjectKey ||
|
||||
!BodiesHavePlaceholder(mode0, mode0Length, mode1, mode1Length)) {
|
||||
return false;
|
||||
}
|
||||
Write16(mode0 + kMode0ObjectKeyOffset, objectKey);
|
||||
if (mode1Length != 0) {
|
||||
Write16(mode1 + kMode1ObjectKeyOffset, objectKey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace dnf90_companion_projection
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include "90CN_companion_skill_data.h"
|
||||
namespace dnf90_companion_quickbar {
|
||||
enum class Purpose { Empty, Combat, PartyBuff, Heal, Passive, SupportAwakening, SelfBuff, DefensiveSupport, Unsupported, Summon, SelfHeal };
|
||||
inline bool IsAssistMode(std::uint8_t behavior) { return behavior != 2; }
|
||||
inline bool SupportsQuickbarPolicy(std::uint8_t job) { return job < 16; }
|
||||
inline bool NativeActionRecent(std::uint32_t now, std::uint32_t started, std::uint32_t window) { return now >= started && now - started < window; }
|
||||
inline bool OrdinarySupportAllowed(std::uint8_t behavior, bool fighting, bool hurt) { return !hurt && (IsAssistMode(behavior) || !fighting); }
|
||||
inline Purpose Classify(std::uint8_t job, std::uint32_t id, std::uint8_t grow = 0) {
|
||||
if (!id) return Purpose::Empty;
|
||||
const auto* r = dnf90_companion_skill_data::Find(job,id);
|
||||
if (!r) return Purpose::Unsupported;
|
||||
// Low nibble is the profession branch; the high nibble is awakening.
|
||||
if (r->passiveGrow & (1u << (grow & 15))) return Purpose::Passive;
|
||||
return static_cast<Purpose>(r->purpose);
|
||||
}
|
||||
inline std::uint32_t CooldownMS(std::uint8_t job, std::uint32_t id) { const auto* r=dnf90_companion_skill_data::Find(job,id); return r ? r->cooldown : 0; }
|
||||
inline bool HasNativeBuffCheck(std::uint8_t job, std::uint32_t id) { const auto* r=dnf90_companion_skill_data::Find(job,id); return r && (r->flags & 2); }
|
||||
inline bool IsAreaCombat(std::uint8_t job, std::uint32_t id) { const auto* r=dnf90_companion_skill_data::Find(job,id); return r && r->purpose==1 && (r->flags & 4); }
|
||||
inline bool CombatPurposeAllowed(std::uint8_t behavior, Purpose purpose) { return !IsAssistMode(behavior) && purpose==Purpose::Combat; }
|
||||
// Accepted dispatch is not an effect receipt. Refresh is a conservative AI
|
||||
// policy. Switches and summons are issued once per native key program lifetime
|
||||
// so another press cannot cancel an existing state.
|
||||
inline bool SupportDispatchHandled(std::uint8_t job, std::uint32_t id, std::uint32_t now, std::uint32_t acceptedAt) {
|
||||
const auto* r=dnf90_companion_skill_data::Find(job,id);
|
||||
if (!r || !acceptedAt) return false;
|
||||
if (r->flags & 1) return true;
|
||||
return r->refresh && NativeActionRecent(now,acceptedAt,r->refresh);
|
||||
}
|
||||
inline bool EligibleSupportBuff(std::uint8_t job, std::uint32_t id, std::uint32_t learned, std::uint8_t grow=0) {
|
||||
const auto p=Classify(job,id,grow);
|
||||
return learned && (p==Purpose::PartyBuff || p==Purpose::SelfBuff || p==Purpose::Summon);
|
||||
}
|
||||
inline bool FirstSupportOption(std::uint8_t job, const std::uint32_t (&quick)[12], const std::uint32_t (&learned)[512], std::size_t slot, std::uint8_t grow) {
|
||||
const auto id=quick[slot]; const auto* r=dnf90_companion_skill_data::Find(job,id);
|
||||
for (std::size_t j=0; j<slot; ++j) {
|
||||
if (quick[j]==id) return false;
|
||||
const auto* prev=dnf90_companion_skill_data::Find(job,quick[j]);
|
||||
if (r && r->group && prev && prev->group==r->group && EligibleSupportBuff(job,quick[j],learned[quick[j]],grow)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
template<class Handled,class Ready>
|
||||
int SelectSupportBuff(std::uint8_t job, const std::uint32_t (&quick)[12], const std::uint32_t (&learned)[512], Handled handled, Ready ready, std::uint8_t grow=0) {
|
||||
constexpr Purpose priorities[]={Purpose::PartyBuff,Purpose::SelfBuff,Purpose::Summon};
|
||||
for (const auto priority:priorities) for (std::size_t i=0;i<12;++i) {
|
||||
const auto id=quick[i];
|
||||
if (id>=512 || !EligibleSupportBuff(job,id,learned[id],grow) || Classify(job,id,grow)!=priority) continue;
|
||||
if (FirstSupportOption(job,quick,learned,i,grow) && !handled(id) && ready(i)) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
template<class Ready>
|
||||
int SelectSupportAwakening(std::uint8_t job, const std::uint32_t (&quick)[12], const std::uint32_t (&learned)[512], Ready ready, std::uint8_t grow=0) {
|
||||
for (std::size_t i=0;i<12;++i) {
|
||||
const auto id=quick[i];
|
||||
if (id>=512 || !learned[id] || Classify(job,id,grow)!=Purpose::SupportAwakening) continue;
|
||||
if (FirstSupportOption(job,quick,learned,i,grow) && ready(i)) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,214 @@
|
||||
// 90CN_digest_trace.cpp —— 章节过场(op1378 / story digest)客户端侧**只读**日志。
|
||||
//
|
||||
// 背景:服务端能证明它把 op1378 发出去了(日志事件
|
||||
// game-current-story-digest-last-level-send 带 last_level),但现象是
|
||||
// 「完成开区任务当次不播,小退重登才播」。要判断卡在哪一侧必须先确认:
|
||||
//
|
||||
// 1. 开区任务完成那一刻客户端**有没有收到** op1378、值是多少;
|
||||
// 2. 它相对场景重建包 op24 是前是后。
|
||||
//
|
||||
// 实现:不新增 hook,只在补丁**生产环境实际安装**的 class0 派发代理
|
||||
// (ProxyTownCopresenceClass0Dispatch)入口读一下已经到手的包结构并写日志。
|
||||
// 不改行为、不改寄存器现场,全部包在 __try/__except 里。
|
||||
//
|
||||
// class0 包布局(与 CacheCurrentCharacterStatsFromClass0Packet 一致):
|
||||
// bytes[0] = class(0)
|
||||
// bytes[1..2] = opcode(u16 LE)
|
||||
// bytes[3..6] = packetLength(u32 LE)
|
||||
// bytes[16..] = body,长度 = packetLength - 16
|
||||
//
|
||||
// 诊断策略:前 kGenericLogLimit 个 class0 包**全部**记一行(含 opcode/长度),
|
||||
// 用来确认布局与是否真的从这里过;op1378 与 op24 则完全不过滤、始终记录。
|
||||
#include "90CN_digest_trace.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr unsigned short kStoryDigestOpcode = 1378; // 0x0562
|
||||
constexpr unsigned short kSceneTransitionOpcode = 24;
|
||||
constexpr long kGenericLogLimit = 400;
|
||||
|
||||
volatile LONG g_totalPackets = 0;
|
||||
volatile LONG g_digestCount = 0;
|
||||
volatile LONG g_sceneCount = 0;
|
||||
volatile LONG g_readyLogged = 0;
|
||||
bool g_logPathReady = false;
|
||||
char g_logPath[MAX_PATH] = {};
|
||||
|
||||
void EnsureLogPath()
|
||||
{
|
||||
if (g_logPathReady) return;
|
||||
g_logPathReady = true;
|
||||
char modulePath[MAX_PATH] = {};
|
||||
HMODULE self = nullptr;
|
||||
if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCSTR>(&EnsureLogPath), &self) && self &&
|
||||
GetModuleFileNameA(self, modulePath, MAX_PATH))
|
||||
{
|
||||
char* slash = strrchr(modulePath, '\\');
|
||||
if (slash)
|
||||
{
|
||||
slash[1] = '\0';
|
||||
sprintf_s(g_logPath, "%s90CN_digest.log", modulePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
strcpy_s(g_logPath, "90CN_digest.log");
|
||||
}
|
||||
|
||||
void WriteLine(const char* text)
|
||||
{
|
||||
#ifdef DNF90_SILENT_LOG
|
||||
(void)text; return;
|
||||
#endif
|
||||
if (!text) return;
|
||||
EnsureLogPath();
|
||||
SYSTEMTIME st;
|
||||
GetLocalTime(&st);
|
||||
FILE* file = nullptr;
|
||||
if (fopen_s(&file, g_logPath, "a") == 0 && file)
|
||||
{
|
||||
fprintf(file, "%02d:%02d:%02d.%03d %s\n",
|
||||
st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, text);
|
||||
fclose(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace cn90
|
||||
{
|
||||
void LogClass0CutscenePacket(const unsigned char* bytes)
|
||||
{
|
||||
if (!bytes) return;
|
||||
|
||||
if (InterlockedCompareExchange(&g_readyLogged, 1, 0) == 0)
|
||||
WriteLine("digest trace ready: class0 packet logging active");
|
||||
|
||||
const unsigned short opcode =
|
||||
*reinterpret_cast<const unsigned short*>(bytes + 1);
|
||||
const unsigned int packetLength =
|
||||
*reinterpret_cast<const unsigned int*>(bytes + 3);
|
||||
if (packetLength < 16 || packetLength > 0x100000) return;
|
||||
const unsigned int bodyLength = packetLength - 16;
|
||||
|
||||
if (opcode == kStoryDigestOpcode)
|
||||
{
|
||||
unsigned int lastLevel = 0;
|
||||
if (bodyLength >= 4)
|
||||
lastLevel = *reinterpret_cast<const unsigned int*>(bytes + 16);
|
||||
char text[256] = {};
|
||||
sprintf_s(text, "op1378 story-digest received body_len=%u last_level=%u (digest#%ld)",
|
||||
bodyLength, lastLevel, static_cast<long>(InterlockedIncrement(&g_digestCount)));
|
||||
WriteLine(text);
|
||||
return;
|
||||
}
|
||||
if (opcode == kSceneTransitionOpcode)
|
||||
{
|
||||
char text[224] = {};
|
||||
sprintf_s(text, "op24 scene-transition received body_len=%u (scene#%ld)",
|
||||
bodyLength, static_cast<long>(InterlockedIncrement(&g_sceneCount)));
|
||||
WriteLine(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// 诊断:前若干个包全记,确认布局与流量确实经过这里。
|
||||
const long total = InterlockedIncrement(&g_totalPackets);
|
||||
if (total <= kGenericLogLimit)
|
||||
{
|
||||
char text[192] = {};
|
||||
sprintf_s(text, "class0 opcode=%u body_len=%u (packet#%ld)",
|
||||
static_cast<unsigned>(opcode), bodyLength, total);
|
||||
WriteLine(text);
|
||||
}
|
||||
}
|
||||
|
||||
void LogAviOpen(const char* path, const char* callerRvas)
|
||||
{
|
||||
char text[768] = {};
|
||||
sprintf_s(text, "avi-open path=%s client_frames=%s",
|
||||
path ? path : "(null)", (callerRvas && callerRvas[0]) ? callerRvas : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieEntry(unsigned retRva, unsigned ecx, unsigned edx,
|
||||
unsigned arg1, const char* s1, unsigned arg2, const char* s2,
|
||||
unsigned arg3, const char* s3)
|
||||
{
|
||||
char text[900] = {};
|
||||
sprintf_s(text,
|
||||
"movie-fn-entry ret_rva=0x%08X ecx=0x%08X edx=0x%08X "
|
||||
"arg1=0x%08X[%s] arg2=0x%08X[%s] arg3=0x%08X[%s]",
|
||||
retRva, ecx, edx,
|
||||
arg1, (s1 && s1[0]) ? s1 : "",
|
||||
arg2, (s2 && s2[0]) ? s2 : "",
|
||||
arg3, (s3 && s3[0]) ? s3 : "");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieInvoke(unsigned fnRva, unsigned thisPtr, const char* path, int willCall)
|
||||
{
|
||||
char text[640] = {};
|
||||
sprintf_s(text, "movie-invoke fn_rva=0x%08X this=0x%08X will_call=%d path=%s",
|
||||
fnRva, thisPtr, willCall, (path && path[0]) ? path : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieChain(const char* chain)
|
||||
{
|
||||
char text[768] = {};
|
||||
sprintf_s(text, "movie-call-chain %s", (chain && chain[0]) ? chain : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieVtable(unsigned object, unsigned vtable, const char* slots)
|
||||
{
|
||||
char text[820] = {};
|
||||
sprintf_s(text, "movie-vtable this=0x%08X vtbl=0x%08X %s", object, vtable,
|
||||
(slots && slots[0]) ? slots : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieStack(const char* frames)
|
||||
{
|
||||
char text[1640] = {};
|
||||
sprintf_s(text, "movie-stack %s", (frames && frames[0]) ? frames : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieOpen(unsigned retRva, unsigned thisPtr, unsigned argPtr,
|
||||
const char* pathText, const char* stackText)
|
||||
{
|
||||
char text[1200] = {};
|
||||
sprintf_s(text, "movie-open ret=0x%08X this=0x%08X arg=0x%08X path=\"%s\" stack=%s",
|
||||
retRva, thisPtr, argPtr,
|
||||
(pathText && pathText[0]) ? pathText : "",
|
||||
(stackText && stackText[0]) ? stackText : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMoviePlay(unsigned retRva, unsigned thisPtr, const char* stackText)
|
||||
{
|
||||
char text[1024] = {};
|
||||
sprintf_s(text, "movie-play ret=0x%08X this=0x%08X stack=%s",
|
||||
retRva, thisPtr, (stackText && stackText[0]) ? stackText : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogMovieRender(long count, unsigned thisPtr, unsigned retRva, const char* stackText)
|
||||
{
|
||||
char text[1024] = {};
|
||||
sprintf_s(text, "movie-render #%ld this=0x%08X ret=0x%08X stack=%s",
|
||||
count, thisPtr, retRva, (stackText && stackText[0]) ? stackText : "(none)");
|
||||
WriteLine(text);
|
||||
}
|
||||
|
||||
void LogStoryDigestTraceReady()
|
||||
{
|
||||
WriteLine("digest trace module loaded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 90CN_digest_trace.h —— 章节过场(op1378 / op24)客户端侧只读日志。
|
||||
//
|
||||
// 只在补丁已有的 class0 派发代理里调用,不新增 hook。
|
||||
#pragma once
|
||||
|
||||
namespace cn90
|
||||
{
|
||||
// 传入 class0 包首地址(bytes[0]=class,bytes[1..2]=opcode)。
|
||||
// 命中 op1378 / op24 时写一行到 90CN_digest.log;其它包直接返回。
|
||||
void LogClass0CutscenePacket(const unsigned char* bytes);
|
||||
|
||||
// 记录一次 *.avi 打开:路径 + 调用点 RVA(用于定位客户端影片播放函数)。
|
||||
void LogAviOpen(const char* path, const char* callerRvas);
|
||||
|
||||
// 记录影片函数入口的寄存器/栈参数,用于判定调用约定。
|
||||
void LogMovieEntry(unsigned retRva, unsigned ecx, unsigned edx,
|
||||
unsigned arg1, const char* s1, unsigned arg2, const char* s2,
|
||||
unsigned arg3, const char* s3);
|
||||
|
||||
// 记录一次「主动调用客户端影片播放器」的尝试(willCall=1 表示真的调用了)。
|
||||
void LogMovieInvoke(unsigned fnRva, unsigned thisPtr, const char* path, int willCall);
|
||||
|
||||
void LogMovieChain(const char* chain);
|
||||
|
||||
// 记录一次「原始栈扫描」结果:把栈上出现的 bdvid32.dll / DNF.exe 返回地址
|
||||
// 连同「前一条指令像不像 call」一起写出来(带 * 的更像真正的返回地址)。
|
||||
void LogMovieStack(const char* frames);
|
||||
|
||||
// 记录影片对象 Open(path) 的一次调用:返回地址、this、宽字符路径、栈上候选返回地址。
|
||||
void LogMovieOpen(unsigned retRva, unsigned thisPtr, unsigned argPtr,
|
||||
const char* pathText, const char* stackText);
|
||||
|
||||
// 记录影片对象 Play() 的一次调用。
|
||||
void LogMoviePlay(unsigned retRva, unsigned thisPtr, const char* stackText);
|
||||
|
||||
// 记录影片 Render 的调用(计数 + this + 返回地址 + 栈上候选)。
|
||||
void LogMovieRender(long count, unsigned thisPtr, unsigned retRva, const char* stackText);
|
||||
|
||||
// 一次性记录播放对象 vtable 的 12 个槽位(模块!RVA)。
|
||||
void LogMovieVtable(unsigned object, unsigned vtable, const char* slots);
|
||||
|
||||
// DLL 初始化完成时写一行 "ready",便于确认补丁版本已加载。
|
||||
void LogStoryDigestTraceReady();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace dnf90_dungeon_assist {
|
||||
constexpr uint16_t kOpcode = 0x7f47;
|
||||
constexpr size_t kHeaderSize = 46;
|
||||
constexpr size_t kMaxKeys = 128;
|
||||
inline uint16_t U16(const unsigned char* p) { return uint16_t(p[0]) | uint16_t(p[1]) << 8; }
|
||||
inline uint32_t U32(const unsigned char* p) { return uint32_t(U16(p)) | uint32_t(U16(p + 2)) << 16; }
|
||||
// A native active wave or a completed hit threshold must never be advanced
|
||||
// again, even while a spawn animation has not produced its actors yet.
|
||||
inline bool CanAdvanceGate(bool wavePresent, bool spawning, int hits, int threshold) {
|
||||
return !wavePresent && !spawning && hits >= 0 && threshold > 0 && hits < threshold;
|
||||
}
|
||||
inline bool ValidQuery(const unsigned char* body, size_t size) {
|
||||
if (!body || size < kHeaderSize + 1 || body[0] != 1 ||
|
||||
body[33] > 1 || !U16(body + 1) || !U32(body + 19) || !U32(body + 23) || !U32(body + 27)) return false;
|
||||
const size_t count = body[kHeaderSize];
|
||||
if (!count || count > kMaxKeys || size != kHeaderSize + 1 + count * 2) return false;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const uint16_t key = U16(body + kHeaderSize + 1 + i * 2);
|
||||
if (!key || key == 0xffff) return false;
|
||||
for (size_t j = 0; j < i; ++j)
|
||||
if (key == U16(body + kHeaderSize + 1 + j * 2)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
#include "90CN_dungeon_skill_gate_fix.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
// v7/v8 live evidence on the current NoPack EXE:
|
||||
// sub_340BCF1 calls the native skill-state precheck, then executes
|
||||
// test al, al
|
||||
// je loc_340BD2E
|
||||
// A non-zero result falls through into sub_99F840, which opens owner 0x49
|
||||
// (the "initial skill state" warning) and then early-returns from the portal
|
||||
// transition before ENUM_CMDPACKET_ENTER_SELECT_DUNGEON/msg15 is emitted.
|
||||
// Force only this post-test branch to the native normal continuation. The
|
||||
// precheck function itself still runs, preserving any side effects/state reads.
|
||||
constexpr uintptr_t kSkillGateBranchRva = 0x0300BCFF; // 0340BCFF at base 00400000
|
||||
constexpr DWORD kInstallDelayMs = 1200;
|
||||
const unsigned char kExpectedBranch[2] = { 0x74, 0x2D }; // je +0x2D
|
||||
const unsigned char kBypassBranch[2] = { 0xEB, 0x2D }; // jmp +0x2D
|
||||
|
||||
HMODULE g_selfModule = nullptr;
|
||||
uintptr_t g_exeBase = 0;
|
||||
HANDLE g_worker = nullptr;
|
||||
volatile LONG g_stop = 0;
|
||||
volatile LONG g_installed = 0;
|
||||
|
||||
// The current 111 MB DB4 client does not contain the legacy 74 2D branch.
|
||||
// Keep the first observation pass strictly passive: this function is the
|
||||
// current selector/list state routine identified by the version-locked
|
||||
// prologue at RVA 0300BB80. The probe records entry state and returns to the
|
||||
// original function through a trampoline; it never changes a branch result.
|
||||
constexpr uintptr_t kCurrentSelectorProbeRva = 0x0300BB80;
|
||||
constexpr size_t kCurrentSelectorProbePatchLength = 10;
|
||||
const unsigned char kCurrentSelectorProbePrologue[
|
||||
kCurrentSelectorProbePatchLength] = {
|
||||
0x55, 0x8B, 0xEC, 0x6A, 0xFF, 0x68, 0xF8, 0x44, 0x27, 0x04
|
||||
};
|
||||
constexpr LONG kCurrentSelectorProbeMaxLoggedCalls = 4096;
|
||||
HMODULE g_currentProbeModule = nullptr;
|
||||
uintptr_t g_currentProbeBase = 0;
|
||||
void* g_currentSelectorProbeOriginal = nullptr;
|
||||
HANDLE g_currentProbeWorker = nullptr;
|
||||
volatile LONG g_currentProbeStop = 0;
|
||||
volatile LONG g_currentProbeInstalled = 0;
|
||||
volatile LONG g_currentProbeCallCount = 0;
|
||||
|
||||
// The DB4 client no longer uses the legacy 0300BCFF branch. The first
|
||||
// observation pass also showed that 0300BB80 is only a startup list routine.
|
||||
// These two current-EXE owner/notice builders are therefore traced separately
|
||||
// while reproducing the native "initial skill state" dialog. They remain
|
||||
// observe-only: no return value, owner, or dialog state is changed.
|
||||
constexpr uintptr_t kCurrentOwnerManagerProbeRva = 0x004A1170; // VA 008A1170
|
||||
constexpr size_t kCurrentOwnerManagerProbePatchLength = 14;
|
||||
const unsigned char kCurrentOwnerManagerProbePrologue[
|
||||
kCurrentOwnerManagerProbePatchLength] = {
|
||||
0x55, 0x8B, 0xEC, 0x83, 0xE4, 0xF8, 0x83, 0xEC,
|
||||
0x18, 0x56, 0x57, 0x8D, 0x45, 0x0C,
|
||||
};
|
||||
constexpr uintptr_t kCurrentNoticeBuilderProbeRva = 0x023B65B0; // VA 027B65B0
|
||||
constexpr size_t kCurrentNoticeBuilderProbePatchLength = 12;
|
||||
const unsigned char kCurrentNoticeBuilderProbePrologue[
|
||||
kCurrentNoticeBuilderProbePatchLength] = {
|
||||
0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x18, 0x83, 0x7D,
|
||||
0x08, 0x00, 0x56, 0x8B,
|
||||
};
|
||||
constexpr LONG kCurrentOwnerProbeMaxLoggedCalls = 1024;
|
||||
void* g_currentOwnerManagerProbeOriginal = nullptr;
|
||||
void* g_currentNoticeBuilderProbeOriginal = nullptr;
|
||||
volatile LONG g_currentOwnerProbeInstalled = 0;
|
||||
volatile LONG g_currentNoticeProbeInstalled = 0;
|
||||
volatile LONG g_currentOwnerProbeCallCount = 0;
|
||||
|
||||
// Current 111 MB DB4 client. In the owner-0x73 dungeon-selector function,
|
||||
// the native count comparison at VA 0x02248CAC uses `jle 0x02248CE8` to
|
||||
// continue normally. The fall-through path builds text id 0x11C79 and sends
|
||||
// event 0x2D6 (the "skill is in initial state" notice) before hiding the
|
||||
// selector owner. Force only this local branch to the already existing normal
|
||||
// continuation. The exact prologue/branch bytes are checked at runtime.
|
||||
constexpr uintptr_t kCurrentSkillGateBranchRva = 0x01E48CAC;
|
||||
const unsigned char kCurrentSkillGateExpected[2] = { 0x7E, 0x3A };
|
||||
const unsigned char kCurrentSkillGateBypass[2] = { 0xEB, 0x3A };
|
||||
HMODULE g_currentSkillGateModule = nullptr;
|
||||
uintptr_t g_currentSkillGateBase = 0;
|
||||
HANDLE g_currentSkillGateWorker = nullptr;
|
||||
volatile LONG g_currentSkillGateStop = 0;
|
||||
volatile LONG g_currentSkillGateInstalled = 0;
|
||||
|
||||
void BuildLogPath(wchar_t* output, size_t outputCount)
|
||||
{
|
||||
if (!output || outputCount == 0) return;
|
||||
output[0] = L'\0';
|
||||
DWORD length = g_selfModule
|
||||
? GetModuleFileNameW(g_selfModule, output, static_cast<DWORD>(outputCount))
|
||||
: 0;
|
||||
if (!length || length >= outputCount) {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
return;
|
||||
}
|
||||
|
||||
wchar_t* slash = wcsrchr(output, L'\\');
|
||||
wchar_t* slash2 = wcsrchr(output, L'/');
|
||||
if (slash2 && (!slash || slash2 > slash)) slash = slash2;
|
||||
if (slash) {
|
||||
slash[1] = L'\0';
|
||||
wcscat_s(output, outputCount, L"90CN_fix.log");
|
||||
} else {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
}
|
||||
}
|
||||
|
||||
void WriteLogLine(const char* text)
|
||||
{
|
||||
if (!text) return;
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
BuildLogPath(path, _countof(path));
|
||||
HANDLE file = CreateFileW(path, FILE_APPEND_DATA,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(file, text, static_cast<DWORD>(strlen(text)), &written, nullptr);
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
bool PatchBytes(unsigned char* target, const unsigned char* bytes, size_t count)
|
||||
{
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, count, PAGE_EXECUTE_READWRITE, &oldProtection)) {
|
||||
return false;
|
||||
}
|
||||
memcpy(target, bytes, count);
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, count, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, count);
|
||||
return memcmp(target, bytes, count) == 0;
|
||||
}
|
||||
|
||||
// 探针开关:DLL 同目录出现 90CN_gate_probe.flag 时**不**打这个旁路,让客户端
|
||||
// 按原逻辑走“技能处于初始状态”分支。用来确认该检查对当前角色是否真的成立,
|
||||
// 不改任何数据、删掉 flag 即恢复。2026-10-19 蛇腹剑形态排查用。
|
||||
static bool GateProbeBypassDisabled()
|
||||
{
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
DWORD length = g_selfModule
|
||||
? GetModuleFileNameW(g_selfModule, path, MAX_PATH)
|
||||
: 0;
|
||||
if (!length || length >= MAX_PATH) return false;
|
||||
wchar_t* slash = wcsrchr(path, L'\\');
|
||||
if (!slash) return false;
|
||||
slash[1] = L'\0';
|
||||
if (wcscat_s(path, L"90CN_gate_probe.flag") != 0) return false;
|
||||
return GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
bool InstallBypass()
|
||||
{
|
||||
if (!g_exeBase) return false;
|
||||
if (GateProbeBypassDisabled()) {
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix probe_mode=1 bypass_disabled=true "
|
||||
"target=0340BCFF reason=90CN_gate_probe.flag\r\n");
|
||||
return true;
|
||||
}
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_exeBase + kSkillGateBranchRva);
|
||||
unsigned char observed[2] = {};
|
||||
|
||||
__try {
|
||||
memcpy(observed, target, sizeof(observed));
|
||||
if (memcmp(observed, kBypassBranch, sizeof(kBypassBranch)) == 0) {
|
||||
InterlockedExchange(&g_installed, 1);
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix already_installed target=0340BCFF bytes=EB2D\r\n");
|
||||
return true;
|
||||
}
|
||||
if (memcmp(observed, kExpectedBranch, sizeof(kExpectedBranch)) != 0) {
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"dungeon-skill-gate-fix install_failed target=0340BCFF "
|
||||
"observed=%02X%02X expected=742D\r\n",
|
||||
observed[0], observed[1]);
|
||||
WriteLogLine(line);
|
||||
return false;
|
||||
}
|
||||
if (!PatchBytes(target, kBypassBranch, sizeof(kBypassBranch))) {
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix install_failed reason=patch-write target=0340BCFF\r\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix install_failed reason=exception target=0340BCFF\r\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
InterlockedExchange(&g_installed, 1);
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix installed target=0340BCFF original=742D replacement=EB2D "
|
||||
"scope=initial-skill-selector-precheck-only preserves=native-skill-check-call-and-other-dungeon-gates\r\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
void RestoreBypass()
|
||||
{
|
||||
if (InterlockedCompareExchange(&g_installed, 0, 0) == 0 || !g_exeBase) return;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_exeBase + kSkillGateBranchRva);
|
||||
|
||||
__try {
|
||||
if (memcmp(target, kBypassBranch, sizeof(kBypassBranch)) == 0) {
|
||||
if (PatchBytes(target, kExpectedBranch, sizeof(kExpectedBranch))) {
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix restored target=0340BCFF bytes=742D\r\n");
|
||||
} else {
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix restore_failed target=0340BCFF\r\n");
|
||||
}
|
||||
} else {
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"dungeon-skill-gate-fix restore_skipped target=0340BCFF current=%02X%02X\r\n",
|
||||
target[0], target[1]);
|
||||
WriteLogLine(line);
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
WriteLogLine(
|
||||
"dungeon-skill-gate-fix restore_failed reason=exception target=0340BCFF\r\n");
|
||||
}
|
||||
InterlockedExchange(&g_installed, 0);
|
||||
}
|
||||
|
||||
bool InstallCurrentSkillGateBypass()
|
||||
{
|
||||
if (!g_currentSkillGateBase) return false;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_currentSkillGateBase + kCurrentSkillGateBranchRva);
|
||||
unsigned char observed[2] = {};
|
||||
|
||||
__try {
|
||||
memcpy(observed, target, sizeof(observed));
|
||||
if (memcmp(observed, kCurrentSkillGateBypass,
|
||||
sizeof(kCurrentSkillGateBypass)) == 0) {
|
||||
InterlockedExchange(&g_currentSkillGateInstalled, 1);
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate already_installed "
|
||||
"target=02248CAC bytes=EB3A scope=owner-73-selector\r\n");
|
||||
return true;
|
||||
}
|
||||
if (memcmp(observed, kCurrentSkillGateExpected,
|
||||
sizeof(kCurrentSkillGateExpected)) != 0) {
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-skill-gate install_failed "
|
||||
"target=02248CAC observed=%02X%02X expected=7E3A\r\n",
|
||||
observed[0], observed[1]);
|
||||
WriteLogLine(line);
|
||||
return false;
|
||||
}
|
||||
if (!PatchBytes(target, kCurrentSkillGateBypass,
|
||||
sizeof(kCurrentSkillGateBypass))) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate install_failed "
|
||||
"reason=patch-write target=02248CAC\r\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate install_failed "
|
||||
"reason=exception target=02248CAC\r\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
InterlockedExchange(&g_currentSkillGateInstalled, 1);
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate installed target=02248CAC "
|
||||
"original=7E3A replacement=EB3A "
|
||||
"scope=owner-73-dungeon-selector-initial-skill-notice\r\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
void RestoreCurrentSkillGateBypass()
|
||||
{
|
||||
if (InterlockedCompareExchange(&g_currentSkillGateInstalled, 0, 0) == 0 ||
|
||||
!g_currentSkillGateBase) return;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_currentSkillGateBase + kCurrentSkillGateBranchRva);
|
||||
|
||||
__try {
|
||||
if (memcmp(target, kCurrentSkillGateBypass,
|
||||
sizeof(kCurrentSkillGateBypass)) == 0) {
|
||||
if (PatchBytes(target, kCurrentSkillGateExpected,
|
||||
sizeof(kCurrentSkillGateExpected))) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate restored "
|
||||
"target=02248CAC bytes=7E3A\r\n");
|
||||
} else {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate restore_failed "
|
||||
"target=02248CAC\r\n");
|
||||
}
|
||||
} else {
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-skill-gate restore_skipped "
|
||||
"target=02248CAC current=%02X%02X\r\n",
|
||||
target[0], target[1]);
|
||||
WriteLogLine(line);
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate restore_failed "
|
||||
"reason=exception target=02248CAC\r\n");
|
||||
}
|
||||
InterlockedExchange(&g_currentSkillGateInstalled, 0);
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallCurrentSkillGateWorker(LPVOID)
|
||||
{
|
||||
g_currentSkillGateBase = reinterpret_cast<uintptr_t>(
|
||||
GetModuleHandleW(nullptr));
|
||||
if (!g_currentSkillGateBase || !g_currentSkillGateModule) return 0;
|
||||
|
||||
DWORD slept = 0;
|
||||
while (slept < kInstallDelayMs &&
|
||||
InterlockedCompareExchange(&g_currentSkillGateStop, 0, 0) == 0) {
|
||||
Sleep(100);
|
||||
slept += 100;
|
||||
}
|
||||
if (InterlockedCompareExchange(&g_currentSkillGateStop, 0, 0) == 0) {
|
||||
InstallCurrentSkillGateBypass();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallWorker(LPVOID)
|
||||
{
|
||||
g_exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
|
||||
if (!g_exeBase || !g_selfModule) return 0;
|
||||
|
||||
DWORD slept = 0;
|
||||
while (slept < kInstallDelayMs &&
|
||||
InterlockedCompareExchange(&g_stop, 0, 0) == 0) {
|
||||
Sleep(100);
|
||||
slept += 100;
|
||||
}
|
||||
if (InterlockedCompareExchange(&g_stop, 0, 0) == 0) {
|
||||
InstallBypass();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void LogCurrentSelectorProbe(uintptr_t self, uintptr_t caller)
|
||||
{
|
||||
LONG callNumber = InterlockedIncrement(&g_currentProbeCallCount);
|
||||
if (callNumber > kCurrentSelectorProbeMaxLoggedCalls) return;
|
||||
|
||||
const uintptr_t base = reinterpret_cast<uintptr_t>(
|
||||
GetModuleHandleW(nullptr));
|
||||
const unsigned int callerRva = (base && caller >= base)
|
||||
? static_cast<unsigned int>(caller - base) : 0;
|
||||
|
||||
unsigned int state2fc = 0xFFFFFFFFu;
|
||||
uintptr_t manager = 0;
|
||||
__try {
|
||||
if (self) {
|
||||
state2fc = static_cast<unsigned int>(
|
||||
*reinterpret_cast<volatile unsigned char*>(self + 0x2FC));
|
||||
manager = *reinterpret_cast<volatile uintptr_t*>(self + 0xFC8);
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
state2fc = 0xFFFFFFFEu;
|
||||
manager = 0;
|
||||
}
|
||||
|
||||
// The two statically identified callers are the useful selector/list
|
||||
// paths. Keep a short prefix of other callers so a changed call graph is
|
||||
// visible without flooding the client trace during startup.
|
||||
const bool selectorCaller =
|
||||
(callerRva >= 0x03011740u && callerRva <= 0x03011790u) ||
|
||||
(callerRva >= 0x03012720u && callerRva <= 0x03012770u);
|
||||
if (!selectorCaller && callNumber > 64) return;
|
||||
|
||||
char line[384] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-selector-probe call=%ld self=0x%08X "
|
||||
"caller_rva=0x%08X state_2fc=%u manager=0x%08X\r\n",
|
||||
callNumber, static_cast<unsigned int>(self), callerRva,
|
||||
state2fc, static_cast<unsigned int>(manager));
|
||||
WriteLogLine(line);
|
||||
}
|
||||
|
||||
bool CurrentProbeBytesMatch(const unsigned char* target,
|
||||
const unsigned char* expected, size_t length);
|
||||
|
||||
void LogCurrentOwnerProbe(unsigned int kind, uintptr_t self,
|
||||
uintptr_t caller, uintptr_t originalStack)
|
||||
{
|
||||
LONG callNumber = InterlockedIncrement(&g_currentOwnerProbeCallCount);
|
||||
if (callNumber > kCurrentOwnerProbeMaxLoggedCalls) return;
|
||||
|
||||
const uintptr_t base = reinterpret_cast<uintptr_t>(
|
||||
GetModuleHandleW(nullptr));
|
||||
const unsigned int callerRva = (base && caller >= base)
|
||||
? static_cast<unsigned int>(caller - base) : 0;
|
||||
unsigned int arg0 = 0xFFFFFFFFu;
|
||||
unsigned int arg1 = 0xFFFFFFFFu;
|
||||
unsigned int arg2 = 0xFFFFFFFFu;
|
||||
__try {
|
||||
if (originalStack) {
|
||||
arg0 = *reinterpret_cast<volatile unsigned int*>(originalStack + 4);
|
||||
arg1 = *reinterpret_cast<volatile unsigned int*>(originalStack + 8);
|
||||
arg2 = *reinterpret_cast<volatile unsigned int*>(originalStack + 0xC);
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
arg0 = arg1 = arg2 = 0xFFFFFFFEu;
|
||||
}
|
||||
|
||||
// Keep the normal call prefix short, but retain every call carrying the
|
||||
// legacy warning owner/message values so the click window is unambiguous.
|
||||
const bool suspicious = arg0 == 0x49u || arg1 == 0x49u || arg2 == 0x49u ||
|
||||
arg0 == 0x73u || arg1 == 0x73u || arg2 == 0x73u ||
|
||||
arg0 == 0xF6u || arg1 == 0xF6u || arg2 == 0xF6u;
|
||||
if (!suspicious && callNumber > 96) return;
|
||||
|
||||
char line[448] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-owner-probe kind=%u call=%ld self=0x%08X "
|
||||
"caller_rva=0x%08X arg0=0x%08X arg1=0x%08X arg2=0x%08X "
|
||||
"suspicious=%u\r\n",
|
||||
kind, callNumber, static_cast<unsigned int>(self), callerRva,
|
||||
arg0, arg1, arg2, suspicious ? 1u : 0u);
|
||||
WriteLogLine(line);
|
||||
}
|
||||
|
||||
__declspec(naked) void ProxyCurrentOwnerManagerProbe()
|
||||
{
|
||||
__asm {
|
||||
pushfd
|
||||
pushad
|
||||
mov ecx, [esp + 0Ch] // pushad saved ESP (after pushfd)
|
||||
mov eax, [ecx + 4] // native return address at original ESP
|
||||
mov edx, [esp + 18h] // saved native ECX (this)
|
||||
add ecx, 4 // original ESP, points at native return address
|
||||
push ecx
|
||||
push eax
|
||||
push edx
|
||||
push 1
|
||||
call LogCurrentOwnerProbe
|
||||
add esp, 10h
|
||||
popad
|
||||
popfd
|
||||
jmp dword ptr [g_currentOwnerManagerProbeOriginal]
|
||||
}
|
||||
}
|
||||
|
||||
__declspec(naked) void ProxyCurrentNoticeBuilderProbe()
|
||||
{
|
||||
__asm {
|
||||
pushfd
|
||||
pushad
|
||||
mov ecx, [esp + 0Ch] // pushad saved ESP (after pushfd)
|
||||
mov eax, [ecx + 4] // native return address at original ESP
|
||||
mov edx, [esp + 18h] // saved native ECX (this)
|
||||
add ecx, 4 // original ESP, points at native return address
|
||||
push ecx
|
||||
push eax
|
||||
push edx
|
||||
push 2
|
||||
call LogCurrentOwnerProbe
|
||||
add esp, 10h
|
||||
popad
|
||||
popfd
|
||||
jmp dword ptr [g_currentNoticeBuilderProbeOriginal]
|
||||
}
|
||||
}
|
||||
|
||||
bool InstallCurrentOwnerProbe(uintptr_t rva, const unsigned char* expected,
|
||||
size_t patchLength, void* detour, void** original, const char* label,
|
||||
volatile LONG* installed)
|
||||
{
|
||||
if (!g_currentProbeBase || !expected || !detour || !original ||
|
||||
!installed || patchLength < 5) return false;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_currentProbeBase + rva);
|
||||
if (!CurrentProbeBytesMatch(target, expected, patchLength)) {
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-owner-probe skipped label=%s "
|
||||
"reason=prologue-mismatch rva=0x%08X\r\n",
|
||||
label ? label : "unknown", static_cast<unsigned int>(rva));
|
||||
WriteLogLine(line);
|
||||
return false;
|
||||
}
|
||||
|
||||
unsigned char* trampoline = static_cast<unsigned char*>(VirtualAlloc(
|
||||
nullptr, patchLength + 5, MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_EXECUTE_READWRITE));
|
||||
if (!trampoline) return false;
|
||||
memcpy(trampoline, target, patchLength);
|
||||
trampoline[patchLength] = 0xE9;
|
||||
*reinterpret_cast<int32_t*>(trampoline + patchLength + 1) =
|
||||
static_cast<int32_t>(reinterpret_cast<intptr_t>(target + patchLength) -
|
||||
reinterpret_cast<intptr_t>(trampoline + patchLength + 5));
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, patchLength, PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
VirtualFree(trampoline, 0, MEM_RELEASE);
|
||||
return false;
|
||||
}
|
||||
unsigned char jump[5] = { 0xE9 };
|
||||
*reinterpret_cast<int32_t*>(jump + 1) =
|
||||
static_cast<int32_t>(reinterpret_cast<intptr_t>(detour) -
|
||||
reinterpret_cast<intptr_t>(target + 5));
|
||||
memcpy(target, jump, sizeof(jump));
|
||||
for (size_t i = sizeof(jump); i < patchLength; ++i) target[i] = 0x90;
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, patchLength, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, patchLength);
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline, patchLength + 5);
|
||||
*original = trampoline;
|
||||
InterlockedExchange(installed, 1);
|
||||
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-owner-probe installed label=%s "
|
||||
"rva=0x%08X patch_length=%u mode=observe-only\r\n",
|
||||
label ? label : "unknown", static_cast<unsigned int>(rva),
|
||||
static_cast<unsigned int>(patchLength));
|
||||
WriteLogLine(line);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RestoreCurrentOwnerProbe(uintptr_t rva, const unsigned char* expected,
|
||||
size_t patchLength, void* detour, void** original, const char* label,
|
||||
volatile LONG* installed)
|
||||
{
|
||||
if (!g_currentProbeBase || !expected || !detour || !original ||
|
||||
!installed || InterlockedCompareExchange(installed, 0, 0) == 0) return;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_currentProbeBase + rva);
|
||||
unsigned char jump[5] = { 0xE9 };
|
||||
*reinterpret_cast<int32_t*>(jump + 1) =
|
||||
static_cast<int32_t>(reinterpret_cast<intptr_t>(detour) -
|
||||
reinterpret_cast<intptr_t>(target + 5));
|
||||
bool ours = CurrentProbeBytesMatch(target, jump, sizeof(jump));
|
||||
for (size_t i = sizeof(jump); ours && i < patchLength; ++i) {
|
||||
ours = target[i] == 0x90;
|
||||
}
|
||||
if (ours) {
|
||||
DWORD oldProtection = 0;
|
||||
if (VirtualProtect(target, patchLength, PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
memcpy(target, expected, patchLength);
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, patchLength, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, patchLength);
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-owner-probe restored label=%s "
|
||||
"rva=0x%08X\r\n", label ? label : "unknown",
|
||||
static_cast<unsigned int>(rva));
|
||||
WriteLogLine(line);
|
||||
}
|
||||
} else {
|
||||
char line[256] = {};
|
||||
sprintf_s(line,
|
||||
"new-client dungeon-owner-probe restore-skipped label=%s "
|
||||
"reason=target-changed rva=0x%08X\r\n", label ? label : "unknown",
|
||||
static_cast<unsigned int>(rva));
|
||||
WriteLogLine(line);
|
||||
}
|
||||
if (*original) {
|
||||
VirtualFree(*original, 0, MEM_RELEASE);
|
||||
*original = nullptr;
|
||||
}
|
||||
InterlockedExchange(installed, 0);
|
||||
}
|
||||
|
||||
// Preserve all native registers, flags, stack cleanup and the original
|
||||
// return address. The stolen prologue contains no relative instruction, so
|
||||
// the generic local trampoline can resume at target+10 safely.
|
||||
__declspec(naked) void ProxyCurrentSelectorProbe()
|
||||
{
|
||||
__asm {
|
||||
pushfd
|
||||
pushad
|
||||
mov eax, [esp + 24h] // native return address
|
||||
mov edx, [esp + 18h] // saved native ECX (this)
|
||||
push eax
|
||||
push edx
|
||||
call LogCurrentSelectorProbe
|
||||
add esp, 8
|
||||
popad
|
||||
popfd
|
||||
jmp dword ptr [g_currentSelectorProbeOriginal]
|
||||
}
|
||||
}
|
||||
|
||||
bool CurrentProbeBytesMatch(const unsigned char* target,
|
||||
const unsigned char* expected, size_t length)
|
||||
{
|
||||
if (!target || !expected || length == 0) return false;
|
||||
__try {
|
||||
return memcmp(target, expected, length) == 0;
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool InstallCurrentSelectorProbe()
|
||||
{
|
||||
if (!g_currentProbeBase) return false;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_currentProbeBase + kCurrentSelectorProbeRva);
|
||||
if (!CurrentProbeBytesMatch(target, kCurrentSelectorProbePrologue,
|
||||
kCurrentSelectorProbePatchLength)) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe skipped: prologue-mismatch "
|
||||
"rva=0300BB80 expected=558BEC6AFF68F8442704\r\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
unsigned char* trampoline = static_cast<unsigned char*>(VirtualAlloc(
|
||||
nullptr, kCurrentSelectorProbePatchLength + 5,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
|
||||
if (!trampoline) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe failed: trampoline-allocation\r\n");
|
||||
return false;
|
||||
}
|
||||
memcpy(trampoline, target, kCurrentSelectorProbePatchLength);
|
||||
trampoline[kCurrentSelectorProbePatchLength] = 0xE9;
|
||||
*reinterpret_cast<int32_t*>(trampoline +
|
||||
kCurrentSelectorProbePatchLength + 1) = static_cast<int32_t>(
|
||||
reinterpret_cast<intptr_t>(target + kCurrentSelectorProbePatchLength) -
|
||||
reinterpret_cast<intptr_t>(trampoline +
|
||||
kCurrentSelectorProbePatchLength + 5));
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, kCurrentSelectorProbePatchLength,
|
||||
PAGE_EXECUTE_READWRITE, &oldProtection)) {
|
||||
VirtualFree(trampoline, 0, MEM_RELEASE);
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe failed: virtualprotect\r\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
unsigned char jump[5] = { 0xE9 };
|
||||
*reinterpret_cast<int32_t*>(jump + 1) = static_cast<int32_t>(
|
||||
reinterpret_cast<intptr_t>(&ProxyCurrentSelectorProbe) -
|
||||
reinterpret_cast<intptr_t>(target + 5));
|
||||
memcpy(target, jump, sizeof(jump));
|
||||
for (size_t i = sizeof(jump); i < kCurrentSelectorProbePatchLength; ++i) {
|
||||
target[i] = 0x90;
|
||||
}
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, kCurrentSelectorProbePatchLength,
|
||||
oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target,
|
||||
kCurrentSelectorProbePatchLength);
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline,
|
||||
kCurrentSelectorProbePatchLength + 5);
|
||||
|
||||
g_currentSelectorProbeOriginal = trampoline;
|
||||
InterlockedExchange(&g_currentProbeInstalled, 1);
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe installed rva=0300BB80 "
|
||||
"patch_length=10 mode=observe-only\r\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CurrentProbeDetourMatches(const unsigned char* target)
|
||||
{
|
||||
if (!target) return false;
|
||||
unsigned char jump[5] = { 0xE9 };
|
||||
*reinterpret_cast<int32_t*>(jump + 1) = static_cast<int32_t>(
|
||||
reinterpret_cast<intptr_t>(&ProxyCurrentSelectorProbe) -
|
||||
reinterpret_cast<intptr_t>(target + 5));
|
||||
if (!CurrentProbeBytesMatch(target, jump, sizeof(jump))) return false;
|
||||
for (size_t i = sizeof(jump); i < kCurrentSelectorProbePatchLength; ++i) {
|
||||
if (target[i] != 0x90) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void RestoreCurrentSelectorProbe()
|
||||
{
|
||||
if (InterlockedCompareExchange(&g_currentProbeInstalled, 0, 0) == 0 ||
|
||||
!g_currentProbeBase) return;
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_currentProbeBase + kCurrentSelectorProbeRva);
|
||||
if (CurrentProbeDetourMatches(target)) {
|
||||
DWORD oldProtection = 0;
|
||||
if (VirtualProtect(target, kCurrentSelectorProbePatchLength,
|
||||
PAGE_EXECUTE_READWRITE, &oldProtection)) {
|
||||
memcpy(target, kCurrentSelectorProbePrologue,
|
||||
kCurrentSelectorProbePatchLength);
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, kCurrentSelectorProbePatchLength,
|
||||
oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target,
|
||||
kCurrentSelectorProbePatchLength);
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe restored rva=0300BB80\r\n");
|
||||
}
|
||||
} else {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe restore-skipped target-changed\r\n");
|
||||
}
|
||||
if (g_currentSelectorProbeOriginal) {
|
||||
VirtualFree(g_currentSelectorProbeOriginal, 0, MEM_RELEASE);
|
||||
g_currentSelectorProbeOriginal = nullptr;
|
||||
}
|
||||
InterlockedExchange(&g_currentProbeInstalled, 0);
|
||||
}
|
||||
|
||||
DWORD WINAPI CurrentSelectorProbeWorker(LPVOID)
|
||||
{
|
||||
g_currentProbeBase = reinterpret_cast<uintptr_t>(
|
||||
GetModuleHandleW(nullptr));
|
||||
if (!g_currentProbeBase) return 0;
|
||||
DWORD slept = 0;
|
||||
while (slept < kInstallDelayMs &&
|
||||
InterlockedCompareExchange(&g_currentProbeStop, 0, 0) == 0) {
|
||||
Sleep(100);
|
||||
slept += 100;
|
||||
}
|
||||
if (InterlockedCompareExchange(&g_currentProbeStop, 0, 0) == 0) {
|
||||
InstallCurrentSelectorProbe();
|
||||
InstallCurrentOwnerProbe(
|
||||
kCurrentOwnerManagerProbeRva,
|
||||
kCurrentOwnerManagerProbePrologue,
|
||||
kCurrentOwnerManagerProbePatchLength,
|
||||
&ProxyCurrentOwnerManagerProbe,
|
||||
&g_currentOwnerManagerProbeOriginal,
|
||||
"owner-manager-008A1170",
|
||||
&g_currentOwnerProbeInstalled);
|
||||
InstallCurrentOwnerProbe(
|
||||
kCurrentNoticeBuilderProbeRva,
|
||||
kCurrentNoticeBuilderProbePrologue,
|
||||
kCurrentNoticeBuilderProbePatchLength,
|
||||
&ProxyCurrentNoticeBuilderProbe,
|
||||
&g_currentNoticeBuilderProbeOriginal,
|
||||
"notice-builder-027B65B0",
|
||||
&g_currentNoticeProbeInstalled);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void StartDungeonSkillGateFix(HMODULE module)
|
||||
{
|
||||
if (g_worker) return;
|
||||
g_selfModule = module;
|
||||
InterlockedExchange(&g_stop, 0);
|
||||
g_worker = CreateThread(nullptr, 0, InstallWorker, nullptr, 0, nullptr);
|
||||
}
|
||||
|
||||
void StopDungeonSkillGateFix()
|
||||
{
|
||||
InterlockedExchange(&g_stop, 1);
|
||||
if (g_worker) {
|
||||
WaitForSingleObject(g_worker, 1500);
|
||||
CloseHandle(g_worker);
|
||||
g_worker = nullptr;
|
||||
}
|
||||
RestoreBypass();
|
||||
}
|
||||
|
||||
void StartCurrentDungeonSkillGateFix(HMODULE module)
|
||||
{
|
||||
if (g_currentSkillGateWorker) return;
|
||||
g_currentSkillGateModule = module;
|
||||
InterlockedExchange(&g_currentSkillGateStop, 0);
|
||||
g_currentSkillGateWorker = CreateThread(nullptr, 0,
|
||||
InstallCurrentSkillGateWorker, nullptr, 0, nullptr);
|
||||
if (!g_currentSkillGateWorker) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-skill-gate failed: worker-create\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
void StopCurrentDungeonSkillGateFix()
|
||||
{
|
||||
InterlockedExchange(&g_currentSkillGateStop, 1);
|
||||
if (g_currentSkillGateWorker) {
|
||||
WaitForSingleObject(g_currentSkillGateWorker, 1500);
|
||||
CloseHandle(g_currentSkillGateWorker);
|
||||
g_currentSkillGateWorker = nullptr;
|
||||
}
|
||||
RestoreCurrentSkillGateBypass();
|
||||
}
|
||||
|
||||
void StartCurrentDungeonSelectorProbe(HMODULE module)
|
||||
{
|
||||
if (g_currentProbeWorker) return;
|
||||
g_currentProbeModule = module;
|
||||
InterlockedExchange(&g_currentProbeStop, 0);
|
||||
g_currentProbeWorker = CreateThread(nullptr, 0,
|
||||
CurrentSelectorProbeWorker, nullptr, 0, nullptr);
|
||||
if (!g_currentProbeWorker) {
|
||||
WriteLogLine(
|
||||
"new-client dungeon-selector-probe failed: worker-create\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
void StopCurrentDungeonSelectorProbe()
|
||||
{
|
||||
InterlockedExchange(&g_currentProbeStop, 1);
|
||||
if (g_currentProbeWorker) {
|
||||
WaitForSingleObject(g_currentProbeWorker, 1500);
|
||||
CloseHandle(g_currentProbeWorker);
|
||||
g_currentProbeWorker = nullptr;
|
||||
}
|
||||
RestoreCurrentSelectorProbe();
|
||||
RestoreCurrentOwnerProbe(
|
||||
kCurrentOwnerManagerProbeRva,
|
||||
kCurrentOwnerManagerProbePrologue,
|
||||
kCurrentOwnerManagerProbePatchLength,
|
||||
&ProxyCurrentOwnerManagerProbe,
|
||||
&g_currentOwnerManagerProbeOriginal,
|
||||
"owner-manager-008A1170",
|
||||
&g_currentOwnerProbeInstalled);
|
||||
RestoreCurrentOwnerProbe(
|
||||
kCurrentNoticeBuilderProbeRva,
|
||||
kCurrentNoticeBuilderProbePrologue,
|
||||
kCurrentNoticeBuilderProbePatchLength,
|
||||
&ProxyCurrentNoticeBuilderProbe,
|
||||
&g_currentNoticeBuilderProbeOriginal,
|
||||
"notice-builder-027B65B0",
|
||||
&g_currentNoticeProbeInstalled);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
// Bypass only the current NoPack client-side "initial skill state" dungeon
|
||||
// selector precheck. The native skill-state probe still runs; this fix only
|
||||
// forces its post-test branch onto the normal selector continuation path.
|
||||
void StartDungeonSkillGateFix(HMODULE module);
|
||||
void StopDungeonSkillGateFix();
|
||||
|
||||
// Current DB4 client: skip only the dungeon-selector branch that displays the
|
||||
// "skill is in initial state" notice when the native selector check fails.
|
||||
// This is a version-locked two-byte branch patch; the shared state predicate
|
||||
// and all other selector gates remain native.
|
||||
void StartCurrentDungeonSkillGateFix(HMODULE module);
|
||||
void StopCurrentDungeonSkillGateFix();
|
||||
|
||||
// Read-only probe for the current DB4 client. It records calls into the
|
||||
// selector/list state routine and then executes the stolen native prologue
|
||||
// unchanged. This is deliberately separate from the legacy 74 2D bypass.
|
||||
void StartCurrentDungeonSelectorProbe(HMODULE module);
|
||||
void StopCurrentDungeonSelectorProbe();
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace dnf90_expert_job
|
||||
{
|
||||
// Current EXE 03A4C1A0 returns actor+18. Mode3 writes state+34C for
|
||||
// personal info, but menu action4 / 00FF1FF0 reads state+368 instead.
|
||||
constexpr size_t kPersonalInfoTypeOffset = 0x364;
|
||||
constexpr size_t kFunctionTypeOffset = 0x380;
|
||||
|
||||
struct PreviousTypes
|
||||
{
|
||||
uint32_t personalInfo;
|
||||
uint32_t function;
|
||||
};
|
||||
|
||||
// The caller resolves the native current actor, validates the server op205
|
||||
// type, and retains its existing SEH/actor identity boundary. This helper
|
||||
// only mirrors that exact type; it does not grant or persist a profession.
|
||||
inline bool MirrorType(void* actor, uint32_t type, PreviousTypes& previous)
|
||||
{
|
||||
if (!actor || type > 4) return false;
|
||||
unsigned char* bytes = static_cast<unsigned char*>(actor);
|
||||
memcpy(&previous.personalInfo, bytes + kPersonalInfoTypeOffset, sizeof(type));
|
||||
memcpy(&previous.function, bytes + kFunctionTypeOffset, sizeof(type));
|
||||
memcpy(bytes + kPersonalInfoTypeOffset, &type, sizeof(type));
|
||||
memcpy(bytes + kFunctionTypeOffset, &type, sizeof(type));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
|
||||
namespace dnf90_first_login_recovery {
|
||||
|
||||
constexpr long kMaxFirstLoginRecoveryAttempts = 2;
|
||||
|
||||
inline bool IsFirstLoginServerProgressMessage(int messageType) {
|
||||
return messageType == 1; // ENUM_CMDPACKET_LOGIN
|
||||
}
|
||||
|
||||
inline bool IsFirstLoginClientProgressMessage(int messageType) {
|
||||
switch (messageType) {
|
||||
case 171: // ENUM_CMDPACKET_CLIENT_SPEC_STATISTIC
|
||||
case 476: // ENUM_CMDPACKET_COMPATIBILITY_INDEX
|
||||
case 1320: // ENUM_CMDPACKET_LOG_DOMAIN_CONNECT
|
||||
case 1262: // ENUM_CMDPACKET_LOG_CONNECT_PROCESS
|
||||
case 593: // ENUM_CMDPACKET_SECURITY_STATUS
|
||||
case 8: // ENUM_CMDPACKET_GET_USERINFO
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The auto-connect timer can run inside the native startup message pump.
|
||||
// Opening a socket while the packet processor is uninitialized loses LOGIN:
|
||||
// native 02261E30 returns 80000001, but 03456304 still consumes the packet.
|
||||
constexpr unsigned int kPacketProcessorPollMs = 50;
|
||||
constexpr unsigned long long kPacketProcessorWaitLimitMs = 30000;
|
||||
|
||||
enum class PacketProcessorReadiness { NotReady, Ready, ReadFailed };
|
||||
enum class AutoConnectAction { Ignore, Wait, Connect, TimedOut, ReadFailed };
|
||||
|
||||
struct AutoConnectReadiness {
|
||||
long generation = 0;
|
||||
unsigned long long startedAt = 0;
|
||||
bool pending = false;
|
||||
bool waited = false;
|
||||
|
||||
void Begin(long newGeneration, unsigned long long now) {
|
||||
generation = newGeneration;
|
||||
startedAt = now;
|
||||
pending = true;
|
||||
waited = false;
|
||||
}
|
||||
|
||||
void Cancel() { pending = false; }
|
||||
|
||||
AutoConnectAction Poll(long currentGeneration, unsigned long long now,
|
||||
PacketProcessorReadiness readiness) {
|
||||
if (!pending || generation != currentGeneration) {
|
||||
return AutoConnectAction::Ignore;
|
||||
}
|
||||
if (readiness == PacketProcessorReadiness::ReadFailed) {
|
||||
pending = false;
|
||||
return AutoConnectAction::ReadFailed;
|
||||
}
|
||||
if (readiness == PacketProcessorReadiness::Ready) {
|
||||
pending = false; // Claim exactly one connect for this directory.
|
||||
return AutoConnectAction::Connect;
|
||||
}
|
||||
waited = true;
|
||||
if (now - startedAt >= kPacketProcessorWaitLimitMs) {
|
||||
pending = false;
|
||||
return AutoConnectAction::TimedOut;
|
||||
}
|
||||
return AutoConnectAction::Wait;
|
||||
}
|
||||
};
|
||||
|
||||
struct WatchdogState {
|
||||
long watchdogGeneration;
|
||||
long currentGeneration;
|
||||
bool active;
|
||||
bool serverDataSeen;
|
||||
bool clientDataSeen;
|
||||
long recoveryCount;
|
||||
};
|
||||
|
||||
inline bool ShouldRecover(const WatchdogState& state) {
|
||||
// Native ChannelConnect success is only a transport candidate. The first
|
||||
// login is healthy once the client emits any proved post-login progress.
|
||||
// serverDataSeen is diagnostic only: a stale native socket can prevent the
|
||||
// receive hook from observing LOGIN even when the server already sent it.
|
||||
return state.active &&
|
||||
state.recoveryCount < kMaxFirstLoginRecoveryAttempts &&
|
||||
state.watchdogGeneration > 0 &&
|
||||
state.currentGeneration == state.watchdogGeneration &&
|
||||
!state.clientDataSeen;
|
||||
}
|
||||
|
||||
inline bool ShouldRecoverAfterRejectedRetry(
|
||||
long initialSuccessGeneration,
|
||||
long retryGeneration,
|
||||
bool clientDataSeen,
|
||||
long recoveryCount,
|
||||
bool retryConnected) {
|
||||
return initialSuccessGeneration > 0 &&
|
||||
retryGeneration > initialSuccessGeneration &&
|
||||
!clientDataSeen &&
|
||||
recoveryCount < kMaxFirstLoginRecoveryAttempts &&
|
||||
!retryConnected;
|
||||
}
|
||||
|
||||
inline bool ShouldForceReleaseStaleSocket(
|
||||
bool residentResetSucceeded,
|
||||
bool socketPresent) {
|
||||
return residentResetSucceeded && socketPresent;
|
||||
}
|
||||
|
||||
// A proved login must not permanently disable recovery after disconnect.
|
||||
// Only a rejected later directory connect with no live transport may reset;
|
||||
// a successful connect or a still-owned socket always remains native-owned.
|
||||
inline bool ShouldRecoverDisconnectedRetry(long initialGeneration,
|
||||
long retryGeneration, bool clientProgress, bool connected,
|
||||
bool transportReadable, bool socketAbsent, long attempts) {
|
||||
return initialGeneration > 0 && retryGeneration > initialGeneration &&
|
||||
clientProgress && !connected && transportReadable && socketAbsent &&
|
||||
attempts < kMaxFirstLoginRecoveryAttempts;
|
||||
}
|
||||
|
||||
} // namespace dnf90_first_login_recovery
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "90CN_op3_parent_fix.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uintptr_t kOp3ParentCallRva = 0x01988B9F; // 01D88B9F
|
||||
constexpr uintptr_t kParentFunctionRva = 0x021B69E0; // 025B69E0
|
||||
|
||||
HMODULE g_selfModule = nullptr;
|
||||
uintptr_t g_exeBase = 0;
|
||||
uintptr_t g_parentFunction = 0;
|
||||
uintptr_t g_continueAfterParentCall = 0;
|
||||
volatile LONG g_bypassSequence = 0;
|
||||
|
||||
void BuildLogPath(wchar_t* output, size_t outputCount)
|
||||
{
|
||||
if (!output || outputCount == 0) return;
|
||||
output[0] = L'\0';
|
||||
DWORD length = g_selfModule
|
||||
? GetModuleFileNameW(g_selfModule, output, static_cast<DWORD>(outputCount))
|
||||
: 0;
|
||||
if (!length || length >= outputCount) {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
return;
|
||||
}
|
||||
wchar_t* slash = wcsrchr(output, L'\\');
|
||||
wchar_t* slash2 = wcsrchr(output, L'/');
|
||||
if (slash2 && (!slash || slash2 > slash)) slash = slash2;
|
||||
if (slash) {
|
||||
slash[1] = L'\0';
|
||||
wcscat_s(output, outputCount, L"90CN_fix.log");
|
||||
}
|
||||
else {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
}
|
||||
}
|
||||
|
||||
void WriteLogLine(const char* text)
|
||||
{
|
||||
if (!text) return;
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
BuildLogPath(path, _countof(path));
|
||||
HANDLE file = CreateFileW(path, FILE_APPEND_DATA,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(file, text, static_cast<DWORD>(strlen(text)), &written, nullptr);
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
void __stdcall RecordNullBypass(uintptr_t actor)
|
||||
{
|
||||
LONG sequence = InterlockedIncrement(&g_bypassSequence);
|
||||
// Prevent pathological log growth while retaining enough evidence for support.
|
||||
if (sequence > 64) return;
|
||||
|
||||
SYSTEMTIME now = {};
|
||||
GetLocalTime(&now);
|
||||
char line[512] = {};
|
||||
sprintf_s(line,
|
||||
"op3-parent-readiness-fix seq=%ld time=%04u-%02u-%02u %02u:%02u:%02u.%03u "
|
||||
"tid=%lu actor=%08X action=bypass-null-parent continue=01D88BA4\r\n",
|
||||
sequence,
|
||||
now.wYear, now.wMonth, now.wDay,
|
||||
now.wHour, now.wMinute, now.wSecond, now.wMilliseconds,
|
||||
GetCurrentThreadId(), static_cast<unsigned int>(actor));
|
||||
WriteLogLine(line);
|
||||
}
|
||||
|
||||
// Replaces only the single CALL at 01D88B9F.
|
||||
// Normal path is untouched: restore incoming EFLAGS and tail-jump to 025B69E0.
|
||||
// Null path skips the known-invalid call, removes CALL return address + 2 args
|
||||
// (12 bytes), and resumes at the original continuation 01D88BA4.
|
||||
__declspec(naked) void ProxyOp3ParentCall()
|
||||
{
|
||||
__asm {
|
||||
pushfd
|
||||
test ecx, ecx
|
||||
jz parent_is_null
|
||||
popfd
|
||||
jmp dword ptr [g_parentFunction]
|
||||
|
||||
parent_is_null:
|
||||
popfd
|
||||
pushfd
|
||||
pushad
|
||||
push esi
|
||||
call RecordNullBypass
|
||||
popad
|
||||
popfd
|
||||
add esp, 0Ch
|
||||
jmp dword ptr [g_continueAfterParentCall]
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallOp3ParentReadinessFix(LPVOID)
|
||||
{
|
||||
HMODULE executable = GetModuleHandleW(nullptr);
|
||||
g_exeBase = reinterpret_cast<uintptr_t>(executable);
|
||||
if (!g_exeBase) return 0;
|
||||
|
||||
unsigned char* callSite = reinterpret_cast<unsigned char*>(
|
||||
g_exeBase + kOp3ParentCallRva);
|
||||
g_parentFunction = g_exeBase + kParentFunctionRva;
|
||||
g_continueAfterParentCall = reinterpret_cast<uintptr_t>(callSite + 5);
|
||||
|
||||
bool verified = false;
|
||||
uintptr_t observedTarget = 0;
|
||||
__try {
|
||||
if (callSite[0] == 0xE8) {
|
||||
LONG currentRelative = *reinterpret_cast<LONG*>(callSite + 1);
|
||||
observedTarget = reinterpret_cast<uintptr_t>(callSite + 5) + currentRelative;
|
||||
verified = observedTarget == g_parentFunction;
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
verified = false;
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
char line[512] = {};
|
||||
sprintf_s(line,
|
||||
"op3-parent-readiness-fix install_failed site=%08X opcode=%02X observed=%08X expected=%08X\r\n",
|
||||
static_cast<unsigned int>(reinterpret_cast<uintptr_t>(callSite)),
|
||||
static_cast<unsigned int>(callSite[0]),
|
||||
static_cast<unsigned int>(observedTarget),
|
||||
static_cast<unsigned int>(g_parentFunction));
|
||||
WriteLogLine(line);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uintptr_t replacement = reinterpret_cast<uintptr_t>(&ProxyOp3ParentCall);
|
||||
LONG replacementRelative = static_cast<LONG>(
|
||||
replacement - reinterpret_cast<uintptr_t>(callSite + 5));
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(callSite, 5, PAGE_EXECUTE_READWRITE, &oldProtection)) {
|
||||
WriteLogLine("op3-parent-readiness-fix install_failed reason=VirtualProtect\r\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
*reinterpret_cast<LONG*>(callSite + 1) = replacementRelative;
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(callSite, 5, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), callSite, 5);
|
||||
|
||||
WriteLogLine("op3-parent-readiness-fix installed site=01D88B9F native=025B69E0 continue=01D88BA4\r\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void StartOp3ParentReadinessFix(HMODULE module)
|
||||
{
|
||||
g_selfModule = module;
|
||||
HANDLE worker = CreateThread(nullptr, 0, InstallOp3ParentReadinessFix, nullptr, 0, nullptr);
|
||||
if (worker) CloseHandle(worker);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
void StartOp3ParentReadinessFix(HMODULE module);
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace dnf90_premium_clock {
|
||||
|
||||
constexpr size_t kNativeBodySize = 77; // success:u8, action:u16, blob74
|
||||
constexpr size_t kServerHeaderSize = 16;
|
||||
constexpr size_t kClockTrailerSize = 8;
|
||||
constexpr size_t kPacketSize = kServerHeaderSize + kNativeBodySize + kClockTrailerSize;
|
||||
|
||||
inline uint32_t ReadU32(const unsigned char* bytes) {
|
||||
return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8) |
|
||||
(uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24);
|
||||
}
|
||||
|
||||
// Only our exact marked server16/class1/op903 action1 packet is eligible.
|
||||
// The unchanged native prefix remains owned by the original handler.
|
||||
inline bool Decode(const unsigned char* bytes, size_t available, int* serverUnix) {
|
||||
if (!bytes || !serverUnix || available < kPacketSize ||
|
||||
bytes[0] != 1 || bytes[1] != 0x87 || bytes[2] != 3 ||
|
||||
ReadU32(bytes + 3) != kPacketSize ||
|
||||
bytes[16] != 1 || bytes[17] != 1 || bytes[18] != 0) return false;
|
||||
const unsigned char* trailer = bytes + kServerHeaderSize + kNativeBodySize;
|
||||
if (trailer[0] != 'T' || trailer[1] != 'I' ||
|
||||
trailer[2] != 'M' || trailer[3] != 'E') return false;
|
||||
uint32_t timestamp = ReadU32(trailer + 4);
|
||||
// The native clock uses signed time32. Refuse zero, wrapped and out-of-era
|
||||
// timestamps rather than writing arbitrary values to native globals.
|
||||
if (timestamp < 946684800u || timestamp > 0x7fffffffu) return false;
|
||||
*serverUnix = static_cast<int>(timestamp);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace dnf90_premium_clock
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "90CN_selected_page_fix.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
// The cold-login failure occurs at the first field access inside
|
||||
// sub_17D6BE0 when it is entered with ECX/this == NULL. Guard that function
|
||||
// boundary itself instead of aborting the surrounding class1/op4 dispatcher;
|
||||
// the latter owns additional HUD/state initialization that must still run.
|
||||
constexpr uintptr_t kSelectedPageApplyRva = 0x013D6BE0; // 017D6BE0
|
||||
constexpr size_t kHookLength = 5;
|
||||
const unsigned char kSelectedPageApplyPrologue[kHookLength] = {
|
||||
0x55, 0x8B, 0xEC, 0x6A, 0xFF
|
||||
};
|
||||
|
||||
HMODULE g_selfModule = nullptr;
|
||||
uintptr_t g_exeBase = 0;
|
||||
void* g_originalSelectedPageApply = nullptr;
|
||||
volatile LONG g_bypassSequence = 0;
|
||||
|
||||
typedef void (__thiscall* SelectedPageApplyFn)(void*);
|
||||
|
||||
void BuildLogPath(wchar_t* output, size_t outputCount)
|
||||
{
|
||||
if (!output || outputCount == 0) return;
|
||||
output[0] = L'\0';
|
||||
DWORD length = g_selfModule
|
||||
? GetModuleFileNameW(g_selfModule, output, static_cast<DWORD>(outputCount))
|
||||
: 0;
|
||||
if (!length || length >= outputCount) {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
return;
|
||||
}
|
||||
|
||||
wchar_t* slash = wcsrchr(output, L'\\');
|
||||
wchar_t* slash2 = wcsrchr(output, L'/');
|
||||
if (slash2 && (!slash || slash2 > slash)) slash = slash2;
|
||||
if (slash) {
|
||||
slash[1] = L'\0';
|
||||
wcscat_s(output, outputCount, L"90CN_fix.log");
|
||||
}
|
||||
else {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
}
|
||||
}
|
||||
|
||||
void WriteLogLine(const char* text)
|
||||
{
|
||||
if (!text) return;
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
BuildLogPath(path, _countof(path));
|
||||
HANDLE file = CreateFileW(path, FILE_APPEND_DATA,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(file, text, static_cast<DWORD>(strlen(text)), &written, nullptr);
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
void RecordNullBypass()
|
||||
{
|
||||
LONG sequence = InterlockedIncrement(&g_bypassSequence);
|
||||
if (sequence > 64) return;
|
||||
|
||||
SYSTEMTIME now = {};
|
||||
GetLocalTime(&now);
|
||||
char line[512] = {};
|
||||
sprintf_s(line,
|
||||
"selected-page-readiness-fix seq=%ld time=%04u-%02u-%02u %02u:%02u:%02u.%03u "
|
||||
"tid=%lu action=skip-null-this target=017D6BE0 boundary=selected-page-entry\r\n",
|
||||
sequence,
|
||||
now.wYear, now.wMonth, now.wDay,
|
||||
now.wHour, now.wMinute, now.wSecond, now.wMilliseconds,
|
||||
GetCurrentThreadId());
|
||||
WriteLogLine(line);
|
||||
}
|
||||
|
||||
void __fastcall ProxySelectedPageApply(void* self, void* /*unused*/)
|
||||
{
|
||||
if (!self) {
|
||||
RecordNullBypass();
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedPageApplyFn original = reinterpret_cast<SelectedPageApplyFn>(
|
||||
g_originalSelectedPageApply);
|
||||
if (original) original(self);
|
||||
}
|
||||
|
||||
bool BuildTrampoline(unsigned char* target)
|
||||
{
|
||||
const size_t trampolineLength = kHookLength + 5;
|
||||
unsigned char* trampoline = reinterpret_cast<unsigned char*>(VirtualAlloc(
|
||||
nullptr, trampolineLength, MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_EXECUTE_READWRITE));
|
||||
if (!trampoline) return false;
|
||||
|
||||
memcpy(trampoline, target, kHookLength);
|
||||
trampoline[kHookLength] = 0xE9;
|
||||
*reinterpret_cast<LONG*>(trampoline + kHookLength + 1) =
|
||||
static_cast<LONG>(
|
||||
reinterpret_cast<uintptr_t>(target + kHookLength) -
|
||||
reinterpret_cast<uintptr_t>(trampoline + trampolineLength));
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline, trampolineLength);
|
||||
g_originalSelectedPageApply = trampoline;
|
||||
return true;
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallSelectedPageReadinessFix(LPVOID)
|
||||
{
|
||||
HMODULE executable = GetModuleHandleW(nullptr);
|
||||
g_exeBase = reinterpret_cast<uintptr_t>(executable);
|
||||
if (!g_exeBase || !g_selfModule) return 0;
|
||||
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_exeBase + kSelectedPageApplyRva);
|
||||
|
||||
bool verified = false;
|
||||
unsigned char observed[kHookLength] = {};
|
||||
__try {
|
||||
memcpy(observed, target, sizeof(observed));
|
||||
verified = memcmp(observed, kSelectedPageApplyPrologue,
|
||||
sizeof(kSelectedPageApplyPrologue)) == 0;
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
verified = false;
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
char line[512] = {};
|
||||
sprintf_s(line,
|
||||
"selected-page-readiness-fix install_failed target=017D6BE0 "
|
||||
"observed=%02X%02X%02X%02X%02X expected=558BEC6AFF\r\n",
|
||||
observed[0], observed[1], observed[2], observed[3], observed[4]);
|
||||
WriteLogLine(line);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!BuildTrampoline(target)) {
|
||||
WriteLogLine(
|
||||
"selected-page-readiness-fix install_failed reason=trampoline-allocation\r\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uintptr_t replacement = reinterpret_cast<uintptr_t>(
|
||||
&ProxySelectedPageApply);
|
||||
const LONG replacementRelative = static_cast<LONG>(
|
||||
replacement - reinterpret_cast<uintptr_t>(target + kHookLength));
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, kHookLength, PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
VirtualFree(g_originalSelectedPageApply, 0, MEM_RELEASE);
|
||||
g_originalSelectedPageApply = nullptr;
|
||||
WriteLogLine(
|
||||
"selected-page-readiness-fix install_failed reason=VirtualProtect\r\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
target[0] = 0xE9;
|
||||
*reinterpret_cast<LONG*>(target + 1) = replacementRelative;
|
||||
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, kHookLength, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, kHookLength);
|
||||
|
||||
WriteLogLine(
|
||||
"selected-page-readiness-fix installed boundary=selected-page-entry "
|
||||
"target=017D6BE0 scope=null-this-only preserves=class1-op4-continuation\r\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void StartSelectedPageReadinessFix(HMODULE module)
|
||||
{
|
||||
g_selfModule = module;
|
||||
HANDLE worker = CreateThread(
|
||||
nullptr, 0, InstallSelectedPageReadinessFix, nullptr, 0, nullptr);
|
||||
if (worker) CloseHandle(worker);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
void StartSelectedPageReadinessFix(HMODULE module);
|
||||
@@ -0,0 +1,413 @@
|
||||
#include "90CN_seria_minimap.h"
|
||||
#include "90CN_seria_room_policy.h"
|
||||
#include <string.h>
|
||||
|
||||
namespace dnf90_seria {
|
||||
static Logger LogLine;
|
||||
static HMODULE g_image;
|
||||
static volatile LONG g_ready;
|
||||
static SRWLOCK g_lock = SRWLOCK_INIT;
|
||||
static Lifecycle g_owner = {};
|
||||
static void* g_original[8];
|
||||
typedef uintptr_t (__thiscall* TownResourceLookup)(void*, int, int);
|
||||
static TownResourceLookup g_titleTownLookup;
|
||||
enum Kind { Setter, Bind, TownGet, Region, Draw, Marker, Rect };
|
||||
static uintptr_t Va(uintptr_t address) { return reinterpret_cast<uintptr_t>(g_image) + address - 0x400000; }
|
||||
|
||||
// PUSHAD layout. Native argument words, register/flag/FPU/SSE state, return
|
||||
// values and RET n cleanup are forwarded verbatim; no guessed C++ native ABI.
|
||||
struct Registers { DWORD edi, esi, ebp, esp, ebx, edx, ecx, eax, flags; };
|
||||
struct Display { Owner owner; uintptr_t ui, entry, marker; int region; };
|
||||
struct Frame {
|
||||
uintptr_t nativeStack, caller, self, esi;
|
||||
Kind kind;
|
||||
int town, area;
|
||||
bool active;
|
||||
Display display;
|
||||
ULONGLONG entered;
|
||||
};
|
||||
struct PacketFrame { unsigned ticket; unsigned opcode; Notice notice; };
|
||||
struct ThreadState {
|
||||
Frame hooks[128]; unsigned depth;
|
||||
PacketFrame packets[8]; unsigned packetDepth, nextTicket;
|
||||
};
|
||||
static __declspec(thread) ThreadState g_thread = {};
|
||||
|
||||
static bool ReadScene(Scene* scene) {
|
||||
*scene = Scene();
|
||||
__try {
|
||||
uintptr_t root = *reinterpret_cast<uintptr_t*>(Va(0x051b1290));
|
||||
if (!root) return false;
|
||||
scene->world = *reinterpret_cast<uintptr_t*>(root + 0xb4);
|
||||
if (!scene->world) return false;
|
||||
scene->town = *reinterpret_cast<int*>(scene->world + 0x1e4);
|
||||
uintptr_t area = *reinterpret_cast<uintptr_t*>(scene->world + 0x1e8);
|
||||
scene->area = area ? *reinterpret_cast<int*>(area) : -1;
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { *scene = Scene(); return false; }
|
||||
}
|
||||
static uintptr_t ReadUi() {
|
||||
__try {
|
||||
uintptr_t manager = *reinterpret_cast<uintptr_t*>(Va(0x051b2764));
|
||||
return manager ? *reinterpret_cast<uintptr_t*>(manager + 0xb7f0) : 0;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return 0; }
|
||||
}
|
||||
static bool ReadDisplay(Display* value, bool binding) {
|
||||
*value = Display();
|
||||
Scene scene;
|
||||
if (!ReadScene(&scene) || !Shared(scene)) return false;
|
||||
AcquireSRWLockShared(&g_lock);
|
||||
value->owner = g_owner.context;
|
||||
ReleaseSRWLockShared(&g_lock);
|
||||
if (value->owner.world != scene.world || !FindTown(value->owner.town)) return false;
|
||||
value->ui = ReadUi();
|
||||
if (!value->ui) return false;
|
||||
__try {
|
||||
value->entry = *reinterpret_cast<uintptr_t*>(value->ui + 0x2c);
|
||||
if (!value->entry) return binding;
|
||||
value->region = *reinterpret_cast<int*>(value->entry + 0x68);
|
||||
value->marker = *reinterpret_cast<uintptr_t*>(value->entry + 0x64);
|
||||
bool supported = value->marker && *reinterpret_cast<uintptr_t*>(value->marker) == Va(0x045a6dd8) &&
|
||||
*reinterpret_cast<int*>(value->marker + 4) == 2 &&
|
||||
*reinterpret_cast<WORD*>(value->marker + 0x5c) != 0xffff;
|
||||
// Binding precedes native marker construction. A previous region's
|
||||
// marker must not veto the FIRST resource selection on cold login.
|
||||
return binding || supported;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
static bool SameDisplay(const Display& a, const Display& b) {
|
||||
return a.owner.world == b.owner.world && a.owner.serial == b.owner.serial &&
|
||||
a.owner.town == b.owner.town && a.entry == b.entry && a.marker == b.marker &&
|
||||
b.region == b.owner.region;
|
||||
}
|
||||
static Frame* Current(Kind kind) {
|
||||
for (unsigned n = g_thread.depth; n; --n) {
|
||||
Frame* frame = g_thread.hooks + n - 1;
|
||||
if (frame->kind != kind) continue;
|
||||
// The innermost frame owns this call, even when it is ineligible.
|
||||
return frame->active && GetTickCount64() - frame->entered < 1500 ? frame : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static bool ValidFrame(Frame* frame, Display* display) {
|
||||
return frame && ReadDisplay(display, false) && SameDisplay(frame->display, *display);
|
||||
}
|
||||
static void BindPending() {
|
||||
if (!g_thread.packetDepth) return;
|
||||
PacketFrame* packet = g_thread.packets + g_thread.packetDepth - 1;
|
||||
if (packet->opcode != 24) return;
|
||||
Scene scene;
|
||||
if (!ReadScene(&scene)) return;
|
||||
AcquireSRWLockExclusive(&g_lock);
|
||||
bool changed = g_owner.Bind(packet->notice, scene);
|
||||
Owner owner = g_owner.context;
|
||||
ReleaseSRWLockExclusive(&g_lock);
|
||||
if (changed) LogLine("Seria minimap owner bound town=%d region=%d serial=%u (UI only)", owner.town, owner.region, owner.serial);
|
||||
}
|
||||
|
||||
unsigned BeginPacket(int packet) {
|
||||
if (!g_ready || !packet || g_thread.packetDepth >= 8) return 0;
|
||||
unsigned opcode;
|
||||
__try { opcode = *reinterpret_cast<unsigned short*>(packet + 1); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { return 0; }
|
||||
PacketFrame value = {};
|
||||
value.opcode = opcode;
|
||||
if (opcode == 24) ReadScene(&value.notice.before);
|
||||
value.ticket = ++g_thread.nextTicket;
|
||||
if (!value.ticket) value.ticket = ++g_thread.nextTicket;
|
||||
g_thread.packets[g_thread.packetDepth++] = value;
|
||||
return value.ticket;
|
||||
}
|
||||
void EndPacket(unsigned ticket) {
|
||||
if (!ticket || !g_thread.packetDepth) return;
|
||||
PacketFrame* value = g_thread.packets + g_thread.packetDepth - 1;
|
||||
if (value->ticket != ticket) return;
|
||||
if (value->opcode == 24) {
|
||||
Scene after;
|
||||
if (ReadScene(&after)) {
|
||||
AcquireSRWLockExclusive(&g_lock);
|
||||
g_owner.Finish(value->notice, after);
|
||||
ReleaseSRWLockExclusive(&g_lock);
|
||||
}
|
||||
}
|
||||
--g_thread.packetDepth;
|
||||
}
|
||||
|
||||
static void ReturnThunk();
|
||||
static void __cdecl Before(unsigned id, Registers* registers) {
|
||||
if (!g_ready || id > Rect) return;
|
||||
uintptr_t* stack = reinterpret_cast<uintptr_t*>(registers + 1);
|
||||
// A native SEH unwind can bypass a return thunk. Discard only frames whose
|
||||
// original stack was actually unwound; never reuse a stale marker scope.
|
||||
while (g_thread.depth && *stack != reinterpret_cast<uintptr_t>(&ReturnThunk) &&
|
||||
reinterpret_cast<uintptr_t>(stack) >= g_thread.hooks[g_thread.depth-1].nativeStack)
|
||||
--g_thread.depth;
|
||||
if (g_thread.depth == 128) return; // no return-address rewrite on overflow
|
||||
Frame value = {};
|
||||
value.kind = static_cast<Kind>(id);
|
||||
value.nativeStack = reinterpret_cast<uintptr_t>(stack);
|
||||
value.caller = *stack;
|
||||
value.self = registers->ecx;
|
||||
value.esi = registers->esi;
|
||||
value.entered = GetTickCount64();
|
||||
__try {
|
||||
if (id == Setter || id == Region || id == Rect) value.town = static_cast<int>(stack[1]);
|
||||
if (id == Rect) value.area = static_cast<int>(stack[2]);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return; }
|
||||
if (id == Setter && value.caller == Va(0x01d82152) && g_thread.packetDepth &&
|
||||
g_thread.packets[g_thread.packetDepth-1].opcode == 1375) {
|
||||
Scene scene;
|
||||
if (ReadScene(&scene) && scene.world == value.self) {
|
||||
AcquireSRWLockExclusive(&g_lock);
|
||||
g_owner.Seed(scene.world, value.town);
|
||||
ReleaseSRWLockExclusive(&g_lock);
|
||||
LogLine("Seria minimap explicit initial owner town=%d (native cache untouched)", value.town);
|
||||
}
|
||||
} else if (id == Bind) {
|
||||
value.active = value.self && value.self == ReadUi();
|
||||
if (value.active) BindPending(); // before the binder's FIRST town getter
|
||||
} else if (id == Draw) {
|
||||
value.active = ReadDisplay(&value.display, false) && value.self == value.display.entry &&
|
||||
value.display.region == value.display.owner.region;
|
||||
} else if (id == Marker) {
|
||||
Frame* draw = Current(Draw);
|
||||
value.active = ValidFrame(draw, &value.display) && value.self == value.display.marker;
|
||||
}
|
||||
g_thread.hooks[g_thread.depth++] = value;
|
||||
*stack = reinterpret_cast<uintptr_t>(&ReturnThunk);
|
||||
}
|
||||
|
||||
// Synthetic descriptors are DLL-owned UI data, not a native scene/PVF object.
|
||||
struct Descriptor { int town, area; int* geometry; int rect[4]; };
|
||||
static Descriptor g_rects[sizeof(kGlyphs)/sizeof(kGlyphs[0])];
|
||||
static uintptr_t DescriptorFor(const GlyphRow* glyph) {
|
||||
return glyph ? reinterpret_cast<uintptr_t>(g_rects + (glyph - kGlyphs)) : 0;
|
||||
}
|
||||
static uintptr_t ProjectRect(const Frame& frame, uintptr_t original) {
|
||||
Display state;
|
||||
if (frame.caller != Va(0x0224e083) || frame.town != 38 || frame.area != 1 ||
|
||||
!ValidFrame(Current(Marker), &state) || frame.self != state.entry) return original;
|
||||
const GlyphRow* glyph = 0;
|
||||
__try {
|
||||
const int* geometry = original ? *reinterpret_cast<int**>(original + 8) : 0;
|
||||
glyph = BorrowGlyph(state.owner.town, original == 0, geometry);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return original; }
|
||||
return glyph ? DescriptorFor(glyph) : original;
|
||||
}
|
||||
static void __cdecl After(Registers* registers) {
|
||||
// Return target resides in the reserved word ABOVE saved flags. Popping
|
||||
// this word with RET preserves every native output register and ESP.
|
||||
uintptr_t* target = reinterpret_cast<uintptr_t*>(registers + 1);
|
||||
if (!g_thread.depth) {
|
||||
// A missing target is an invariant violation, not a recoverable UI
|
||||
// state. It cannot be produced by disabled/overflow hooks (no rewrite).
|
||||
RaiseFailFastException(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
Frame frame = g_thread.hooks[--g_thread.depth];
|
||||
*target = frame.caller;
|
||||
if (!g_ready) return;
|
||||
Display state;
|
||||
if (frame.kind == TownGet && registers->eax == 38 &&
|
||||
ReadDisplay(&state, frame.caller == Va(0x01c987f6) || frame.caller == Va(0x01c9bc5b)) &&
|
||||
frame.self == state.owner.world) {
|
||||
Frame* bind = Current(Bind);
|
||||
bool allowed = (frame.caller == Va(0x01c987f6) && bind && bind->self == state.ui) ||
|
||||
frame.caller == Va(0x01c9bc5b) ||
|
||||
(frame.caller == Va(0x022478bd) && frame.esi == state.marker && ValidFrame(Current(Draw), &state));
|
||||
if (allowed) registers->eax = state.owner.town;
|
||||
} else if (frame.kind == Region && frame.town == 38 && registers->eax == 18 &&
|
||||
frame.caller == Va(0x0223c4fd) && ValidFrame(Current(Marker), &state)) {
|
||||
registers->eax = state.owner.region;
|
||||
} else if (frame.kind == Rect) {
|
||||
registers->eax = static_cast<DWORD>(ProjectRect(frame, registers->eax));
|
||||
}
|
||||
}
|
||||
|
||||
// sub_3411790 binds the minimap, then independently copies the REAL town
|
||||
// resource's name at 3412282. Its room38 name bypasses scene_town_get entirely.
|
||||
// Redirect only that CALL's string source; native copy/destruction still owns
|
||||
// the value, and the scene, town resource and global string copier stay intact.
|
||||
static void __cdecl BeforeTitleCopy(Registers* registers) {
|
||||
if (!g_ready || !g_titleTownLookup || !g_thread.packetDepth ||
|
||||
g_thread.packets[g_thread.packetDepth-1].opcode != 24) return;
|
||||
uintptr_t* stack = reinterpret_cast<uintptr_t*>(registers + 1);
|
||||
if (*stack != Va(0x03412287)) return;
|
||||
Display state;
|
||||
if (!ReadDisplay(&state, true) || state.region != state.owner.region ||
|
||||
registers->edi != state.owner.world || state.owner.town == 38) return;
|
||||
__try {
|
||||
// The guarded caller takes this exact source from its scene resource.
|
||||
uintptr_t realTown = *reinterpret_cast<uintptr_t*>(registers->ebp - 0x24);
|
||||
if (!realTown || stack[1] != realTown + 4) return;
|
||||
uintptr_t loader = *reinterpret_cast<uintptr_t*>(Va(0x051b26d8));
|
||||
// This singleton is constructed with town resource type 12 at
|
||||
// 22938d1..22938df; the generic lookup must not parse another type.
|
||||
if (!loader || *reinterpret_cast<int*>(loader + 0x22c) != 12) return;
|
||||
// Same native cache-enabled resource lookup used by scene entry.
|
||||
uintptr_t town = g_titleTownLookup(reinterpret_cast<void*>(loader), state.owner.town, 1);
|
||||
if (!town) return;
|
||||
uintptr_t name = town + 4;
|
||||
unsigned length = *reinterpret_cast<unsigned*>(name + 0x14);
|
||||
unsigned capacity = *reinterpret_cast<unsigned*>(name + 0x18);
|
||||
if (!length || length > capacity) return;
|
||||
Display current;
|
||||
if (!ReadDisplay(¤t, true) || !SameDisplay(state, current)) return;
|
||||
stack[1] = name;
|
||||
LogLine("Seria minimap title projected town=%d region=%d serial=%u (native resource name)",
|
||||
state.owner.town, state.owner.region, state.owner.serial);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return; }
|
||||
}
|
||||
|
||||
// Preserve flags and the complete x87/MMX/SSE state around our observer, not
|
||||
// merely callee-saved integer registers. FX area is 16-byte aligned even when
|
||||
// the game's incoming stack is not. The native function sees its exact stack.
|
||||
#define SAVE_OBSERVER __asm { pushfd } __asm { pushad } __asm { sub esp, 528 } \
|
||||
__asm { lea eax, [esp+15] } __asm { and eax, -16 } __asm { fxsave [eax] }
|
||||
#define RESTORE_OBSERVER __asm { lea eax, [esp+15] } __asm { and eax, -16 } \
|
||||
__asm { fxrstor [eax] } __asm { add esp, 528 } __asm { popad } __asm { popfd }
|
||||
#define ENTRY(name, id, offset) static void __declspec(naked) name() { \
|
||||
SAVE_OBSERVER __asm { lea eax, [esp+528] } __asm { push eax } __asm { push id } \
|
||||
__asm { call Before } __asm { add esp, 8 } RESTORE_OBSERVER \
|
||||
__asm { jmp dword ptr [g_original+offset] } }
|
||||
ENTRY(SetterEntry, 0, 0)
|
||||
ENTRY(BindEntry, 1, 4)
|
||||
ENTRY(TownEntry, 2, 8)
|
||||
ENTRY(RegionEntry, 3, 12)
|
||||
ENTRY(DrawEntry, 4, 16)
|
||||
ENTRY(MarkerEntry, 5, 20)
|
||||
ENTRY(RectEntry, 6, 24)
|
||||
static void __declspec(naked) TitleCopyEntry() {
|
||||
SAVE_OBSERVER
|
||||
__asm { lea eax, [esp+528] }
|
||||
__asm { push eax }
|
||||
__asm { call BeforeTitleCopy }
|
||||
__asm { add esp, 4 }
|
||||
RESTORE_OBSERVER
|
||||
__asm { jmp dword ptr [g_original+28] }
|
||||
}
|
||||
static void __declspec(naked) ReturnThunk() {
|
||||
__asm { push 0 }
|
||||
SAVE_OBSERVER
|
||||
__asm { lea eax, [esp+528] }
|
||||
__asm { push eax }
|
||||
__asm { call After }
|
||||
__asm { add esp, 4 }
|
||||
RESTORE_OBSERVER
|
||||
__asm { ret }
|
||||
}
|
||||
#undef ENTRY
|
||||
#undef SAVE_OBSERVER
|
||||
#undef RESTORE_OBSERVER
|
||||
|
||||
struct Hook { const char* name; uintptr_t address; const char* bytes; unsigned size; void* entry; uintptr_t callTarget; BYTE saved[16]; };
|
||||
static Hook g_hooks[] = {
|
||||
{"world_town_set",0x03406800,"558bec568b7508",7,SetterEntry},
|
||||
{"minimap_bind",0x01c987c0,"568bf1ff156c261a04",9,BindEntry},
|
||||
{"scene_town_get",0x03406320,"8b81e4010000",6,TownEntry},
|
||||
{"town_region_resolve",0x0392cc90,"558bec83ec18",6,RegionEntry},
|
||||
{"minimap_draw_layers",0x022476c0,"558bec6aff",5,DrawEntry},
|
||||
{"marker_update",0x0223c460,"558bec83ec0c",6,MarkerEntry},
|
||||
{"map_rect_lookup",0x0224d960,"558bec83ec18",6,RectEntry},
|
||||
{"minimap_title_copy",0x03412282,"e849a732fd",5,TitleCopyEntry,0x0073c9d0}
|
||||
};
|
||||
static const unsigned kHookCount = sizeof(g_hooks)/sizeof(g_hooks[0]);
|
||||
struct Guard { uintptr_t address; const char* bytes; };
|
||||
static const Guard g_guards[] = {
|
||||
{0x03406818,"85c0740980b8f0000000017443"}, {0x01d8214d,"e8ae4668018be55dc3"},
|
||||
{0x01c987f1,"e82adb760150e89444c901"}, {0x01c9bc56,"e8c5a6760150e82f10c901"},
|
||||
{0x022478b8,"e863ea1b0150e8cd536e0183c4043b43687568"},
|
||||
{0x02247884,"8b34b985f6744d837e0402753a"}, {0x0223c4f8,"e893076f018bf883c404"},
|
||||
{0x0224e07e,"e8ddf8ffff8b4d0c"}, {0x03680720,"8b412cc3"}, {0x034e4b90,"8b4164c3"},
|
||||
{0x01c43fa3,"e8e80b8a0183c008"}, {0x01c44239,"e8420b0500"},
|
||||
{0x0224821e,"8b55e4897a64"}, {0x02240c56,"895e08895e0c"},
|
||||
{0x022479a2,"c23800"},
|
||||
{0x03411950,"6a01899fe40100008b0dd8261b0553e84c8704008945dc"},
|
||||
{0x03412273,"8b45dc83ec2083c0048bcc8965d050e849a732fdc745fc03000000c745fcffffffff8bcee8142183fe"},
|
||||
{0x0345a0b0,"558bec5356578bf1e873f9ffff85c074118b5d08538bc8e8c4aa19008bf885ff75095f5e33c05b5dc208008b450c508bce899e34020000e804ad37ff578bcee8acfc13005fc78634020000ffffffff5e5b5dc20800"},
|
||||
{0x0073c9d0,"558bec6aff6868f6f90364a1000000005083ec1456a13c670e05"}
|
||||
};
|
||||
static int Hex(char c) { return c >= '0' && c <= '9' ? c-'0' : c >= 'a' && c <= 'f' ? c-'a'+10 : -1; }
|
||||
static bool Match(uintptr_t address, const char* bytes) {
|
||||
__try {
|
||||
size_t length = strlen(bytes);
|
||||
if (length % 2) return false;
|
||||
for (size_t i = 0; i < length/2; ++i)
|
||||
if (*reinterpret_cast<BYTE*>(Va(address)+i) != (Hex(bytes[2*i])*16+Hex(bytes[2*i+1]))) return false;
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
static bool WriteCode(void* destination, const void* source, unsigned size) {
|
||||
DWORD protection = 0;
|
||||
if (!VirtualProtect(destination, size, PAGE_EXECUTE_READWRITE, &protection)) return false;
|
||||
memcpy(destination, source, size);
|
||||
FlushInstructionCache(GetCurrentProcess(), destination, size);
|
||||
DWORD ignored;
|
||||
return VirtualProtect(destination, size, protection, &ignored) != FALSE;
|
||||
}
|
||||
static void Jump(BYTE* destination, uintptr_t source, uintptr_t target) {
|
||||
destination[0] = 0xe9;
|
||||
*reinterpret_cast<DWORD*>(destination+1) = static_cast<DWORD>(target - source - 5);
|
||||
}
|
||||
bool Install(HMODULE image, Logger logger) {
|
||||
if (g_ready) return true;
|
||||
g_image = image;
|
||||
LogLine = logger;
|
||||
if (!image || !logger) return false;
|
||||
// Verify ALL entry/caller/layout guards before allocation or any hook.
|
||||
for (unsigned n = 0; n < sizeof(g_hooks)/sizeof(g_hooks[0]); ++n)
|
||||
if (!Match(g_hooks[n].address, g_hooks[n].bytes)) {
|
||||
LogLine("Seria minimap disabled: native entry mismatch %s", g_hooks[n].name); return false;
|
||||
}
|
||||
for (unsigned n = 0; n < sizeof(g_guards)/sizeof(g_guards[0]); ++n)
|
||||
if (!Match(g_guards[n].address, g_guards[n].bytes)) {
|
||||
LogLine("Seria minimap disabled: native guard mismatch %08X", static_cast<unsigned>(g_guards[n].address)); return false;
|
||||
}
|
||||
g_titleTownLookup = reinterpret_cast<TownResourceLookup>(Va(0x0345a0b0));
|
||||
for (unsigned n = 0; n < sizeof(g_rects)/sizeof(g_rects[0]); ++n) {
|
||||
g_rects[n].town = 38; g_rects[n].area = 1; g_rects[n].geometry = g_rects[n].rect;
|
||||
memcpy(g_rects[n].rect, kGlyphs[n].rect, sizeof(g_rects[n].rect));
|
||||
}
|
||||
unsigned allocated = 0, written = 0;
|
||||
bool prepared = true, patched = true;
|
||||
for (; allocated != kHookCount; ++allocated) {
|
||||
Hook& hook = g_hooks[allocated];
|
||||
memcpy(hook.saved, reinterpret_cast<void*>(Va(hook.address)), hook.size);
|
||||
if (hook.callTarget) {
|
||||
g_original[allocated] = reinterpret_cast<void*>(Va(hook.callTarget));
|
||||
continue;
|
||||
}
|
||||
BYTE* trampoline = static_cast<BYTE*>(VirtualAlloc(0, hook.size+5, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE));
|
||||
if (!trampoline) { prepared = false; break; }
|
||||
g_original[allocated] = trampoline;
|
||||
memcpy(trampoline, hook.saved, hook.size);
|
||||
Jump(trampoline+hook.size, reinterpret_cast<uintptr_t>(trampoline)+hook.size, Va(hook.address)+hook.size);
|
||||
DWORD previous;
|
||||
if (!VirtualProtect(trampoline, hook.size+5, PAGE_EXECUTE_READ, &previous)) { prepared = false; ++allocated; break; }
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline, hook.size+5);
|
||||
}
|
||||
if (prepared && allocated == kHookCount) {
|
||||
for (; written != kHookCount; ++written) {
|
||||
Hook& hook = g_hooks[written]; BYTE replacement[16]; memset(replacement, 0x90, hook.size);
|
||||
Jump(replacement, Va(hook.address), reinterpret_cast<uintptr_t>(hook.entry));
|
||||
if (hook.callTarget) replacement[0] = 0xe8; // keep the native CALL/RET 4 ABI
|
||||
if (!WriteCode(reinterpret_cast<void*>(Va(hook.address)), replacement, hook.size)) { patched = false; ++written; break; }
|
||||
}
|
||||
if (patched && written == kHookCount) {
|
||||
InterlockedExchange(&g_ready, 1);
|
||||
LogLine("Seria minimap ready: owning-town title hooks=8 guards=19 generated-rooms=16, recording independent");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Roll back entry bytes BEFORE freeing executable trampolines.
|
||||
bool restored = true;
|
||||
for (unsigned n = 0; n != written; ++n)
|
||||
restored = WriteCode(reinterpret_cast<void*>(Va(g_hooks[n].address)), g_hooks[n].saved, g_hooks[n].size) && restored;
|
||||
if (restored) for (unsigned n = 0; n != allocated; ++n) {
|
||||
if (g_original[n] && !g_hooks[n].callTarget) VirtualFree(g_original[n], 0, MEM_RELEASE);
|
||||
g_original[n] = 0;
|
||||
}
|
||||
LogLine("Seria minimap disabled: install failed; entry rollback=%d", restored ? 1 : 0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
|
||||
// UI-only shared-room ownership. Packet callbacks use the existing dispatcher;
|
||||
// this module never replaces a scene, actor, quest, PVF row, or game field.
|
||||
namespace dnf90_seria {
|
||||
typedef void (__cdecl* Logger)(const char* format, ...);
|
||||
bool Install(HMODULE image, Logger logger);
|
||||
unsigned BeginPacket(int packet);
|
||||
void EndPacket(unsigned ticket);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
// Generated from the complete hash-checked current PVF/NPK census. Do not edit city rows.
|
||||
// Input: data/seria_room_catalog.json; generator: tools/generate_seria_room_catalog.py.
|
||||
#include <stdint.h>
|
||||
namespace dnf90_seria {
|
||||
struct TownRow { int town; int region; uint64_t areas; };
|
||||
struct GlyphRow { int town; int rect[4]; };
|
||||
static const char kPvfSha256[] = "510daa58d01bc0f483b146b80faca9e21af4e85ad099467058d67f84dce6af5c";
|
||||
static const char kNpkSha256[] = "e107637a2990d1d4d5802700a4ece64cf8338058c986b23cc337fc749ee66248";
|
||||
static const TownRow kTowns[] = {
|
||||
{6, 21, 0x1fULL},
|
||||
{7, 4, 0x3fULL},
|
||||
{9, 21, 0x3ULL},
|
||||
{10, 5, 0xffULL},
|
||||
{11, 5, 0x7ULL},
|
||||
{12, 5, 0x1ULL},
|
||||
{13, 5, 0x1ULL},
|
||||
{14, 22, 0x1fULL},
|
||||
{17, 6, 0x1fULL},
|
||||
{18, 5, 0xfULL},
|
||||
{19, 3, 0x7ULL},
|
||||
{20, 22, 0x7ULL},
|
||||
{22, 7, 0x1fULL},
|
||||
{28, 11, 0x3ULL},
|
||||
{30, 13, 0x7ULL},
|
||||
{31, 13, 0xfULL},
|
||||
{32, 14, 0x1fULL},
|
||||
{35, 13, 0x3ULL},
|
||||
{36, 16, 0x1ULL},
|
||||
{37, 17, 0x1ULL},
|
||||
{38, 18, 0x7dULL},
|
||||
{39, 19, 0x3fffffffffULL},
|
||||
{40, 19, 0x3fULL},
|
||||
{41, 20, 0x1fULL},
|
||||
{42, 20, 0xfULL},
|
||||
{43, 18, 0x7ULL},
|
||||
{46, 23, 0x7fULL},
|
||||
{98, 24, 0x1ffffffffULL},
|
||||
{106, 1, 0x1ULL},
|
||||
{107, 15, 0x1ULL},
|
||||
};
|
||||
static const GlyphRow kGlyphs[] = {
|
||||
{6, {280, 118, 20, 16}},
|
||||
{9, {390, 326, 20, 16}},
|
||||
{11, {371, 276, 20, 16}},
|
||||
{14, {347, 442, 20, 16}},
|
||||
{17, {400, 227, 20, 16}},
|
||||
{18, {255, 415, 20, 16}},
|
||||
{20, {159, 157, 20, 16}},
|
||||
{22, {117, 448, 20, 16}},
|
||||
{30, {65, 66, 20, 16}},
|
||||
{31, {523, 278, 20, 16}},
|
||||
{39, {293, 289, 20, 16}},
|
||||
{40, {514, 218, 20, 16}},
|
||||
{41, {350, 214, 20, 16}},
|
||||
{42, {416, 361, 20, 16}},
|
||||
{43, {578, 105, 20, 16}},
|
||||
{46, {546, 208, 20, 16}},
|
||||
};
|
||||
static const int kNativeSharedProjectRect[] = {302, 357, 16, 10};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include "90CN_seria_room_catalog.h"
|
||||
#include <stddef.h>
|
||||
|
||||
namespace dnf90_seria {
|
||||
inline const TownRow* FindTown(int town) {
|
||||
for (size_t i = 0; i < sizeof(kTowns)/sizeof(kTowns[0]); ++i)
|
||||
if (kTowns[i].town == town) return kTowns+i;
|
||||
return 0;
|
||||
}
|
||||
inline const GlyphRow* FindGlyph(int town) {
|
||||
for (size_t i = 0; i < sizeof(kGlyphs)/sizeof(kGlyphs[0]); ++i)
|
||||
if (kGlyphs[i].town == town) return kGlyphs+i;
|
||||
return 0;
|
||||
}
|
||||
struct Scene { uintptr_t world; int town; int area; };
|
||||
inline bool Shared(const Scene& scene) { return scene.world && scene.town == 38 && scene.area == 1; }
|
||||
inline bool ValidOrigin(const Scene& scene) {
|
||||
const TownRow* row = FindTown(scene.town);
|
||||
return scene.world && row && scene.area >= 0 && scene.area < 64 &&
|
||||
(row->areas & (uint64_t(1) << scene.area)) != 0;
|
||||
}
|
||||
struct Owner { uintptr_t world; int town; int region; unsigned serial; };
|
||||
struct Notice { Scene before; bool bound; };
|
||||
struct Lifecycle {
|
||||
Owner context;
|
||||
uintptr_t seedWorld;
|
||||
int seedTown;
|
||||
unsigned serial;
|
||||
void Clear() { context = Owner(); seedWorld = 0; seedTown = 0; ++serial; }
|
||||
void Seed(uintptr_t world, int town) {
|
||||
Clear();
|
||||
if (world && FindTown(town)) { seedWorld = world; seedTown = town; }
|
||||
}
|
||||
bool Bind(Notice& notice, const Scene& after) {
|
||||
if (notice.bound || after.world != notice.before.world || !Shared(after)) return false;
|
||||
int town = seedWorld == after.world ? seedTown : (ValidOrigin(notice.before) ? notice.before.town : 0);
|
||||
seedWorld = 0; seedTown = 0;
|
||||
if (town) {
|
||||
const TownRow* row = FindTown(town);
|
||||
context = Owner{after.world, town, row->region, ++serial};
|
||||
} else if (!(Shared(notice.before) && context.world == after.world)) {
|
||||
context = Owner(); ++serial;
|
||||
}
|
||||
notice.bound = true;
|
||||
return true;
|
||||
}
|
||||
void Finish(Notice& notice, const Scene& after) {
|
||||
if (!after.world || notice.before.world != after.world) return;
|
||||
if (!Shared(after)) Clear(); else Bind(notice, after);
|
||||
}
|
||||
};
|
||||
inline bool OriginalSharedRect(const int* rect) {
|
||||
if (!rect) return false;
|
||||
for (int i = 0; i != 4; ++i) if (rect[i] != kNativeSharedProjectRect[i]) return false;
|
||||
return true;
|
||||
}
|
||||
// Unknown non-NULL native geometry wins. Native owner38 needs no synthetic row.
|
||||
inline const GlyphRow* BorrowGlyph(int town, bool nativeNull, const int* originalRect) {
|
||||
return (nativeNull || OriginalSharedRect(originalRect)) ? FindGlyph(town) : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
// Current-client pointer-vector snapshot; this policy never writes native state.
|
||||
namespace dnf90 {
|
||||
struct UiIteratorSnapshot {
|
||||
uintptr_t owner, proxy, iteratorProxy, proxyOwner, begin, end, current;
|
||||
};
|
||||
inline bool CanAdvanceUiIterator(const UiIteratorSnapshot& s)
|
||||
{
|
||||
return s.owner != 0 && s.proxy != 0 && s.iteratorProxy == s.proxy &&
|
||||
s.proxyOwner == s.owner && s.begin != 0 && s.end >= s.begin &&
|
||||
s.current >= s.begin && s.current < s.end &&
|
||||
(s.end - s.begin) % 4 == 0 && (s.current - s.begin) % 4 == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
#include "90CN_weapon_skill_relogin_fix.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uintptr_t kRequirementBranchRva = 0x01C07739;
|
||||
constexpr uintptr_t kRequirementInvalidResumeRva = 0x01C07748;
|
||||
constexpr uintptr_t kRequirementValidResumeRva = 0x01C07780;
|
||||
constexpr uintptr_t kNativeSkillValidityRva = 0x0225F610;
|
||||
constexpr uintptr_t kReloginEquipmentCallerReturnRva = 0x01C0859E;
|
||||
constexpr DWORD kInstallDelayMs = 1500;
|
||||
|
||||
// Native bytes immediately after actor->lookup(requirementId, 1):
|
||||
// je invalid
|
||||
// push 1
|
||||
// mov ecx,eax
|
||||
const unsigned char kExpectedBytes[6] = { 0x74, 0x0D, 0x6A, 0x01, 0x8B, 0xC8 };
|
||||
|
||||
HMODULE g_selfModule = nullptr;
|
||||
uintptr_t g_exeBase = 0;
|
||||
HANDLE g_worker = nullptr;
|
||||
volatile LONG g_stop = 0;
|
||||
volatile LONG g_installed = 0;
|
||||
volatile LONG g_bypassLogCount = 0;
|
||||
SRWLOCK g_logLock = SRWLOCK_INIT;
|
||||
void* g_nativeSkillValidity = nullptr;
|
||||
void* g_invalidResume = nullptr;
|
||||
void* g_validResume = nullptr;
|
||||
uintptr_t g_expectedCallerReturn = 0;
|
||||
|
||||
void BuildLogPath(wchar_t* output, size_t outputCount)
|
||||
{
|
||||
if (!output || outputCount == 0) return;
|
||||
output[0] = L'\0';
|
||||
DWORD length = g_selfModule
|
||||
? GetModuleFileNameW(g_selfModule, output,
|
||||
static_cast<DWORD>(outputCount))
|
||||
: 0;
|
||||
if (!length || length >= outputCount) {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
return;
|
||||
}
|
||||
wchar_t* slash = wcsrchr(output, L'\\');
|
||||
wchar_t* slash2 = wcsrchr(output, L'/');
|
||||
if (slash2 && (!slash || slash2 > slash)) slash = slash2;
|
||||
if (slash) {
|
||||
slash[1] = L'\0';
|
||||
wcscat_s(output, outputCount, L"90CN_fix.log");
|
||||
} else {
|
||||
wcscpy_s(output, outputCount, L"90CN_fix.log");
|
||||
}
|
||||
}
|
||||
|
||||
void WriteLog(const char* format, ...)
|
||||
{
|
||||
if (!format) return;
|
||||
char message[2048] = {};
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
_vsnprintf_s(message, sizeof(message), _TRUNCATE, format, args);
|
||||
va_end(args);
|
||||
|
||||
SYSTEMTIME now = {};
|
||||
GetLocalTime(&now);
|
||||
char line[2304] = {};
|
||||
_snprintf_s(line, sizeof(line), _TRUNCATE,
|
||||
"%04u-%02u-%02u %02u:%02u:%02u.%03u tid=%lu %s\r\n",
|
||||
now.wYear, now.wMonth, now.wDay,
|
||||
now.wHour, now.wMinute, now.wSecond, now.wMilliseconds,
|
||||
static_cast<unsigned long>(GetCurrentThreadId()), message);
|
||||
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
BuildLogPath(path, _countof(path));
|
||||
AcquireSRWLockExclusive(&g_logLock);
|
||||
HANDLE file = CreateFileW(path, FILE_APPEND_DATA,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file != INVALID_HANDLE_VALUE) {
|
||||
DWORD written = 0;
|
||||
WriteFile(file, line, static_cast<DWORD>(strlen(line)), &written, nullptr);
|
||||
CloseHandle(file);
|
||||
}
|
||||
ReleaseSRWLockExclusive(&g_logLock);
|
||||
}
|
||||
|
||||
__declspec(noinline) void __cdecl LogReloginBypass(
|
||||
unsigned int requirementId, uintptr_t callerReturn)
|
||||
{
|
||||
LONG count = InterlockedIncrement(&g_bypassLogCount);
|
||||
if (count > 8) return;
|
||||
WriteLog(
|
||||
"weapon-relogin-fix bypass hit=%ld requirement_id=%u caller_rva=0x%08X "
|
||||
"lookup_record=present native_validity=0 action=treat-valid "
|
||||
"scope=op24-relogin-main-weapon-only",
|
||||
count, requirementId,
|
||||
g_exeBase && callerReturn >= g_exeBase
|
||||
? static_cast<unsigned int>(callerReturn - g_exeBase)
|
||||
: 0u);
|
||||
}
|
||||
|
||||
// Original sequence at RVA 0x01C07737:
|
||||
// test eax,eax
|
||||
// je invalid
|
||||
// push 1
|
||||
// mov ecx,eax
|
||||
// call sub_265F610
|
||||
// test eax,eax
|
||||
// jne valid
|
||||
//
|
||||
// The caller has already decoded the equipped main weapon's requirement id,
|
||||
// skipped requirementId == -1, and resolved actor->lookup(requirementId, 1).
|
||||
// Live evidence proved that op24 can reach this point with a real requirement
|
||||
// record while sub_265F610 transiently returns 0 before skill initialization is
|
||||
// fully settled. Preserve every native decision except that timing window.
|
||||
//
|
||||
// The standard EBP frame lets us require [EBP+4] == base + 0x01C0859E, the
|
||||
// observed op24 relogin main-weapon revalidation caller. Therefore normal
|
||||
// manual equipping, other callers, missing requirement records, and native
|
||||
// validity successes all keep their original behavior. No skill/profession
|
||||
// allow list is needed, so the same mechanism covers other exclusive weapons.
|
||||
__declspec(naked) void ProxyWeaponSkillReloginFix()
|
||||
{
|
||||
__asm {
|
||||
// EAX is actor->lookup(requirementId, 1). A missing record remains
|
||||
// invalid exactly as in native code.
|
||||
test eax, eax
|
||||
jz requirement_invalid
|
||||
|
||||
// Preserve the native validity call and accept native success first.
|
||||
push 1
|
||||
mov ecx, eax
|
||||
call dword ptr [g_nativeSkillValidity]
|
||||
test eax, eax
|
||||
jnz requirement_valid
|
||||
|
||||
// Only repair the known relogin main-weapon revalidation call site.
|
||||
// EBX still holds the decoded requirement id for logging.
|
||||
mov edx, dword ptr [ebp + 4]
|
||||
cmp edx, dword ptr [g_expectedCallerReturn]
|
||||
jne requirement_invalid
|
||||
|
||||
pushfd
|
||||
pushad
|
||||
push edx
|
||||
push ebx
|
||||
call LogReloginBypass
|
||||
add esp, 8
|
||||
popad
|
||||
popfd
|
||||
|
||||
mov eax, 1
|
||||
jmp dword ptr [g_validResume]
|
||||
|
||||
requirement_invalid:
|
||||
jmp dword ptr [g_invalidResume]
|
||||
|
||||
requirement_valid:
|
||||
jmp dword ptr [g_validResume]
|
||||
}
|
||||
}
|
||||
|
||||
bool PatchFix(unsigned char* target)
|
||||
{
|
||||
if (!target) return false;
|
||||
unsigned char patch[6] = { 0xE9, 0, 0, 0, 0, 0x90 };
|
||||
LONG relative = static_cast<LONG>(
|
||||
reinterpret_cast<uintptr_t>(&ProxyWeaponSkillReloginFix) -
|
||||
reinterpret_cast<uintptr_t>(target + 5));
|
||||
*reinterpret_cast<LONG*>(patch + 1) = relative;
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, sizeof(patch), PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
return false;
|
||||
}
|
||||
memcpy(target, patch, sizeof(patch));
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(target, sizeof(patch), oldProtection, &ignored);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, sizeof(patch));
|
||||
return memcmp(target, patch, sizeof(patch)) == 0;
|
||||
}
|
||||
|
||||
bool RestoreFix(unsigned char* target)
|
||||
{
|
||||
if (!target) return false;
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, sizeof(kExpectedBytes), PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
return false;
|
||||
}
|
||||
memcpy(target, kExpectedBytes, sizeof(kExpectedBytes));
|
||||
DWORD ignored = 0;
|
||||
VirtualProtect(target, sizeof(kExpectedBytes), oldProtection, &ignored);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, sizeof(kExpectedBytes));
|
||||
return memcmp(target, kExpectedBytes, sizeof(kExpectedBytes)) == 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallWorker(LPVOID)
|
||||
{
|
||||
g_exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
|
||||
if (!g_exeBase || !g_selfModule) return 0;
|
||||
|
||||
DWORD slept = 0;
|
||||
while (slept < kInstallDelayMs &&
|
||||
InterlockedCompareExchange(&g_stop, 0, 0) == 0) {
|
||||
Sleep(100);
|
||||
slept += 100;
|
||||
}
|
||||
if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) return 0;
|
||||
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_exeBase + kRequirementBranchRva);
|
||||
g_nativeSkillValidity = reinterpret_cast<void*>(
|
||||
g_exeBase + kNativeSkillValidityRva);
|
||||
g_invalidResume = reinterpret_cast<void*>(
|
||||
g_exeBase + kRequirementInvalidResumeRva);
|
||||
g_validResume = reinterpret_cast<void*>(
|
||||
g_exeBase + kRequirementValidResumeRva);
|
||||
g_expectedCallerReturn = g_exeBase + kReloginEquipmentCallerReturnRva;
|
||||
|
||||
__try {
|
||||
if (memcmp(target, kExpectedBytes, sizeof(kExpectedBytes)) != 0) {
|
||||
WriteLog(
|
||||
"weapon-relogin-fix install-failed target_rva=0x%08X "
|
||||
"observed=%02X%02X%02X%02X%02X%02X expected=740D6A018BC8",
|
||||
static_cast<unsigned int>(kRequirementBranchRva),
|
||||
target[0], target[1], target[2], target[3], target[4], target[5]);
|
||||
return 0;
|
||||
}
|
||||
if (!PatchFix(target)) {
|
||||
WriteLog(
|
||||
"weapon-relogin-fix install-failed reason=patch-write target_rva=0x%08X",
|
||||
static_cast<unsigned int>(kRequirementBranchRva));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
WriteLog("weapon-relogin-fix install exception code=0x%08X",
|
||||
GetExceptionCode());
|
||||
return 0;
|
||||
}
|
||||
|
||||
InterlockedExchange(&g_installed, 1);
|
||||
WriteLog(
|
||||
"weapon-relogin-fix installed target_rva=0x%08X native_validity_rva=0x%08X "
|
||||
"caller_rva=0x%08X requirement_scope=any-present-record "
|
||||
"scope=op24-relogin-main-weapon-only",
|
||||
static_cast<unsigned int>(kRequirementBranchRva),
|
||||
static_cast<unsigned int>(kNativeSkillValidityRva),
|
||||
static_cast<unsigned int>(kReloginEquipmentCallerReturnRva));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void StartWeaponSkillReloginFix(HMODULE module)
|
||||
{
|
||||
if (g_worker || !module) return;
|
||||
g_selfModule = module;
|
||||
InterlockedExchange(&g_stop, 0);
|
||||
g_worker = CreateThread(nullptr, 0, InstallWorker, nullptr, 0, nullptr);
|
||||
if (!g_worker) {
|
||||
WriteLog("weapon-relogin-fix start failed reason=CreateThread error=%lu",
|
||||
GetLastError());
|
||||
}
|
||||
}
|
||||
|
||||
void StopWeaponSkillReloginFix()
|
||||
{
|
||||
InterlockedExchange(&g_stop, 1);
|
||||
if (g_worker) {
|
||||
WaitForSingleObject(g_worker, 1500);
|
||||
CloseHandle(g_worker);
|
||||
g_worker = nullptr;
|
||||
}
|
||||
if (InterlockedCompareExchange(&g_installed, 0, 0) != 0 && g_exeBase) {
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(
|
||||
g_exeBase + kRequirementBranchRva);
|
||||
bool restored = false;
|
||||
__try {
|
||||
restored = RestoreFix(target);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
restored = false;
|
||||
}
|
||||
WriteLog("weapon-relogin-fix restore result=%d", restored ? 1 : 0);
|
||||
InterlockedExchange(&g_installed, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
// Narrow client-side fix for relogin weapon revalidation. It preserves the
|
||||
// native lookup and validity checks, and only repairs the op24 login-equipment
|
||||
// path when a real requirement record exists but its transient native validity
|
||||
// state still reports false. No profession or skill-id allow list is used.
|
||||
void StartWeaponSkillReloginFix(HMODULE module);
|
||||
void StopWeaponSkillReloginFix();
|
||||
@@ -0,0 +1,94 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <unordered_map>
|
||||
|
||||
class InlineHook
|
||||
{
|
||||
public:
|
||||
static InlineHook& Instance()
|
||||
{
|
||||
static InlineHook inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
bool Add(void* target, void* detour)
|
||||
{
|
||||
SIZE_T patchLen = CalcPatchLen(target, 5);
|
||||
if (patchLen < 5) return false;
|
||||
|
||||
auto trampoline = (BYTE*)VirtualAlloc(NULL, patchLen + 5, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
|
||||
if (!trampoline) return false;
|
||||
|
||||
memcpy(trampoline, target, patchLen);
|
||||
trampoline[patchLen] = 0xE9;
|
||||
*(DWORD*)(trampoline + patchLen + 1) = (DWORD)((BYTE*)target + patchLen) - (DWORD)(trampoline + patchLen + 5);
|
||||
|
||||
DWORD old;
|
||||
VirtualProtect(target, patchLen, PAGE_EXECUTE_READWRITE, &old);
|
||||
*(BYTE*)target = 0xE9;
|
||||
*(DWORD*)((BYTE*)target + 1) = (DWORD)(BYTE*)detour - (DWORD)((BYTE*)target + 5);
|
||||
for (SIZE_T i = 5; i < patchLen; i++)
|
||||
*((BYTE*)target + i) = 0x90;
|
||||
VirtualProtect(target, patchLen, old, &old);
|
||||
|
||||
m_trampolines[(uintptr_t)target] = trampoline;
|
||||
return true;
|
||||
}
|
||||
|
||||
void* GetTrampoline(uintptr_t target)
|
||||
{
|
||||
auto it = m_trampolines.find(target);
|
||||
return it != m_trampolines.end() ? it->second : nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
std::unordered_map<uintptr_t, BYTE*> m_trampolines;
|
||||
|
||||
SIZE_T CalcPatchLen(void* addr, SIZE_T minLen)
|
||||
{
|
||||
SIZE_T len = 0;
|
||||
BYTE* p = (BYTE*)addr;
|
||||
while (len < minLen)
|
||||
len += InsnLen(p + len);
|
||||
return len;
|
||||
}
|
||||
|
||||
SIZE_T InsnLen(BYTE* p)
|
||||
{
|
||||
if (p[0] == 0xCC || p[0] == 0xC3 || p[0] == 0x90) return 1;
|
||||
if (p[0] == 0x55 || p[0] == 0x53 || p[0] == 0x56 || p[0] == 0x57) return 1;
|
||||
if (p[0] == 0x5D || p[0] == 0x5B || p[0] == 0x5E || p[0] == 0x5F) return 1;
|
||||
if (p[0] == 0x50 || p[0] == 0x51 || p[0] == 0x52) return 1;
|
||||
if (p[0] == 0x58 || p[0] == 0x59 || p[0] == 0x5A) return 1;
|
||||
if (p[0] == 0x60 || p[0] == 0x61 || p[0] == 0x9C || p[0] == 0x9D) return 1;
|
||||
if (p[0] == 0x8B && (p[1] & 0xC0) == 0xC0) return 2;
|
||||
if (p[0] == 0x8B && p[1] == 0xEC) return 2;
|
||||
if (p[0] == 0x8B && p[1] == 0xFF) return 2;
|
||||
if (p[0] == 0x89 && (p[1] & 0xC0) == 0xC0) return 2;
|
||||
if (p[0] == 0x33 && (p[1] & 0xC0) == 0xC0) return 2;
|
||||
if (p[0] == 0x6A) return 2;
|
||||
if (p[0] == 0xEB) return 2;
|
||||
if (p[0] == 0xB8 || p[0] == 0xB9 || p[0] == 0xBA || p[0] == 0xBB ||
|
||||
p[0] == 0xBC || p[0] == 0xBD || p[0] == 0xBE || p[0] == 0xBF) return 5;
|
||||
if (p[0] == 0xE8 || p[0] == 0xE9) return 5;
|
||||
if (p[0] == 0x68) return 5;
|
||||
if (p[0] == 0x83 && p[1] == 0xEC) return 3;
|
||||
if (p[0] == 0x83 && p[1] == 0xE4) return 3;
|
||||
if (p[0] == 0x81 && p[1] == 0xEC) return 6;
|
||||
if (p[0] == 0xFF && p[1] == 0x25) return 6;
|
||||
if (p[0] == 0x8D && (p[1] & 0xC7) == 0x44) return 4;
|
||||
if (p[0] == 0x8D && (p[1] & 0xC0) == 0x40) return 3;
|
||||
if (p[0] == 0x64 && p[1] == 0xA1) return 6;
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
inline BOOLEAN Hook_Inline(void* target, void* detour)
|
||||
{
|
||||
return InlineHook::Instance().Add(target, detour) ? TRUE : FALSE;
|
||||
}
|
||||
|
||||
inline void* Hook_GetTrampoline(uintptr_t target)
|
||||
{
|
||||
return InlineHook::Instance().GetTrampoline(target);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#include <windows.h>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
|
||||
// 2026-10-21: 性能版构建开关——关闭所有日志写盘(战力插件等功能不受影响)。
|
||||
#ifndef DNF90_SILENT_LOG
|
||||
#define DNF90_SILENT_LOG 1
|
||||
#endif
|
||||
|
||||
|
||||
static std::wstring HexToWString(const unsigned char* data, size_t length)
|
||||
{
|
||||
std::wstringstream wss;
|
||||
wss << std::hex << std::uppercase << std::setfill(L'0');
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
wss << std::setw(2) << static_cast<int>(data[i]);
|
||||
if (i != length - 1) {
|
||||
wss << L" ";
|
||||
}
|
||||
}
|
||||
return wss.str();
|
||||
}
|
||||
|
||||
static std::vector<std::wstring> HexToWStrings(const unsigned char* data, size_t length, size_t bytesPerLine = 16)
|
||||
{
|
||||
std::vector<std::wstring> lines;
|
||||
std::wstringstream wss;
|
||||
wss << std::hex << std::uppercase << std::setfill(L'0');
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
wss << std::setw(2) << static_cast<int>(data[i]);
|
||||
if ((i + 1) % bytesPerLine == 0 || i == length - 1) {
|
||||
lines.push_back(wss.str());
|
||||
wss.str(L"");
|
||||
wss.clear();
|
||||
}
|
||||
else {
|
||||
wss << L" ";
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
static void AppendFileLogLine(const wchar_t* filename, const std::wstring& line)
|
||||
{
|
||||
#ifdef DNF90_SILENT_LOG
|
||||
(void)filename; (void)line; return;
|
||||
#endif
|
||||
FILE* fp = NULL;
|
||||
_wfopen_s(&fp, filename, L"a, ccs=UTF-8");
|
||||
if (fp) {
|
||||
fwprintf(fp, L"%s\n", line.c_str());
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
static void AppendFileLogLines(const wchar_t* filename, const std::vector<std::wstring>& lines)
|
||||
{
|
||||
#ifdef DNF90_SILENT_LOG
|
||||
(void)filename; (void)lines; return;
|
||||
#endif
|
||||
FILE* fp = NULL;
|
||||
_wfopen_s(&fp, filename, L"a, ccs=UTF-8");
|
||||
if (fp) {
|
||||
for (const auto& line : lines) {
|
||||
fwprintf(fp, L"%s\n", line.c_str());
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
static void AppendFileLogFormatLine(const wchar_t* filename, const wchar_t* format, ...)
|
||||
{
|
||||
#ifdef DNF90_SILENT_LOG
|
||||
(void)filename; (void)format; return;
|
||||
#endif
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
int neededSize = _vscwprintf(format, args) + 1;
|
||||
va_end(args);
|
||||
|
||||
wchar_t stackBuffer[2048];
|
||||
wchar_t* buffer = stackBuffer;
|
||||
if (neededSize > _countof(stackBuffer)) {
|
||||
buffer = (wchar_t*)malloc(neededSize * sizeof(wchar_t));
|
||||
if (!buffer) return;
|
||||
}
|
||||
|
||||
va_start(args, format);
|
||||
vswprintf_s(buffer, neededSize > _countof(stackBuffer) ? neededSize : _countof(stackBuffer), format, args);
|
||||
va_end(args);
|
||||
|
||||
AppendFileLogLine(filename, std::wstring(buffer));
|
||||
|
||||
if (buffer != stackBuffer)
|
||||
free(buffer);
|
||||
}
|
||||
|
||||
static void AppendHexLogLine(const wchar_t* filename, const unsigned char* data, size_t length)
|
||||
{
|
||||
#ifdef DNF90_SILENT_LOG
|
||||
(void)filename; (void)data; (void)length; return;
|
||||
#endif
|
||||
std::wstring hexString = HexToWString(data, length);
|
||||
AppendFileLogLine(filename, hexString);
|
||||
}
|
||||
|
||||
static void AppendHexLogLines(const wchar_t* filename, const unsigned char* data, size_t length, size_t bytesPerLine = 16)
|
||||
{
|
||||
#ifdef DNF90_SILENT_LOG
|
||||
(void)filename; (void)data; (void)length; (void)bytesPerLine; return;
|
||||
#endif
|
||||
std::vector<std::wstring> hexLines = HexToWStrings(data, length, bytesPerLine);
|
||||
AppendFileLogLines(filename, hexLines);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
// Production bitmap resources embedded in 90CN.dll for supported DLL-owned
|
||||
// panels. Native Seria-luck UI resources are intentionally not embedded here.
|
||||
#define IDR_COMBAT_POWER_PANEL_SKIN_V2 301
|
||||
#define IDR_COMBAT_RANK_EXPLORE_V3 302
|
||||
#define IDR_COMBAT_RANK_PIONEER_V3 303
|
||||
#define IDR_COMBAT_RANK_FEARLESS_V3 304
|
||||
#define IDR_COMBAT_RANK_CONQUER_V3 305
|
||||
#define IDR_COMBAT_RANK_BATTLE_PINNACLE_V3 306
|
||||
#define IDR_COMBAT_RANK_HEROIC_V3 307
|
||||
#define IDR_COMBAT_RANK_MARTIAL_MASTERY_V3 308
|
||||
#define IDR_COMBAT_POWER_RANK_TOOLTIP_V2 309
|
||||
@@ -0,0 +1,312 @@
|
||||
#include <windows.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
#include <wchar.h>
|
||||
#include <string.h>
|
||||
#include <intrin.h>
|
||||
#include "90CN.h"
|
||||
#include "90CN_op3_parent_fix.h"
|
||||
#include "90CN_selected_page_fix.h"
|
||||
#include "90CN_dungeon_skill_gate_fix.h"
|
||||
#include "90CN_weapon_skill_relogin_fix.h"
|
||||
#include "90CN_3149_writer_resync_fix.h"
|
||||
|
||||
#define EXPAPI(Name) EXTERN_C __declspec(dllexport) void Name() {}
|
||||
|
||||
EXPAPI(A)
|
||||
|
||||
namespace
|
||||
{
|
||||
// Current NoPack's direct-scene route predicate is replaced asynchronously by
|
||||
// 90CN.cpp. The imported source baseline accidentally omitted class-1 opcode
|
||||
// 47 even though the 2026-08-19 live fix proved that DROP_ITEM acknowledgements
|
||||
// must enter the native class-1 handler to release the client's discard
|
||||
// pending token. Keep this compatibility shim deliberately narrower than the
|
||||
// main allow-list: opcode 47 is admitted here and every other opcode tail-calls
|
||||
// the already-installed scene-route proxy unchanged.
|
||||
constexpr uintptr_t kDprotoRoutePredicateRva = 0x030734E0;
|
||||
|
||||
// Current-EXE PVP ready gate. Runtime proof on 2026-09-09 showed that the
|
||||
// resident PK element is a real type-40 channel object and that the virtual
|
||||
// predicate at 0x018928E0 returns false solely because sub_36B0C90's legacy
|
||||
// type whitelist does not contain 40. The same predicate may be used by other
|
||||
// channel/UI paths, so do not modify the global whitelist. Admit type 40 only
|
||||
// when the call comes from the proven PVP-ready vcall return at 0x0189537A.
|
||||
constexpr uintptr_t kPvpReadyElementPredicateRva = 0x014928E0;
|
||||
constexpr uintptr_t kPvpReadyTypeWhitelistRva = 0x032B0C90;
|
||||
constexpr uintptr_t kPvpReadyElementCallReturnRva = 0x0149537A;
|
||||
constexpr unsigned int kPvpNativeChannelType = 40;
|
||||
|
||||
HMODULE g_selfModule = nullptr;
|
||||
PVOID g_previousSceneRoute = nullptr;
|
||||
|
||||
typedef int (__cdecl* PvpReadyTypePredicateFn)(unsigned int);
|
||||
|
||||
PvpReadyTypePredicateFn g_pvpReadyTypePredicate = nullptr;
|
||||
|
||||
void PvpTraceLine(const char* format, ...)
|
||||
{
|
||||
wchar_t exePath[MAX_PATH] = {};
|
||||
if (!GetModuleFileNameW(nullptr, exePath, MAX_PATH)) return;
|
||||
wchar_t* slash = wcsrchr(exePath, L'\\');
|
||||
if (slash) slash[1] = L'\0';
|
||||
|
||||
wchar_t logPath[MAX_PATH] = {};
|
||||
_snwprintf_s(logPath, _countof(logPath), _TRUNCATE,
|
||||
L"%ls90CN_trace.log", exePath);
|
||||
|
||||
char body[768] = {};
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
_vsnprintf_s(body, sizeof(body), _TRUNCATE, format, args);
|
||||
va_end(args);
|
||||
|
||||
SYSTEMTIME now = {};
|
||||
GetLocalTime(&now);
|
||||
char line[1024] = {};
|
||||
int length = _snprintf_s(line, sizeof(line), _TRUNCATE,
|
||||
"[%04u-%02u-%02u %02u:%02u:%02u.%03u] [TRACE] [pid=%lu tid=%lu] %s\r\n",
|
||||
now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond,
|
||||
now.wMilliseconds, GetCurrentProcessId(), GetCurrentThreadId(), body);
|
||||
if (length <= 0) return;
|
||||
|
||||
HANDLE file = CreateFileW(logPath, FILE_APPEND_DATA,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return;
|
||||
DWORD ignored = 0;
|
||||
WriteFile(file, line, static_cast<DWORD>(length), &ignored, nullptr);
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
int __fastcall ProxyPvpReadyElement(void* element, void*)
|
||||
{
|
||||
uintptr_t executable = reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
|
||||
uintptr_t caller = reinterpret_cast<uintptr_t>(_ReturnAddress());
|
||||
uintptr_t callerRva = caller >= executable ? caller - executable : 0;
|
||||
unsigned int type = 0;
|
||||
|
||||
__try {
|
||||
type = *reinterpret_cast<unsigned int*>(
|
||||
reinterpret_cast<unsigned char*>(element) + 8);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
PvpTraceLine(
|
||||
"pvp ready element compatibility read failed caller_rva=0x%08X element=%p",
|
||||
static_cast<unsigned int>(callerRva), element);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (callerRva == kPvpReadyElementCallReturnRva &&
|
||||
type == kPvpNativeChannelType) {
|
||||
PvpTraceLine(
|
||||
"pvp ready element type40 admitted caller_rva=0x%08X element=%p type=%u reason=proved_current_pvp_ready_gate_20260909",
|
||||
static_cast<unsigned int>(callerRva), element, type);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return g_pvpReadyTypePredicate ? g_pvpReadyTypePredicate(type) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallPvpReadyElementCompatibility(LPVOID)
|
||||
{
|
||||
HMODULE executable = GetModuleHandleW(nullptr);
|
||||
if (!executable || !g_selfModule) return 0;
|
||||
|
||||
unsigned char* base = reinterpret_cast<unsigned char*>(executable);
|
||||
unsigned char* target = base + kPvpReadyElementPredicateRva;
|
||||
unsigned char* whitelist = base + kPvpReadyTypeWhitelistRva;
|
||||
const uintptr_t replacement = reinterpret_cast<uintptr_t>(
|
||||
&ProxyPvpReadyElement);
|
||||
|
||||
static const unsigned char kExpectedTarget[] = {
|
||||
0x8B, 0x41, 0x08, 0x50, 0xE8, 0xA7, 0xE3, 0xE1, 0x01
|
||||
};
|
||||
static const unsigned char kExpectedWhitelist[] = {
|
||||
0x55, 0x8B, 0xEC, 0x8B, 0x45, 0x08, 0x83, 0xF8, 0x04
|
||||
};
|
||||
|
||||
unsigned int waitedMs = 0;
|
||||
for (; waitedMs < 120000; waitedMs += 50) {
|
||||
bool ready = false;
|
||||
__try {
|
||||
ready = memcmp(target, kExpectedTarget, sizeof(kExpectedTarget)) == 0 &&
|
||||
memcmp(whitelist, kExpectedWhitelist,
|
||||
sizeof(kExpectedWhitelist)) == 0;
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return 0;
|
||||
}
|
||||
if (ready) break;
|
||||
Sleep(50);
|
||||
}
|
||||
if (waitedMs >= 120000) {
|
||||
PvpTraceLine(
|
||||
"pvp ready element compatibility skipped target=0x%08X whitelist=0x%08X reason=native-bytes-timeout",
|
||||
reinterpret_cast<uintptr_t>(target),
|
||||
reinterpret_cast<uintptr_t>(whitelist));
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_pvpReadyTypePredicate = reinterpret_cast<PvpReadyTypePredicateFn>(
|
||||
whitelist);
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
PvpTraceLine(
|
||||
"pvp ready element compatibility failed reason=VirtualProtect gle=%lu",
|
||||
GetLastError());
|
||||
return 0;
|
||||
}
|
||||
|
||||
target[0] = 0xE9;
|
||||
*reinterpret_cast<LONG*>(target + 1) = static_cast<LONG>(
|
||||
replacement - reinterpret_cast<uintptr_t>(target + 5));
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, 5, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, 5);
|
||||
|
||||
PvpTraceLine(
|
||||
"pvp ready element compatibility installed target=0x%08X whitelist=0x%08X pvp_return_rva=0x%08X admitted_type=%u waited_ms=%u",
|
||||
reinterpret_cast<uintptr_t>(target),
|
||||
reinterpret_cast<uintptr_t>(whitelist),
|
||||
static_cast<unsigned int>(kPvpReadyElementCallReturnRva),
|
||||
kPvpNativeChannelType, waitedMs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec(naked) void ProxyDropItemAckSceneRoute()
|
||||
{
|
||||
__asm {
|
||||
cmp word ptr [esp + 4], 47
|
||||
jne drop_item_route_fallback
|
||||
mov eax, 1
|
||||
ret 4
|
||||
|
||||
drop_item_route_fallback:
|
||||
jmp dword ptr [g_previousSceneRoute]
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI InstallDropItemAckSceneRoute(LPVOID)
|
||||
{
|
||||
HMODULE executable = GetModuleHandleW(nullptr);
|
||||
if (!executable || !g_selfModule) return 0;
|
||||
|
||||
unsigned char* target = reinterpret_cast<unsigned char*>(executable) +
|
||||
kDprotoRoutePredicateRva;
|
||||
const uintptr_t replacement = reinterpret_cast<uintptr_t>(
|
||||
&ProxyDropItemAckSceneRoute);
|
||||
|
||||
// Start90CNPatch installs the ordinary route proxy on its own worker.
|
||||
// Wait until its verified E9+NOP patch is present, then chain this one
|
||||
// opcode in front of it. If DNF_ROUTE_COMPAT=0, no matching patch appears
|
||||
// and this worker exits without changing the native predicate.
|
||||
for (unsigned int waitedMs = 0; waitedMs < 120000; waitedMs += 100) {
|
||||
__try {
|
||||
if (target[0] != 0xE9 || target[5] != 0x90) {
|
||||
Sleep(100);
|
||||
continue;
|
||||
}
|
||||
|
||||
LONG previousRelative = *reinterpret_cast<LONG*>(target + 1);
|
||||
unsigned char* previous = target + 5 + previousRelative;
|
||||
if (reinterpret_cast<uintptr_t>(previous) == replacement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
MEMORY_BASIC_INFORMATION memory = {};
|
||||
if (VirtualQuery(previous, &memory, sizeof(memory)) != sizeof(memory) ||
|
||||
memory.AllocationBase != g_selfModule) {
|
||||
Sleep(100);
|
||||
continue;
|
||||
}
|
||||
|
||||
g_previousSceneRoute = previous;
|
||||
LONG replacementRelative = static_cast<LONG>(
|
||||
replacement - reinterpret_cast<uintptr_t>(target + 5));
|
||||
|
||||
DWORD oldProtection = 0;
|
||||
if (!VirtualProtect(target, 6, PAGE_EXECUTE_READWRITE,
|
||||
&oldProtection)) {
|
||||
return 0;
|
||||
}
|
||||
target[0] = 0xE9;
|
||||
*reinterpret_cast<LONG*>(target + 1) = replacementRelative;
|
||||
target[5] = 0x90;
|
||||
DWORD ignoredProtection = 0;
|
||||
VirtualProtect(target, 6, oldProtection, &ignoredProtection);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, 6);
|
||||
return 0;
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
|
||||
{
|
||||
switch (ul_reason_for_call)
|
||||
{
|
||||
case DLL_PROCESS_ATTACH:
|
||||
g_selfModule = hModule;
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
|
||||
// The new standalone client has a different PE layout from the
|
||||
// production NoPack executable. Keep every legacy RVA hook out of
|
||||
// that process; only the proven native channel selection bridge is
|
||||
// safe there. The current-client dungeon selector/owner/state
|
||||
// observer rewrites the DB4 native entry points and was only added
|
||||
// for a short diagnostic capture. Keep it opt-in so a normal game
|
||||
// launch cannot be destabilised by an observation probe.
|
||||
if (IsNewClientCompatibilityMode()) {
|
||||
wchar_t probeEnabled[8] = {};
|
||||
DWORD probeLength = GetEnvironmentVariableW(
|
||||
L"DNF_DUNGEON_SELECTOR_PROBE", probeEnabled,
|
||||
static_cast<DWORD>(_countof(probeEnabled)));
|
||||
if (probeLength > 0 && probeEnabled[0] == L'1') {
|
||||
StartCurrentDungeonSelectorProbe(hModule);
|
||||
PvpTraceLine(
|
||||
"new-client dungeon selector probe enabled by environment");
|
||||
} else {
|
||||
PvpTraceLine(
|
||||
"new-client dungeon selector/owner/state probes disabled "
|
||||
"for normal launch");
|
||||
}
|
||||
StartCurrentDungeonSkillGateFix(hModule);
|
||||
StartNewClientChannelCompatibility();
|
||||
break;
|
||||
}
|
||||
|
||||
StartOp3ParentReadinessFix(hModule);
|
||||
Start90CNPatch();
|
||||
Start3149WriterResyncFix(hModule);
|
||||
StartSelectedPageReadinessFix(hModule);
|
||||
StartDungeonSkillGateFix(hModule);
|
||||
StartWeaponSkillReloginFix(hModule);
|
||||
{
|
||||
HANDLE worker = CreateThread(
|
||||
nullptr, 0, InstallPvpReadyElementCompatibility,
|
||||
nullptr, 0, nullptr);
|
||||
if (worker) CloseHandle(worker);
|
||||
}
|
||||
{
|
||||
HANDLE worker = CreateThread(
|
||||
nullptr, 0, InstallDropItemAckSceneRoute, nullptr, 0, nullptr);
|
||||
if (worker) CloseHandle(worker);
|
||||
}
|
||||
break;
|
||||
case DLL_PROCESS_DETACH:
|
||||
StopCurrentDungeonSelectorProbe();
|
||||
StopCurrentDungeonSkillGateFix();
|
||||
StopWeaponSkillReloginFix();
|
||||
StopDungeonSkillGateFix();
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,129 @@
|
||||
# PVF 解析套件开发文档
|
||||
|
||||
> 覆盖 PVF 解析库(pvfine)、物品名称表导出工具(pvfexport)、以及与 GM 工具的离线名称显示集成。
|
||||
>
|
||||
> 最后更新:2026-11-03
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
DNF 的 `Script.pvf` 是专有的二进制容器(加密 + 压缩),物品名称(`.equ`/`.stk` 的 `[name]` 段)只存在于 PVF 内。为了让 GM 工具**离线(服务端关闭)也能显示物品中文名**,我们引入了一个 PVF 解析方案。
|
||||
|
||||
---
|
||||
|
||||
## 二、pvfine 项目(`pvf/`)
|
||||
|
||||
`pvf/` 是开源项目 **pvfine**(GPL-3.0)的核心源码,一个 Go 编写的 PVF 编辑器/解析套件,支持 86/90/100/110/115/TW 全版本 `Script.pvf`。
|
||||
|
||||
### 结构
|
||||
|
||||
```
|
||||
pvf/
|
||||
├── internal/pvf/ 核心解析包(Open / Find / Text / ScriptMetadata / ParseListText)
|
||||
├── internal/ani/ buildmode/ rendering/ ... pvf 依赖的其他内部包
|
||||
├── internal/mcpserver/ npk/ mod/ script/ ... 编辑器相关
|
||||
├── config/ go:embed 的索引数据(paths.json 等,编译必需)
|
||||
├── cmd/
|
||||
│ ├── annotation-editor/ 注解编辑器
|
||||
│ └── pvfexport/ 物品名称表导出工具(本仓库新增)
|
||||
├── services/ frontend/ 编辑器后端/前端
|
||||
├── main.go 桌面 GUI 入口(Wails)
|
||||
└── go.mod module pvfine, go 1.26.0
|
||||
```
|
||||
|
||||
### 核心 API(`internal/pvf`)
|
||||
|
||||
```go
|
||||
import "pvfine/internal/pvf"
|
||||
|
||||
archive, err := pvf.Open("Script.pvf") // 打开归档(自动识别版本)
|
||||
defer archive.Release()
|
||||
|
||||
idx, found := archive.Find("stackable/stackable.lst") // 找文件索引
|
||||
text, err := archive.Text(idx) // 读脚本文本
|
||||
pairs, err := pvf.ParseListText(text) // 解析 .lst -> [{ID, Path}]
|
||||
|
||||
meta, err := archive.ScriptMetadata(idx) // 读物品元数据(Name/Rarity)
|
||||
// meta.Name 即物品名称
|
||||
```
|
||||
|
||||
> 注意:`internal/pvf` 依赖 `internal/ani`、`internal/buildmode`、`internal/rendering`、
|
||||
> `internal/annotations`、`internal/ruleconfig`、`config` 等包,**不能单独抽出**,
|
||||
> 需要以整个 `pvfine` 模块作为依赖(或直接在其中添加 cmd 子命令)。
|
||||
|
||||
---
|
||||
|
||||
## 三、pvfexport 工具(`pvf/cmd/pvfexport/`)
|
||||
|
||||
从 `Script.pvf` 提取 `item_id → 名称` 全量映射,导出 JSON。
|
||||
|
||||
### 编译
|
||||
|
||||
```bash
|
||||
cd pvf
|
||||
go build -o pvfexport.exe ./cmd/pvfexport/
|
||||
```
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
pvfexport -pvf Script.pvf -out items.json # 完整 JSON(含 path/rarity/kind)
|
||||
pvfexport -pvf Script.pvf -out dnf_items.json -compact # 紧凑 {item_id: name}(推荐)
|
||||
```
|
||||
|
||||
- `-compact`:只输出 `{item_id: name}` 映射(仅保留有名称的项),体积约 5.7MB(14.6 万条)。
|
||||
- 完整模式:输出 `{pvf_path, total, items:[{id,name,rarity,path,kind}]}`,约 35MB。
|
||||
|
||||
### 实现逻辑
|
||||
|
||||
1. `pvf.Open` 打开归档。
|
||||
2. 读 `stackable/stackable.lst`(堆叠道具)和 `equipment/equipment.lst`(装备),用 `ParseListText` 解析出 `item_id → 路径`。
|
||||
3. 对每个 item,用 `resolveItemFile` 找文件(路径候选策略复刻服务端 `readDungeonDropText`):
|
||||
- 直接用 lst 里的路径
|
||||
- 尝试 `root/` 前缀(`stackable/` / `equipment/`)
|
||||
- 尝试 `(r)` 修订文件名(90 版本特有)
|
||||
4. `ScriptMetadata` 读 `[name]` 段得到名称。
|
||||
|
||||
---
|
||||
|
||||
## 四、与 GM 工具的集成(离线名称显示)
|
||||
|
||||
### 名称表存放
|
||||
|
||||
名称表 `dnf_items.json` 与 `pvfexport.exe` 放在服务端根目录下的 **`GM配置/`** 目录:
|
||||
|
||||
```
|
||||
90server/
|
||||
└── GM配置/
|
||||
├── pvfexport.exe
|
||||
└── dnf_items.json
|
||||
```
|
||||
|
||||
### 自动重导出
|
||||
|
||||
GM 工具(launcher)启动时,`db.py` 的 `_load_item_names()` 会:
|
||||
|
||||
1. 定位 `GM配置/dnf_items.json` 和 `Client/Script.pvf`。
|
||||
2. 对比两者修改时间:
|
||||
- 名称表**已是最新** → 秒加载(约 0.09 秒)。
|
||||
- **PVF 被更新**(或名称表缺失)→ 自动调 `pvfexport.exe -compact` 重导出(约 5 秒),再加载。
|
||||
3. 加 `_EXPORT_LOCK` 锁防并发重复触发。
|
||||
|
||||
> 因此:**PVF 新增物品后,只需替换 `Client/Script.pvf`,GM 工具下次启动自动重导名称表,
|
||||
> 无需重新打包 GM 工具。**
|
||||
|
||||
### 注入方式
|
||||
|
||||
`server.py` 启动时调用 `db.set_gm_config(GM_CONFIG_DIR, PVF_PATH)` 注入目录与 PVF 路径;
|
||||
`db.py` 的 `item_name(item_id)` 据此惰性加载名称表。
|
||||
|
||||
---
|
||||
|
||||
## 五、许可证与合规
|
||||
|
||||
- `pvf/` 遵循 **GPL-3.0**(见 `pvf/LICENSE`)。
|
||||
- 若将 pvfine 与闭源的 server/launcher 合并发布,需注意 **GPL 传染性**:
|
||||
- 单独分发 pvfexport(独立 Go 进程)可隔离传染;
|
||||
- 若将 `internal/pvf` 直接 link 进闭源服务端,会触发 GPL 义务。
|
||||
- 建议:保持 pvfine 作为**独立子模块/子仓库**,通过 CLI 工具(pvfexport)与主项目解耦。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 开发文档索引
|
||||
|
||||
本目录汇总各子项目的开发文档。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [开发文档.md](开发文档.md) | **GM 启动器 / 整合工具** 完整开发文档(环境搭建、架构、API 契约、打包部署、常见问题) |
|
||||
| [服务端开发文档.md](服务端开发文档.md) | **服务端(Go)** 架构、GM 接口契约、拍卖行/背包改动、编译部署 |
|
||||
| [PVF解析开发文档.md](PVF解析开发文档.md) | **PVF 解析** 套件(pvfine)、pvfexport 工具、离线名称显示集成 |
|
||||
| [launcher-README.md](launcher-README.md) | 整合启动器 + GM 工具的原始 README |
|
||||
|
||||
其余子项目的 README 分散在各自目录:
|
||||
|
||||
- `server/README.md` — 服务端源码说明
|
||||
- `pvf/README.md` — pvfine 项目说明
|
||||
- `gmtool/README.md` — GM 工具旧版说明
|
||||
|
||||
---
|
||||
|
||||
## 快速上手路径
|
||||
|
||||
1. **只想跑起来**:看根目录 [README](../README.md) 的「快速开始」。
|
||||
2. **改 GM 工具(启动器)**:看 [开发文档.md](开发文档.md)。
|
||||
3. **改服务端 GM 接口**:看 [服务端开发文档.md](服务端开发文档.md) 的「GM 接口契约」。
|
||||
4. **改 PVF 解析 / 物品名**:看 [PVF解析开发文档.md](PVF解析开发文档.md)。
|
||||
@@ -0,0 +1,95 @@
|
||||
# DNF90 单机模拟器 · 整合启动器 + GM 管理工具
|
||||
|
||||
一个**单 EXE** 的精美启动器,内置完整的 GM 管理工具(角色管理、删角色、删档、无限疲劳、道具发放等)。
|
||||
|
||||
## 一、交付成果
|
||||
|
||||
### 1. 单 EXE 整合启动器(`launcher/dist/DNF90启动器.exe`)
|
||||
|
||||
纯 Python + pywebview 内嵌窗口,PyInstaller 打包成单文件 EXE(14MB),双击即用。
|
||||
|
||||
**窗口化界面**:不再打开浏览器,而是直接弹出一个独立的桌面窗口(基于 Edge WebView2 渲染),
|
||||
界面就是截图里的那个登录器样式。
|
||||
|
||||
**启动器主界面(4 个按钮)**:
|
||||
- ▶ **开始游戏**:configure-client → 启动服务端 → 注册账号 → 自动登录进游戏
|
||||
- ⚙ **打开 GM**:切换到内置 GM 管理工具界面(不再打开独立 exe)
|
||||
- ■ **关闭服务**:安全停止服务端
|
||||
- ♥ **赞助我们**:弹出赞助二维码
|
||||
|
||||
**内置 GM 工具(点击「打开 GM」进入)**,包含 10 个功能页:
|
||||
- 📊 控制台(服务端/数据库/Native API 状态总览)
|
||||
- 👥 角色管理(查看/编辑等级、金币、职业,含**无限疲劳**、**删除角色**)
|
||||
- 🎁 道具发放(发放道具到背包)
|
||||
- ✉️ 邮件系统 · 🎒 背包装备
|
||||
- 📜 任务管理 · ⚔️ 副本控制 · 💰 钱包点券
|
||||
- 🗑️ 删档管理(删除角色/删除账号)
|
||||
- ⚙️ 连接设置(SQLite / Native 数据源切换)
|
||||
|
||||
### 2. 界面特色
|
||||
|
||||
深色 DNF 风格 + 紫青渐变 + 毛玻璃质感 + 动画光斑 + 危险操作二次确认(输入 DELETE)。
|
||||
|
||||
## 二、技术原理
|
||||
|
||||
### 服务端架构
|
||||
- **DNF90 单机模拟器**(非私服),单账号本地模式
|
||||
- 核心控制:`Server/runtime/bin/DNF90Control.exe`(start/stop/configure-client/launch-client/account)
|
||||
- 数据库:**SQLite**,位于 `Server/runtime/data/dnf90.db`(首次启动服务端时创建)
|
||||
|
||||
### GM 工具双数据源
|
||||
- **Native API**:服务端运行时(`127.0.0.1:18111` + `X-Admin-Token`),实时操作
|
||||
- **SQLite 直读**:服务端停止时直接读写数据库,可删角色/删档/改疲劳
|
||||
|
||||
### 关键功能实现
|
||||
- **无限疲劳**:把 `stats` 里的 `fatigue` 和 `fatigue_limit` 都设为超大值(999999)
|
||||
- **删除角色**:直接删除 SQLite 中 `character`/`equipment`/`inventory`/`mailbox`/`quest`/`skill` 等记录,**需先停止服务端**
|
||||
|
||||
## 三、使用方法
|
||||
|
||||
### 直接运行
|
||||
```
|
||||
双击 DNF90启动器.exe
|
||||
```
|
||||
> 首次运行会请求管理员权限(UAC),因为启动服务端需要管理员权限。
|
||||
|
||||
### 部署位置
|
||||
EXE 需放在 `Z:\90server` 目录下(或旁边),会自动向上查找 `Server/` 和 `Client/` 目录。若找不到会 fallback 到 `Z:\90server`。
|
||||
|
||||
### 赞助二维码
|
||||
`sponsor.png` 已打包进 EXE。如需更换,替换源码目录的 `sponsor.png` 后重新打包即可。
|
||||
|
||||
## 四、源码结构
|
||||
|
||||
```
|
||||
launcher/
|
||||
├── server.py # 整合后端(启动器 + GM 工具,纯标准库)
|
||||
├── db.py # SQLite 数据访问层(删角色/删档/疲劳)
|
||||
├── native.py # Native API 客户端(完整 GM 端点)
|
||||
├── index.html # 双视图界面(启动器 + GM 工具)
|
||||
├── style.css # 统一精美深色主题
|
||||
├── app.js # 前端逻辑(启动器 + GM 路由)
|
||||
├── sponsor.png # 赞助二维码
|
||||
└── dist/
|
||||
└── DNF90启动器.exe # 打包产物
|
||||
```
|
||||
|
||||
## 五、重新打包
|
||||
|
||||
```bash
|
||||
cd launcher
|
||||
python -m PyInstaller --onefile --noconsole --name "DNF90启动器" \
|
||||
--add-data "index.html;." --add-data "style.css;." \
|
||||
--add-data "app.js;." --add-data "sponsor.png;." \
|
||||
--add-data "db.py;." --add-data "native.py;." \
|
||||
--collect-all webview --hidden-import clr_loader --hidden-import pythonnet \
|
||||
server.py
|
||||
```
|
||||
|
||||
## 六、重要提示
|
||||
|
||||
1. **删除角色/删档需停服**:先点「关闭服务」再操作,否则数据库被锁定
|
||||
2. **改疲劳需停服**:疲劳值只能 SQLite 离线直改
|
||||
3. **管理员权限**:启动服务端需要管理员权限
|
||||
4. **数据库首次创建**:首次「开始游戏」后,数据库才会在 `Server/runtime/data/dnf90.db` 生成
|
||||
5. **依赖 WebView2**:内嵌窗口基于 Windows 自带的 Edge WebView2 运行时(Win10/11 已预装);若系统缺失会自动降级为打开浏览器
|
||||
+586
@@ -0,0 +1,586 @@
|
||||
# DNF90 单机模拟器 · 整合启动器 + GM 工具 开发文档
|
||||
|
||||
> 本文档用于**换机后快速恢复开发环境**,覆盖环境搭建、项目结构、架构原理、接口清单、打包部署、常见问题等全部关键信息。
|
||||
>
|
||||
> 最后更新:2026-11-03
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [项目概述](#一项目概述)
|
||||
2. [开发环境搭建](#二开发环境搭建)
|
||||
3. [项目结构与关键文件](#三项目结构与关键文件)
|
||||
4. [技术架构与原理](#四技术架构与原理)
|
||||
5. [服务端 API 契约](#五服务端-api-契约)
|
||||
6. [启动器后端 API 清单](#六启动器后端-api-清单)
|
||||
7. [关键业务逻辑与约定](#七关键业务逻辑与约定)
|
||||
8. [打包与部署](#八打包与部署)
|
||||
9. [参考资料位置](#九参考资料位置)
|
||||
10. [常见问题与坑](#十常见问题与坑)
|
||||
|
||||
---
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
一个**单 EXE** 的 DNF90 单机模拟器启动器,内置完整的 GM 管理工具。
|
||||
|
||||
- **技术栈**:Python 3.15 + pywebview 6.x(WebView2 内嵌窗口)+ 纯标准库 HTTP 后端 + 原生 HTML/CSS/JS 前端
|
||||
- **交付物**:`launcher/dist/DNF90启动器.exe`(单文件,约 14.57MB)
|
||||
- **部署位置**:EXE 始终放在 `Z:\90server\` 根目录(服务端目录内)
|
||||
|
||||
### 核心功能
|
||||
|
||||
**启动器主界面(4 按钮)**:
|
||||
- ▶ 开始游戏:configure-client → 启动服务端 → 注册账号 → 自动登录进游戏
|
||||
- ⚙ 打开 GM:切换到内置 GM 管理工具界面
|
||||
- ■ 关闭服务:安全停止服务端
|
||||
- ♥ 赞助我们:弹出赞助二维码
|
||||
- 🔄 一键换装:启动 LoadoutTool/DNFLoadout.exe
|
||||
|
||||
**内置 GM 工具(功能页)**:
|
||||
- 控制台(服务端/数据库/Native API 状态)
|
||||
- 角色管理(等级/金币/职业转职/无限疲劳/删除角色)
|
||||
- 道具发放(装备/道具两分区 + 武器子类型筛选 + 品级筛选)
|
||||
- 邮件系统 / 背包装备 / 任务管理 / 副本控制 / 钱包点券(含黑钻)
|
||||
- 删档管理 / 连接设置(SQLite / Native 数据源切换)
|
||||
|
||||
---
|
||||
|
||||
## 二、开发环境搭建
|
||||
|
||||
### 2.1 Python 环境
|
||||
|
||||
**本机有两个 Python,务必使用 Python 3.15:**
|
||||
|
||||
| Python | 路径 | 用途 |
|
||||
|--------|------|------|
|
||||
| **Python 3.15.2** | `C:\Python315-32\python.exe` | **主开发环境(必须用这个)** |
|
||||
| Python 3.13.14 | `python`(PATH 默认) | WorkBuddy 内置,勿用于本项目 |
|
||||
|
||||
> ⚠️ 所有命令用 `C:\Python315-32\python.exe`,不要用裸 `python`(会指向 3.13)。
|
||||
|
||||
### 2.2 依赖包安装
|
||||
|
||||
```bash
|
||||
# 切到 Python 3.15
|
||||
C:/Python315-32/python.exe -m pip install pywebview pythonnet clr-loader pyinstaller pillow requests fastapi
|
||||
```
|
||||
|
||||
关键依赖及版本:
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| pywebview | 6.2.1 | 内嵌 WebView2 窗口 |
|
||||
| pythonnet | 3.2.1 | .NET CLR 互操作(pywebview 依赖) |
|
||||
| clr-loader | (latest) | pythonnet 的 CLR 加载器 |
|
||||
| pyinstaller | 6.22.3 | 打包单 EXE |
|
||||
| pillow | 13.0.0 | 二维码裁剪处理 |
|
||||
| requests | 2.34.2 | Native API 客户端(native.py 用) |
|
||||
| fastapi | 0.162.0 | 仅 gmtool 旧版用(launcher 主项目不用) |
|
||||
|
||||
### 2.3 pip 国内源(换机后需重新配置)
|
||||
|
||||
`C:\Users\Administrator\AppData\Roaming\pip\pip.ini`:
|
||||
|
||||
```ini
|
||||
[global]
|
||||
index-url = https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
extra-index-url = https://mirrors.aliyun.com/pypi/simple/
|
||||
trusted-host = pypi.tuna.tsinghua.edu.cn
|
||||
```
|
||||
|
||||
### 2.4 换机后检查清单
|
||||
|
||||
- [ ] 安装 Python 3.15(任意位置,更新 `.bat` 和打包命令里的路径)
|
||||
- [ ] 配置 pip 清华源
|
||||
- [ ] 安装上述依赖包
|
||||
- [ ] 确认目标电脑有 **WebView2 运行时**(Win10/11 自带;Win7 需手动装)
|
||||
- [ ] 将整个项目目录拷贝到新机(含 `launcher/` 源码和 `90server/` 运行环境)
|
||||
|
||||
---
|
||||
|
||||
## 三、项目结构与关键文件
|
||||
|
||||
### 3.1 工作区目录(开发源码所在地)
|
||||
|
||||
```
|
||||
C:/Users/Administrator/WorkBuddy/DNF启动器和GM管理工具/
|
||||
├── launcher/ # ★ 主项目(启动器 + GM 工具)
|
||||
│ ├── server.py # ★ 整合后端(启动器动作 + GM HTTP 服务,纯标准库)
|
||||
│ ├── native.py # ★ Native API 客户端(封装服务端全部 GM 端点)
|
||||
│ ├── db.py # ★ SQLite 数据访问层(删角色/删档/疲劳)
|
||||
│ ├── index.html # 双视图界面(启动器 + GM 工具)
|
||||
│ ├── style.css # 深色 DNF 风格主题
|
||||
│ ├── app.js # ★ 前端逻辑(启动器 + GM 路由)
|
||||
│ ├── sponsor.png # 赞助二维码(已裁剪)
|
||||
│ ├── sponsor_full_backup.png# 二维码原图备份
|
||||
│ ├── sponsor_qr.png # 二维码中间产物
|
||||
│ ├── DNF90启动器.spec # ★ PyInstaller 打包配置
|
||||
│ ├── build/ # 打包中间产物(可删)
|
||||
│ └── dist/ # 打包产物 DNF90启动器.exe
|
||||
├── gmtool/ # 旧版 FastAPI GM 工具(已弃用,仅参考)
|
||||
│ ├── main.py
|
||||
│ ├── gm_client.py
|
||||
│ └── static/
|
||||
├── README.md # 项目简介(较简略)
|
||||
├── 开发文档.md # 本文档
|
||||
├── 2026-11-03.md # 当日详细工作日志(历史记录)
|
||||
├── 启动启动器.bat # 源码方式运行启动器
|
||||
├── 启动GM工具.bat # 源码方式运行旧 GM 工具(已弃用)
|
||||
└── .workbuddy/ # WorkBuddy 项目数据(勿删)
|
||||
└── memory/ # 工作记忆(日志)
|
||||
```
|
||||
|
||||
### 3.2 运行环境(服务端所在,非源码)
|
||||
|
||||
```
|
||||
Z:/90server/ # ★ 可运行模拟器 + 启动器部署位置
|
||||
├── DNF90启动器.exe # ★ 打包后的启动器(放这里运行)
|
||||
├── Server/ # 服务端
|
||||
│ └── runtime/
|
||||
│ ├── bin/DNF90Control.exe # 服务端控制器(start/stop/configure-client/launch-client/account)
|
||||
│ ├── config/instance.json # Native API token 配置
|
||||
│ └── data/dnf90.db # SQLite 数据库(首次启动生成)
|
||||
├── Client/ # 游戏客户端
|
||||
└── LoadoutTool/DNFLoadout.exe # 一键换装工具
|
||||
```
|
||||
|
||||
### 3.3 服务端源码(Go,仅供参考 API 契约)
|
||||
|
||||
```
|
||||
C:/Users/Administrator/Downloads/90轮回源码/90轮回源码/go-server/
|
||||
└── internal/
|
||||
├── app/dnf90/
|
||||
│ ├── gm_tool_admin.go # ★ GM 工具 HTTP 路由(GET 查询类)
|
||||
│ └── gm_admin.go # ★ 黑钻等写操作
|
||||
├── services/dnfbridge/
|
||||
│ ├── gm_tool.go # GM 业务逻辑(职业/任务结构定义)
|
||||
│ ├── gm_tool_extended.go # 角色详情、分类逻辑
|
||||
│ ├── gm_tool_items_extra.go# 道具发放 grant-options
|
||||
│ ├── gm_tool_dungeon.go # 副本难度
|
||||
│ └── gm_tool_item_customization.go
|
||||
└── modules/dnf/
|
||||
├── itemquality/quality.go # 品级定义
|
||||
└── profession/profile.go # 职业 Option 结构
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、技术架构与原理
|
||||
|
||||
### 4.1 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ DNF90启动器.exe(单文件,pywebview WebView2) │
|
||||
│ │
|
||||
│ ┌──────────────┐ HTTP(127.0.0.1:19527) │
|
||||
│ │ 前端 app.js │ ◄──────────────────────────► │
|
||||
│ │ index.html │ │
|
||||
│ └──────────────┘ │
|
||||
│ ▲ │
|
||||
│ │ api()/post() │
|
||||
│ ┌───────┴────────────────────────────────────┐ │
|
||||
│ │ server.py(纯标准库 BaseHTTPRequestHandler)│ │
|
||||
│ │ ├─ 启动器动作 flow_*() │ │
|
||||
│ │ ├─ GM HTTP 路由 _api_*() │ │
|
||||
│ │ ├─ SQLite 直读(DB, db.py) │ │
|
||||
│ │ └─ Native 客户端(NATIVE, native.py) │ │
|
||||
│ └───────┬──────────────────┬─────────────────┘ │
|
||||
└──────────┼──────────────────┼───────────────────┘
|
||||
│ SQLite 直读 │ HTTP(127.0.0.1:18111, X-Admin-Token)
|
||||
▼ ▼
|
||||
Server/runtime/data/dnf90.db DNF90Control.exe(运行中的服务端)
|
||||
```
|
||||
|
||||
### 4.2 双数据源机制(核心设计)
|
||||
|
||||
GM 工具支持两种数据源,通过「连接设置」页切换:
|
||||
|
||||
| 数据源 | 条件 | 特点 |
|
||||
|--------|------|------|
|
||||
| **Native API** | 服务端运行时 | 实时操作,`127.0.0.1:18111` + `X-Admin-Token` |
|
||||
| **SQLite 直读** | 服务端停止时 | 直接读写 `dnf90.db`,可删角色/删档/改疲劳 |
|
||||
|
||||
- 判断逻辑:`_use_sqlite()` 返回 `True` 时走 SQLite,否则走 Native
|
||||
- **删角色/删档/改疲劳必须停服**(服务端运行时数据库被锁)
|
||||
|
||||
### 4.3 路径自动识别(重要)
|
||||
|
||||
`SERVER_ROOT` 按以下优先级自动定位(`server.py` 模块加载时执行一次):
|
||||
|
||||
1. 环境变量 `DNF90_SERVER_ROOT`(手动覆盖)
|
||||
2. EXE 自身所在目录(若其下存在 `Server/`)—— **正常场景命中**
|
||||
3. 向上逐级查找含 `Server/` 的目录(最多 4 层)
|
||||
4. 兜底 EXE 所在目录 `BASE_DIR`
|
||||
|
||||
派生路径(全部基于 SERVER_ROOT):
|
||||
|
||||
```python
|
||||
CONTROL = SERVER_ROOT + r"\Server\runtime\bin\DNF90Control.exe"
|
||||
CLIENT_DIR = SERVER_ROOT + r"\Client"
|
||||
LOADOUT_EXE = SERVER_ROOT + r"\LoadoutTool\DNFLoadout.exe"
|
||||
INSTANCE_PATH = SERVER_ROOT + r"\Server\runtime\config\instance.json"
|
||||
DB_PATH = SERVER_ROOT + r"\Server\runtime\data\dnf90.db"
|
||||
```
|
||||
|
||||
**关键约定**:启动器 EXE 永远放在 `90server` 根目录,路径解析完全锚定 EXE 自身位置,不依赖任何写死盘符。
|
||||
|
||||
### 4.4 Native API 认证机制
|
||||
|
||||
- 服务端 GM API 监听 `127.0.0.1:18111`
|
||||
- 认证头:`X-Admin-Token`
|
||||
- 两套路由:`/dnf/gm/*`(新版)、`/local/dnf-gm/*`(兼容版)
|
||||
- token 通过 `AUTO_GENERATE` 机制生成,存于 `instance.json`
|
||||
- 危险写操作需 `X-Admin-Confirm` + `X-Admin-Idempotency-Key` 头(`_post` 默认 `confirm=True`)
|
||||
|
||||
---
|
||||
|
||||
## 五、服务端 API 契约
|
||||
|
||||
> 服务端是 Go 编写的二进制,源码在 `90轮回源码/go-server/`。以下契约是从源码反推,供 native.py 和 server.py 对接。
|
||||
|
||||
### 5.1 职业转职
|
||||
|
||||
```
|
||||
GET /dnf/gm/characters/{id}/career → GMCareerState
|
||||
POST /dnf/gm/characters/{id}/career {job, first, awakening}
|
||||
```
|
||||
|
||||
`GMCareerState` 字段:`job`、`first_grow`(分支,0=未转职)、`awakening`、`profession_name`、`options[]`、`skills_reset`、`quest_reset_ids`
|
||||
|
||||
`options` 层级结构:
|
||||
```
|
||||
Option { job, name, branches[] }
|
||||
BranchOption { value, name, awakenings[] }
|
||||
AwakeningOption { value, name }
|
||||
```
|
||||
|
||||
### 5.2 任务查询
|
||||
|
||||
```
|
||||
GET /dnf/gm/characters/{id}/quests → {character, active[], completed[]}
|
||||
GET /dnf/gm/quests/search?query=... → GMQuestSummary[]
|
||||
GET /dnf/gm/quests/{id}/detail?quest_id=... → 详情
|
||||
POST(任务操作 accept/ready/give-up/complete/uncomplete)
|
||||
```
|
||||
|
||||
`GMQuestSummary` 字段:`quest_id`、`name`、`grade`、`type`、`level_min`、`level_max`、`status`、`active`、`completed`、`acceptable`
|
||||
|
||||
### 5.3 爆率 / 副本 / 黑钻
|
||||
|
||||
```
|
||||
# 爆率(游戏玩法设置)
|
||||
GET /dnf/gm/gameplay → GMGameplaySettings
|
||||
POST /dnf/gm/gameplay/hell-drop-rate {percent}
|
||||
POST /dnf/gm/gameplay/epic-potion-test-multiplier {percent}
|
||||
|
||||
# 副本
|
||||
GET /dnf/gm/characters/{id}/dungeons → {difficulty_dungeons, difficulty_dungeons_unlocked, hell_areas, hell_permission_quests, hell_permission_quests_cleared}
|
||||
POST /dnf/gm/characters/{id}/dungeons/unlock-difficulties
|
||||
POST /dnf/gm/characters/{id}/dungeons/unlock-hell
|
||||
|
||||
# 黑钻
|
||||
GET /local/dnf-gm/character/black-diamond
|
||||
POST /local/dnf-gm/character/black-diamond {character_id, days}
|
||||
```
|
||||
|
||||
`GMGameplaySettings` 字段:`hell_epic_drop_rate_percent`(当前爆率%)、`minimum_percent`、`maximum_percent`(上限 15000%)、`epic_drop_rate_potion_test_multiplier_percent`
|
||||
|
||||
黑钻返回:`active`、`expires_at`(时间戳,0=未开启)、`remaining_days`、`fatigue_limit`、`black_diamond_fatigue_limit`
|
||||
|
||||
### 5.4 品级(rarity)枚举
|
||||
|
||||
**rarity 0~6 共 7 档,权威映射:**
|
||||
|
||||
| 值 | 品级 | 示例装备 |
|
||||
|----|------|---------|
|
||||
| 0 | 普通 | 粗布衬衫 |
|
||||
| 1 | 高级 | 棉布衬衫 |
|
||||
| 2 | 稀有 | 荣誉者长袍 |
|
||||
| 3 | 神器 | (旧) 雕翎长袍 |
|
||||
| 4 | 史诗 | (旧) 银月少女T恤衫 |
|
||||
| 5 | 传说 | 贤者的智慧之衣 |
|
||||
| 6 | 神话 | 首领萨斯的阴影长袍 |
|
||||
|
||||
> ⚠️ **注意**:4=史诗、5=传说、6=神话(顺序易记错)。「勇者」不是独立品级档,是异界装备称呼,实际归类于神器(3)。
|
||||
> 物品搜索的 `rarity` 参数是**精确匹配**(`item.Rarity != rarity` 过滤),范围 -1~6,-1=不限。
|
||||
|
||||
### 5.5 品级机制(装备发放时)
|
||||
|
||||
- `equipment_quality` 仅接受 `default` / `random` / `top` 三个值
|
||||
- 品级本质是 `quality_seed` 数值:1=最下级,999999998(TopSeed)=最上级
|
||||
- **直接进背包路径(grant-options)忽略品级参数**,只有**邮件(mail)路径**品级才真正生效
|
||||
- 服务端不支持「最下级」,只能最上级(top)或随机(random)
|
||||
|
||||
### 5.6 装备分类与武器子类型
|
||||
|
||||
- 服务端 `kind` 值:`"equipment"`(装备)、`"stackable"`(道具)
|
||||
- 武器子类型通过 `q=/weapon/<sub>/` 匹配 PVFPath(如 `equipment/character/swordman/weapon/katana/`)
|
||||
|
||||
---
|
||||
|
||||
## 六、启动器后端 API 清单
|
||||
|
||||
> `server.py` 提供,前端通过 `api()`(GET)和 `post()`(POST)调用。端口 `19527`。
|
||||
|
||||
### 6.1 GET 路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `/api/status` | 启动器状态(含 server_root/client_dir/各文件是否存在/native_available) |
|
||||
| `/api/logs` | 运行日志 |
|
||||
| `/api/gm/health` | GM 健康检查 |
|
||||
| `/api/accounts-config` | 账号配置 |
|
||||
| `/api/accounts` | 账号列表 |
|
||||
| `/api/characters` | 角色列表(支持 ?account= 筛选) |
|
||||
| `/api/character` | 角色详情 |
|
||||
| `/api/character/inventory` | 背包 |
|
||||
| `/api/character/equipment` | 装备 |
|
||||
| `/api/character/mailbox` | 邮箱 |
|
||||
| `/api/character/fatigue` | 疲劳(mode=query) |
|
||||
| `/api/character/career` | 职业信息(转职查询) |
|
||||
| `/api/character/dungeons` | 副本状态 |
|
||||
| `/api/character/black-diamond` | 黑钻状态 |
|
||||
| `/api/gameplay` | 爆率设置查询 |
|
||||
| `/api/quests` | 任务概览 |
|
||||
| `/api/quests/search` | 任务搜索 |
|
||||
| `/api/quests/detail` | 任务详情 |
|
||||
| `/api/wipe/preview` | 删档预览 |
|
||||
| `/api/items/categories` | 道具分类 |
|
||||
| `/api/item/search` | 道具搜索 |
|
||||
|
||||
### 6.2 POST 路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `/api/action` | 启动器动作(start/stop/gm/loadout) |
|
||||
| `/api/accounts-config` | 账号配置写操作 |
|
||||
| `/api/source` | 切换数据源(SQLite/Native) |
|
||||
| `/api/character/save` | 保存角色 |
|
||||
| `/api/character/stat` | 改属性 |
|
||||
| `/api/character/fatigue` | 改疲劳 |
|
||||
| `/api/character/gold` | 改金币 |
|
||||
| `/api/character/level` | 改等级 |
|
||||
| `/api/character/skill-points` | 改技能点 |
|
||||
| `/api/character/career` | 应用转职 |
|
||||
| `/api/dungeon/unlock` | 解锁副本难度 |
|
||||
| `/api/black-diamond/activate` | 开启黑钻 |
|
||||
| `/api/gameplay/hell-drop-rate` | 设置爆率 |
|
||||
| `/api/gameplay/epic-potion-multiplier` | 设置药剂倍率 |
|
||||
| `/api/quests/action` | 任务操作 |
|
||||
| `/api/character/grant` | 发放道具 |
|
||||
| `/api/character/delete` | 删除角色 |
|
||||
| `/api/account/delete` | 删除账号 |
|
||||
| `/api/item/search` | 道具搜索(POST body) |
|
||||
| `/api/item/grant` | 发放道具 |
|
||||
| `/api/item/grant-options` | 发放道具(高级选项) |
|
||||
| `/api/item/mail` | 邮件发放道具 |
|
||||
| `/api/mail/send` | 发送邮件 |
|
||||
| `/api/mailbox/clear` | 清空邮箱 |
|
||||
| `/api/inventory/clear` | 清空背包 |
|
||||
| `/api/inventory/delete` | 删除背包物品 |
|
||||
| `/api/wallet/query` | 钱包查询 |
|
||||
| `/api/cera` | 点券充值 |
|
||||
|
||||
### 6.3 native.py 方法清单(服务端 GM 端点封装)
|
||||
|
||||
`native.py` 是服务端 GM API 的完整 Python 客户端封装,方法列表:
|
||||
|
||||
- **账号**:`list_accounts` / `get_account` / `set_account_value`
|
||||
- **角色**:`list_characters` / `get_character` / `get_character_detail`
|
||||
- **物品/背包**:`get_inventory` / `get_equipment` / `get_mailbox` / `get_item_detail` / `get_item_mail_options` / `get_item_categories` / `search_items` / `search_avatar_sets`
|
||||
- **道具发放**:`grant_item` / `grant_item_options` / `grant_item_with_emblems` / `send_item_mail`
|
||||
- **删除**:`delete_item` / `delete_inventory_item` / `clear_inventory` / `unequip_equipment` / `delete_equipment`
|
||||
- **属性修改**:`set_gold` / `set_level` / `set_skill_bonus` / `set_career` / `get_career` / `set_cera` / `adjust_revive_coin`
|
||||
- **钱包**:`query_wallet` / `update_wallet` / `set_account_cube` / `get_account_cubes`
|
||||
- **邮件**:`send_system_mail` / `clear_mailbox` / `delete_mailbox_mails`
|
||||
- **任务**:`get_quest_overview` / `search_quests` / `get_quest_detail` / `accept_quest` / `mark_quest_ready` / `give_up_quest` / `complete_quest` / `uncomplete_quest` / `complete_quest_batch`
|
||||
- **副本**:`get_dungeons` / `unlock_dungeon_difficulties` / `unlock_hell_challenges`
|
||||
- **黑钻**:`get_black_diamond` / `activate_black_diamond`
|
||||
- **爆率**:`get_gameplay_settings` / `set_hell_drop_rate` / `set_epic_potion_multiplier`
|
||||
- **宠物**:`update_pet`
|
||||
|
||||
---
|
||||
|
||||
## 七、关键业务逻辑与约定
|
||||
|
||||
### 7.1 疲劳值机制
|
||||
|
||||
- 角色 `stats` 里的字段:`fatigue`(当前疲劳)、`fatigue_limit`(上限,默认156/黑钻188)、`fatigue_display_update`(已用)
|
||||
- 每天 6 点重置回上限
|
||||
- **无限疲劳** = 把 `fatigue` 和 `fatigue_limit` 都设为超大值(999999)
|
||||
- 服务端**无独立疲劳查询 GM 接口**,从 `get_character_detail()` 的 `stats` 里读
|
||||
|
||||
### 7.2 账号配置
|
||||
|
||||
- `accounts.json` 存 EXE 同目录(`BASE_DIR`)
|
||||
- `load_accounts` / `save_accounts` / `get_default_account`
|
||||
- 默认账号 `dnfplayer` 不可删除
|
||||
|
||||
### 7.3 窗口与 UI 约定
|
||||
|
||||
- **GM 窗口打开直接最大化**,但**不用 `maximize()`**(会遮挡任务栏)
|
||||
- 用 `_get_work_area()`(`ctypes` 调 `SystemParametersInfoW(SPI_GETWORKAREA=0x0030)`)获取扣除任务栏的工作区,再 `move()` + `resize()`
|
||||
- 主窗口和 GM 窗口都屏幕居中(`webview.screens[0].width/height` 计算 x/y)
|
||||
- 深色 DNF 风格 + 紫青渐变 + 毛玻璃质感
|
||||
|
||||
### 7.4 危险操作约定
|
||||
|
||||
- 删除角色/删档/改疲劳:**需先停服**(SQLite 直读模式)
|
||||
- 危险操作二次确认:输入确认词(如 `DELETE`、`转职`)
|
||||
|
||||
### 7.5 进程回收
|
||||
|
||||
- 关闭后需 `os._exit(0)` 强制退出 + `_kill_webview2_children()`(清理 WebView2 子进程)
|
||||
- 打包前需先 `taskkill /F /IM "DNF90启动器.exe"`(否则 dist exe 被锁定)
|
||||
|
||||
---
|
||||
|
||||
## 八、打包与部署
|
||||
|
||||
### 8.1 打包命令
|
||||
|
||||
用 spec 文件打包(推荐,配置已固化):
|
||||
|
||||
```bash
|
||||
cd "C:/Users/Administrator/WorkBuddy/DNF启动器和GM管理工具/launcher"
|
||||
|
||||
# 先杀掉残留进程(避免文件锁定)
|
||||
taskkill /F /IM "DNF90启动器.exe"
|
||||
|
||||
# 打包
|
||||
C:/Python315-32/python.exe -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
|
||||
```
|
||||
|
||||
产物:`launcher/dist/DNF90启动器.exe`
|
||||
|
||||
### 8.2 拷贝到运行目录
|
||||
|
||||
```bash
|
||||
cp -f "C:/Users/Administrator/WorkBuddy/DNF启动器和GM管理工具/launcher/dist/DNF90启动器.exe" "Z:/90server/DNF90启动器.exe"
|
||||
```
|
||||
|
||||
### 8.3 spec 文件要点
|
||||
|
||||
`DNF90启动器.spec` 关键配置:
|
||||
- `datas`:打包 `index.html`、`style.css`、`app.js`、`sponsor.png`、`db.py`、`native.py`
|
||||
- `hiddenimports`:`clr_loader`、`pythonnet`
|
||||
- `collect_all('webview')`:pywebview 全部资源
|
||||
- `console=False`:windowed 模式(无控制台)
|
||||
- `upx=True`:UPX 压缩
|
||||
- `name='DNF90启动器'`
|
||||
|
||||
> 如果不用 spec,等价命令行(注意 Windows 分隔符是 `;`)见 `README.md`。
|
||||
|
||||
### 8.4 部署要求
|
||||
|
||||
- EXE 放在 `90server` 根目录
|
||||
- 目标电脑需 WebView2 运行时(Win10/11 自带)
|
||||
- 首次运行请求管理员权限(UAC,启动服务端需要)
|
||||
- 无需安装 Python 或其他依赖(单文件已全打包)
|
||||
|
||||
### 8.5 验证方法
|
||||
|
||||
打包后在同批命令内验证(后台进程在命令结束时会被回收):
|
||||
|
||||
```bash
|
||||
cd "Z:/90server" && ./DNF90启动器.exe > /dev/null 2>&1 &
|
||||
sleep 8
|
||||
curl -s --noproxy "*" "http://127.0.0.1:19527/api/status"
|
||||
```
|
||||
|
||||
预期返回:`server_root=Z:\90server`、`db_exists=true`、`native_available=true`、`control_exists=true`、`server_dir_exists=true`、`client_dir_exists=true`。
|
||||
|
||||
---
|
||||
|
||||
## 九、参考资料位置
|
||||
|
||||
| 资料 | 位置 |
|
||||
|------|------|
|
||||
| 服务端源码(Go) | `C:/Users/Administrator/Downloads/90轮回源码/90轮回源码/go-server/` |
|
||||
| 旧 GM 工具源码备份 | WorkBuddy 资料库节点 `sfEHKi5MJIh0D9lSykGVoT`(DNF90工具与资料备份 web 页) |
|
||||
| 旧 GM 工具(运行版) | `Z:/90server/gm_tool/`(已打包 DNF90GM工具.exe) |
|
||||
| 可运行模拟器 | `Z:/90server/` |
|
||||
| 当日工作日志 | `C:/Users/Administrator/WorkBuddy/DNF启动器和GM管理工具/2026-11-03.md` |
|
||||
|
||||
### 读取资料库参考资料的方法
|
||||
|
||||
```bash
|
||||
# 查询节点信息
|
||||
python3 "C:/Users/Administrator/AppData/Local/Programs/WorkBuddy/resources/app.asar.unpacked/resources/plugins/workbuddy-builtin/skills/library/space_api.py" space.workspace.node-info --node-id sfEHKi5MJIh0D9lSykGVoT
|
||||
|
||||
# 下载 artifacts
|
||||
python3 ".../library/page/download_page_artifacts.py" --node-id "sfEHKi5MJIh0D9lSykGVoT" --out-dir "/tmp/gmref" --path "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、常见问题与坑
|
||||
|
||||
### 10.1 打包相关
|
||||
|
||||
| 问题 | 解决 |
|
||||
|------|------|
|
||||
| 打包报 OSError safe-delete 失败 | dist 的 exe 被占用,先 `taskkill /F /IM "DNF90启动器.exe"` |
|
||||
| `--add-data` 分隔符 | Windows 用 `;`(不是 `:`) |
|
||||
| 打包后窗口变浏览器 | pywebview `transparent=True` 在 Windows 不支持,会导致降级 |
|
||||
|
||||
### 10.2 pywebview 相关
|
||||
|
||||
| 问题 | 解决 |
|
||||
|------|------|
|
||||
| 无边框窗口 maximize 遮挡任务栏 | 用 `_get_work_area()` 获取工作区再 resize,不要用 `maximize()` |
|
||||
| 关闭后 WebView2 子进程残留 | `os._exit(0)` + `_kill_webview2_children()` |
|
||||
| `create_window()` 报错 | 必须在 `webview.start()` 之前调用 |
|
||||
|
||||
### 10.3 后端参数解析
|
||||
|
||||
**重要教训**:`_api_character_fatigue` 曾有个 bug——用 `if "id" in data_or_qs` 判断,导致 GET 请求的 `parse_qs` 结果(值是 list)被当成标量,`char_id` 变成 `['1']` 列表,报「角色 ['1'] 不存在」。
|
||||
|
||||
**正确写法**:GET 请求统一用 `qs.get("id", [""])[0]` 取第一个值;POST body 才直接 `data.get("id")`。
|
||||
|
||||
### 10.4 数据源相关
|
||||
|
||||
| 问题 | 解决 |
|
||||
|------|------|
|
||||
| 删角色/删档报数据库锁定 | 需先停服(点「关闭服务」) |
|
||||
| 疲劳值读不出 | 服务端无独立疲劳接口,用 `get_character_detail()` 的 `stats.fatigue` |
|
||||
| 道具搜索 404 | 前端用 `post('/api/item/search')` 但后端原来只有 GET 路由,需补 POST 路由 |
|
||||
|
||||
### 10.5 品级相关
|
||||
|
||||
- rarity 精确匹配(`-1` = 不限),枚举 0~6:普通/高级/稀有/神器/史诗/传说/神话
|
||||
- 品级 `equipment_quality` 只接受 default/random/top
|
||||
- 品级只有邮件路径生效,直接进背包路径忽略品级
|
||||
|
||||
### 10.6 测试相关
|
||||
|
||||
- **后台进程在命令结束时会被回收**(非交互模式),验证必须把启动和测试放同一批命令内
|
||||
- 服务端 `DNF90Server.exe` 是常驻进程,测试时**不要**误杀
|
||||
- 默认测试角色:`character_id=1`,名「测试」,职业神枪手,1级
|
||||
|
||||
---
|
||||
|
||||
## 附:快速上手速查
|
||||
|
||||
```bash
|
||||
# 1. 源码方式运行启动器(开发调试)
|
||||
cd "C:/Users/Administrator/WorkBuddy/DNF启动器和GM管理工具/launcher"
|
||||
C:/Python315-32/python.exe server.py
|
||||
|
||||
# 2. 校验语法
|
||||
C:/Python315-32/python.exe -c "import ast; ast.parse(open('server.py',encoding='utf-8').read())"
|
||||
node --check app.js
|
||||
|
||||
# 3. 打包 + 拷贝(换机后重复此流程)
|
||||
taskkill /F /IM "DNF90启动器.exe"
|
||||
cd "C:/Users/Administrator/WorkBuddy/DNF启动器和GM管理工具/launcher"
|
||||
C:/Python315-32/python.exe -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
|
||||
cp -f dist/DNF90启动器.exe "Z:/90server/DNF90启动器.exe"
|
||||
|
||||
# 4. 用默认测试角色验证(同批命令内)
|
||||
cd "Z:/90server" && ./DNF90启动器.exe > /dev/null 2>&1 &
|
||||
sleep 8 && curl -s --noproxy "*" "http://127.0.0.1:19527/api/status"
|
||||
```
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# DNF90 服务端开发文档
|
||||
|
||||
> 覆盖服务端(Go)的架构、GM 接口契约、编译部署、以及本次新增的拍卖行 / 背包管理改动。
|
||||
>
|
||||
> 最后更新:2026-11-03
|
||||
|
||||
---
|
||||
|
||||
## 一、架构概览
|
||||
|
||||
服务端是 Go 单模块(`module longheng.io/server`),采用分层结构:
|
||||
|
||||
```
|
||||
server/
|
||||
├── cmd/
|
||||
│ ├── server/dnf90/ 产品入口(主服务进程,产出 DNF90Server.exe)
|
||||
│ ├── server/control/ 部署控制器(DNF90Control.exe,start/stop/configure)
|
||||
│ ├── server/doctor/ 部署预检(DNF90Doctor.exe)
|
||||
│ ├── companionpvf/ PVF 伴随进程
|
||||
│ └── devtools/ scratch/ 开发调试工具(非产品)
|
||||
├── internal/
|
||||
│ ├── app/dnf90/ 单一组合根(路由注册、依赖装配)
|
||||
│ ├── services/dnfbridge/ 客户端传输 + GM bridge 层(本次改动重点)
|
||||
│ ├── modules/dnf/ DNF 领域模块(auction、dungeon、character 等)
|
||||
│ ├── platform/ 框架层(含 platform/pvf PVF 解析)
|
||||
│ ├── services/logic/dnf/ MySQL 仓储装配
|
||||
│ └── ...
|
||||
└── pkg/ 对外包
|
||||
```
|
||||
|
||||
关键入口:
|
||||
- 主服务:`cmd/server/dnf90/main.go`
|
||||
- GM 路由:`internal/app/dnf90/gm_tool_compat.go`(兼容层,`/local/dnf-gm/*`)
|
||||
- GM bridge:`internal/services/dnfbridge/gm_*.go`
|
||||
|
||||
---
|
||||
|
||||
## 二、编译与部署
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Go ≥ 1.26**(`go.mod` 声明 `go 1.26.5`)
|
||||
- 依赖走国内镜像(加速):`GOPROXY=https://goproxy.cn,direct`、`GOSUMDB=sum.golang.google.cn`
|
||||
|
||||
### 编译命令
|
||||
|
||||
```bash
|
||||
cd server
|
||||
# 主服务端
|
||||
go build -buildvcs=false -trimpath -o DNF90Server.exe ./cmd/server/dnf90
|
||||
# 部署控制器
|
||||
go build -buildvcs=false -trimpath -o DNF90Control.exe ./cmd/server/control
|
||||
# 部署预检
|
||||
go build -buildvcs=false -trimpath -o DNF90Doctor.exe ./cmd/server/doctor
|
||||
```
|
||||
|
||||
编译产物部署到 `90server/Server/runtime/bin/`。
|
||||
|
||||
> 若 `go build` 报 "package unsafe is not in std",说明本地 Go SDK 的 src 不完整,
|
||||
> 需改用完整 toolchain(见 `docs/开发文档.md` 的「Go 环境问题」章节)。
|
||||
|
||||
---
|
||||
|
||||
## 三、GM 接口契约(`/local/dnf-gm/*`)
|
||||
|
||||
GM 接口由 `internal/app/dnf90/gm_tool_compat.go` 注册,bridge 层实现在 `internal/services/dnfbridge/`。
|
||||
|
||||
### 认证
|
||||
|
||||
所有接口走 admin HTTP 端口(默认 `127.0.0.1:18111`),请求头带 `X-Admin-Token`(token 来自 `instance.json` 的 `adminToken`)。
|
||||
|
||||
### 返回结构
|
||||
|
||||
统一约定:`{"ok": true, "data": <结果>}`;错误时 `{"error": "<msg>"}`。
|
||||
|
||||
### 拍卖行接口(本次新增)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/local/dnf-gm/auction/listings` | 列出所有挂单,返回 `{listings, escrow_gold}` |
|
||||
| POST | `/local/dnf-gm/auction/list` | 上架(支持自动补足) |
|
||||
| POST | `/local/dnf-gm/auction/cancel` | 下架 |
|
||||
|
||||
上架请求体(`GMAuctionListRequest`):
|
||||
|
||||
```json
|
||||
{
|
||||
"seller_id": "角色ID",
|
||||
"item_id": 10001,
|
||||
"count": 1,
|
||||
"unit_price": 10000,
|
||||
"start_price": -1, // 每件起拍价,-1=不设竞拍
|
||||
"buyout_price": -1, // 每件一口价,-1=不设一口价
|
||||
"duration_hours": 24, // 24 或 48
|
||||
"grant_if_missing": true // 背包不足时自动凭空发放差额再上架
|
||||
}
|
||||
```
|
||||
|
||||
### 背包管理接口(本次新增)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/local/dnf-gm/inventory/list?character_id=xxx` | 列角色背包 + 穿戴装备,每行 `{collection, entry_key, item_id, count, bind, item_name}` |
|
||||
| POST | `/local/dnf-gm/item/delete` | 删除指定背包行(`entry_key` + `collection`,支持 slots/wear) |
|
||||
|
||||
---
|
||||
|
||||
## 四、本次关键改动说明
|
||||
|
||||
### 1. GM 拍卖行(线上上架通道)
|
||||
|
||||
- **新增** `internal/services/dnfbridge/gm_auction.go`:
|
||||
- `GMAuctionListings` / `GMAuctionList` / `GMAuctionCancel` 三个方法。
|
||||
- 直接调用 `dnfauction.Owner` 的纯数据层 `Register`/`Cancel`/`LoadListing`,**不依赖玩家 session**。
|
||||
- **新增** `GMInventoryList` 方法(列背包全部道具 + 穿戴装备)。
|
||||
|
||||
### 2. 上架免押金
|
||||
|
||||
`gm_auction.go` 的 `GMAuctionList` 中,`Register` 的 `Deposit` 设为 `0`(原为 `dnfauction.ListingDepositGold = 10000`)。
|
||||
服务端 `owner.go` 里是 `if cmd.Deposit > 0` 才扣款,因此 `Deposit=0` 直接跳过押金逻辑。
|
||||
|
||||
### 3. 自动补足上架(发放式)
|
||||
|
||||
`GMAuctionList` 支持 `grant_if_missing`:卖家背包物品不足时,先调 `GMGrantItem`(`dnfdungeon.Owner.GrantPickupItem`)凭空发放差额到背包,再 `Register` 上架。实现「像发放道具那样直接上架」。
|
||||
|
||||
### 4. 路由层
|
||||
|
||||
`gm_tool_compat.go` 新增常量与路由:
|
||||
- `gmAuctionListingsPath` / `gmAuctionListPath` / `gmAuctionCancelPath`
|
||||
- `gmInventoryListPath`
|
||||
|
||||
---
|
||||
|
||||
## 五、GM bridge 层约定
|
||||
|
||||
- GM 方法都在 `internal/services/dnfbridge/` 包,方法签名形如 `func (s *Service) GMXxx(ctx, req) (resp, error)`。
|
||||
- 数据访问用 `s.repositoryGroup()`(返回 `dnfrepo.Group`,含 `Inventory`、`Equipment`、`Character` 等仓储)。
|
||||
- 物品名解析用 `s.currentPVFItemCatalog()`(`dnfpvf` 目录),`ResolveItem(itemID)` 得到 `definition.Name`。
|
||||
|
||||
---
|
||||
|
||||
## 六、常见坑
|
||||
|
||||
1. **`go 1.26.5` 版本声明**:装 ≥1.26 即可,`GOTOOLCHAIN=auto` 会自动下载所需 toolchain。
|
||||
2. **GM 接口路由**:`gm_tool_compat.go` 里 `gmToolPost` 包装 POST 接口,GET 接口用 `HandleFunc` + `writeGMJSON`。
|
||||
3. **服务端 `Register` 的校验**:`buyout_price` 必须等于 `unit_price`(或"不设一口价"),`start_price` 在 `[0, unit_price]` 或"不设竞拍",时长只能是 24/48。
|
||||
4. **对外发布**:不要附带本机测试用的深渊掉落修改;远程换装工具不要公开 Admin 端口。
|
||||
@@ -0,0 +1,86 @@
|
||||
# DNF90 GM 管理工具
|
||||
|
||||
一个全新的、页面精美的 DNF90(九零轮回)单机私服 GM 管理工具。
|
||||
|
||||
基于对旧源码(`90轮回源码` 与 `DNF90工具与资料备份`)的深入分析,完整还原了服务端 GM API 协议,并重新实现了一套现代化、美观的管理界面。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎨 **精美深色主题**:DNF 风格渐变 + 毛玻璃质感,响应式布局
|
||||
- 👥 **角色管理**:按账号/角色名/用户名查询角色,编辑等级、金币、技能点
|
||||
- 🎁 **道具发放**:搜索道具并发放、查看道具详情
|
||||
- ✉️ **邮件系统**:系统邮件、角色邮件、附件道具与金币
|
||||
- 🎒 **背包装备**:查看背包、删除道具、清空背包、装备管理
|
||||
- 📜 **任务管理**:查询任务、完成/放弃/撤销任务
|
||||
- ⚔️ **副本控制**:副本状态监控、解锁难度/深渊、安全回城
|
||||
- 💰 **钱包点券**:钱包查询、黑钻开通
|
||||
- 🎮 **玩法配置**:深渊掉率、史诗药剂倍率
|
||||
- 📡 **频道管理**:频道列表、热重载
|
||||
- 🔍 **自动发现**:自动探测本地服务端地址与 Admin Token
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
浏览器前端 (HTML/CSS/JS 单页应用)
|
||||
↓ fetch
|
||||
FastAPI 后端 (Python) ← 端口 9527,CORS 代理
|
||||
↓ HTTP + X-Admin-Token
|
||||
DNF90Server 本地 GM API ← 端口 18111
|
||||
```
|
||||
|
||||
- **后端**:Python 3.15 + FastAPI + requests
|
||||
- **前端**:纯原生 HTML/CSS/JS,无框架依赖
|
||||
- **API 协议**:完整对接服务端 `/dnf/gm/*` 与 `/local/dnf-gm/*` 两套路由
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install fastapi uvicorn requests
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
python gmtool/main.py
|
||||
```
|
||||
|
||||
或双击 `启动GM工具.bat`。
|
||||
|
||||
### 3. 访问
|
||||
|
||||
浏览器打开 http://127.0.0.1:9527
|
||||
|
||||
## 连接服务端
|
||||
|
||||
GM 工具需要连接本地运行的 DNF90 服务端(`DNF90Server.exe`):
|
||||
|
||||
1. **自动发现**:点击顶栏「🔍 自动发现服务端」,工具会自动探测端口与 Token
|
||||
2. **手动连接**:在「连接设置」页面填写主机、端口、Admin Token
|
||||
|
||||
默认配置:
|
||||
- 主机:`127.0.0.1`
|
||||
- 端口:`18111`
|
||||
- Token:`local-admin-token`(或服务端 `instance.json` 中自动生成的 Token)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
gmtool/
|
||||
├── main.py # FastAPI 后端入口
|
||||
├── gm_client.py # GM API 客户端封装(含自动发现)
|
||||
└── static/
|
||||
├── index.html # 页面骨架
|
||||
├── css/style.css # 样式
|
||||
└── js/
|
||||
├── api.js # API 客户端与工具函数
|
||||
├── pages.js # 各功能页面
|
||||
└── app.js # 应用初始化与路由
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 工具通过本地回环地址通信,不暴露到公网
|
||||
- 危险操作(清空背包、删除道具等)均有二次确认
|
||||
- 服务端需在 `local` 环境运行,`/local/*` 兼容路由才可用
|
||||
@@ -0,0 +1,413 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DNF90 GM 工具 —— 服务端 GM API 客户端封装。
|
||||
|
||||
负责:
|
||||
1. 自动发现本地服务端(DNF90Server.exe)的 admin 端口与 token;
|
||||
2. 封装全部 /dnf/gm/* 与 /local/* 接口为统一方法;
|
||||
3. 处理 X-Admin-Token / X-Admin-Confirm / X-Admin-Idempotency-Key 等管理头。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_ADMIN_PORT = 18111
|
||||
DEFAULT_TOKEN = "local-admin-token"
|
||||
|
||||
# 常见部署根目录,用于自动定位实例配置与 token
|
||||
SEARCH_ROOTS = [
|
||||
r"D:\90server",
|
||||
r"D:\90CN",
|
||||
r"Z:\90server",
|
||||
r"C:\90server",
|
||||
r"D:\90轮回源码",
|
||||
]
|
||||
|
||||
|
||||
def _is_port_open(host, port, timeout=0.35):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class GMClient:
|
||||
"""与服务端本地 GM API 通信的客户端。"""
|
||||
|
||||
def __init__(self, host="127.0.0.1", port=DEFAULT_ADMIN_PORT, token=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.token = token or DEFAULT_TOKEN
|
||||
self.base_url = "http://%s:%d" % (host, port)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------ 发现
|
||||
@staticmethod
|
||||
def discover():
|
||||
"""尝试自动发现服务端地址与 token。返回 (base_url, token) 或 (None, None)。"""
|
||||
# 1) 直接探测默认端口
|
||||
if _is_port_open("127.0.0.1", DEFAULT_ADMIN_PORT):
|
||||
token = GMClient._find_token()
|
||||
if GMClient._probe("127.0.0.1", DEFAULT_ADMIN_PORT, token):
|
||||
return "http://127.0.0.1:%d" % DEFAULT_ADMIN_PORT, token
|
||||
|
||||
# 2) 扫描配置里的 adminListen
|
||||
for root in SEARCH_ROOTS:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for token in GMClient._tokens_from_root(root):
|
||||
# 尝试从 token 配置旁推断端口
|
||||
for port in GMClient._ports_from_root(root):
|
||||
if _is_port_open("127.0.0.1", port) and GMClient._probe("127.0.0.1", port, token):
|
||||
return "http://127.0.0.1:%d" % port, token
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def _probe(host, port, token):
|
||||
try:
|
||||
r = requests.get("http://%s:%d/dnf/gm/status" % (host, port),
|
||||
headers={"X-Admin-Token": token}, timeout=1.0)
|
||||
return r.status_code in (200, 401, 403)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _tokens_from_root(root):
|
||||
"""从部署目录里搜集候选 token。"""
|
||||
tokens = [DEFAULT_TOKEN]
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
if len(tokens) > 40:
|
||||
break
|
||||
for fn in files:
|
||||
lower = fn.lower()
|
||||
if lower in ("instance.json", "server.json", "config.json", "dnfbridge.toml"):
|
||||
p = os.path.join(dirpath, fn)
|
||||
try:
|
||||
txt = open(p, "r", encoding="utf-8", errors="ignore").read()
|
||||
except OSError:
|
||||
continue
|
||||
# token = "..."
|
||||
for m in re.finditer(r'(?:adminToken|token|admin_token|AdminToken)["\']?\s*[:=]\s*["\']([^"\']+)["\']', txt):
|
||||
v = m.group(1).strip()
|
||||
if v and v not in tokens:
|
||||
tokens.append(v)
|
||||
# [admin]\ntoken = "..."
|
||||
for m in re.finditer(r'token\s*=\s*"([^"]+)"', txt):
|
||||
v = m.group(1).strip()
|
||||
if v and v not in tokens:
|
||||
tokens.append(v)
|
||||
return tokens
|
||||
|
||||
@staticmethod
|
||||
def _ports_from_root(root):
|
||||
ports = [DEFAULT_ADMIN_PORT]
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
if len(ports) > 20:
|
||||
break
|
||||
for fn in files:
|
||||
if fn.lower() in ("instance.json", "server.json", "dnfbridge.toml"):
|
||||
p = os.path.join(dirpath, fn)
|
||||
try:
|
||||
txt = open(p, "r", encoding="utf-8", errors="ignore").read()
|
||||
except OSError:
|
||||
continue
|
||||
for m in re.finditer(r'(?:adminListen|listen)["\']?\s*[:=]\s*["\']?127\.0\.0\.1:(\d+)', txt):
|
||||
try:
|
||||
port = int(m.group(1))
|
||||
if port not in ports:
|
||||
ports.append(port)
|
||||
except ValueError:
|
||||
pass
|
||||
return ports
|
||||
|
||||
# ------------------------------------------------------------------ HTTP
|
||||
def _headers(self, mutate=False, confirm=None):
|
||||
h = {"X-Admin-Token": self.token, "Content-Type": "application/json"}
|
||||
if mutate:
|
||||
op_id = confirm or "op"
|
||||
h["X-Admin-Confirm"] = op_id
|
||||
h["X-Admin-Idempotency-Key"] = str(uuid.uuid4())
|
||||
return h
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
url = self.base_url + path
|
||||
return requests.request(method, url, timeout=15, **kwargs)
|
||||
|
||||
def _get(self, path, params=None):
|
||||
r = self._request("GET", path, params=params, headers=self._headers())
|
||||
return self._parse(r)
|
||||
|
||||
def _post(self, path, payload=None, mutate=True):
|
||||
r = self._request("POST", path, json=payload or {},
|
||||
headers=self._headers(mutate=mutate))
|
||||
return self._parse(r)
|
||||
|
||||
@staticmethod
|
||||
def _parse(r):
|
||||
try:
|
||||
data = r.json()
|
||||
except ValueError:
|
||||
data = {"ok": False, "error": "非 JSON 响应 (%d)" % r.status_code, "raw": r.text[:200]}
|
||||
# 统一归一化:服务端两套路由返回格式略有差异
|
||||
if isinstance(data, dict):
|
||||
if "success" in data and "data" in data:
|
||||
return {"ok": data.get("success", False), "data": data.get("data"),
|
||||
"error": data.get("error")}
|
||||
if "ok" in data:
|
||||
return data
|
||||
return {"ok": r.status_code < 400, "data": data,
|
||||
"error": None if r.status_code < 400 else "HTTP %d" % r.status_code}
|
||||
|
||||
# ------------------------------------------------------------------ 状态
|
||||
def status(self):
|
||||
return self._get("/dnf/gm/status")
|
||||
|
||||
def channels(self):
|
||||
return self._get("/dnf/gm/channels")
|
||||
|
||||
def gameplay(self):
|
||||
return self._get("/dnf/gm/gameplay")
|
||||
|
||||
# ------------------------------------------------------------------ 账号
|
||||
def list_accounts(self, q="", limit=50):
|
||||
return self._get("/dnf/gm/accounts", params={"q": q, "limit": limit})
|
||||
|
||||
def account(self, account_id):
|
||||
return self._get("/dnf/gm/accounts/%s" % account_id)
|
||||
|
||||
def account_characters(self, account_id):
|
||||
return self._get("/dnf/gm/accounts/%s/characters" % account_id)
|
||||
|
||||
def set_account_value(self, account_id, field, value):
|
||||
return self._post("/dnf/gm/accounts/%s/value" % account_id,
|
||||
{"field": field, "value": value})
|
||||
|
||||
# ------------------------------------------------------------------ 角色
|
||||
def characters(self, account_id="", character_name="", username=""):
|
||||
"""兼容旧路由 /local/dnf-gm/characters 的角色查询。"""
|
||||
params = {}
|
||||
if username:
|
||||
params["username"] = username
|
||||
elif account_id:
|
||||
params["account_id"] = account_id
|
||||
elif character_name:
|
||||
params["character_name"] = character_name
|
||||
return self._get("/local/dnf-gm/characters", params=params)
|
||||
|
||||
def character(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s" % character_id)
|
||||
|
||||
def character_detail(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s/detail" % character_id)
|
||||
|
||||
def set_gold(self, character_id, value):
|
||||
return self._post("/dnf/gm/characters/%s/gold" % character_id, {"value": value})
|
||||
|
||||
def set_level(self, character_id, level):
|
||||
return self._post("/dnf/gm/characters/%s/level" % character_id, {"level": level})
|
||||
|
||||
def set_skill_points(self, character_id, bonus_sp, bonus_tp):
|
||||
return self._post("/dnf/gm/characters/%s/skill-points" % character_id,
|
||||
{"bonus_sp": bonus_sp, "bonus_tp": bonus_tp})
|
||||
|
||||
def set_career(self, character_id, job, first, awakening):
|
||||
return self._post("/dnf/gm/characters/%s/career" % character_id,
|
||||
{"job": job, "first": first, "awakening": awakening})
|
||||
|
||||
# ------------------------------------------------------------------ 邮件
|
||||
def mailbox(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s/mailbox" % character_id)
|
||||
|
||||
def delete_mail(self, character_id, mail_ids, force=False):
|
||||
return self._post("/dnf/gm/characters/%s/mailbox/delete" % character_id,
|
||||
{"mail_ids": mail_ids, "force": force})
|
||||
|
||||
def send_mail(self, character_id, item_id, count, title, body,
|
||||
expire_days=7, reinforce=0, amplify_type=0,
|
||||
equipment_quality="", forging=0, emblem_item_ids=None,
|
||||
enchant_bead_item_id=0):
|
||||
payload = {
|
||||
"item_id": item_id, "count": count, "title": title, "body": body,
|
||||
"expire_days": expire_days, "reinforce": reinforce,
|
||||
"amplify_type": amplify_type, "equipment_quality": equipment_quality,
|
||||
"forging": forging, "emblem_item_ids": emblem_item_ids or [],
|
||||
"enchant_bead_item_id": enchant_bead_item_id,
|
||||
}
|
||||
return self._post("/dnf/gm/characters/%s/mail" % character_id, payload)
|
||||
|
||||
def system_mail(self, character_name, title, body, gold=0, attachments=None):
|
||||
payload = {"character_name": character_name, "title": title, "body": body,
|
||||
"gold": gold, "attachments": attachments or []}
|
||||
return self._post("/local/dnf-gm/mail", payload)
|
||||
|
||||
# ------------------------------------------------------------------ 道具
|
||||
def search_items(self, q="", limit=30):
|
||||
return self._get("/dnf/gm/items", params={"q": q, "limit": limit})
|
||||
|
||||
def item_page(self, **kwargs):
|
||||
return self._get("/dnf/gm/items/page", params=kwargs)
|
||||
|
||||
def item_detail(self, item_id):
|
||||
return self._get("/dnf/gm/items/%d/detail" % item_id)
|
||||
|
||||
def item_categories(self):
|
||||
return self._get("/dnf/gm/items/categories")
|
||||
|
||||
def item_mail_options(self, item_id, mode="", q="", rarity=-1, limit=40, offset=0):
|
||||
params = {"mode": mode, "q": q, "rarity": rarity, "limit": limit, "offset": offset}
|
||||
return self._get("/dnf/gm/items/%d/mail-options" % item_id, params=params)
|
||||
|
||||
def grant_item(self, character_id, item_id, count=1, **opts):
|
||||
return self._post("/local/dnf-gm/item/grant", {
|
||||
"character_id": str(character_id), "item_id": item_id, "count": count, **opts})
|
||||
|
||||
def delete_item(self, character_id, item_key, count=1):
|
||||
return self._post("/local/dnf-gm/item/delete", {
|
||||
"character_id": str(character_id), "key": item_key, "count": count})
|
||||
|
||||
# ------------------------------------------------------------------ 背包
|
||||
def inventory(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s/inventory" % character_id)
|
||||
|
||||
def delete_inventory_item(self, character_id, container, key, count):
|
||||
return self._post("/dnf/gm/characters/%s/inventory/delete" % character_id,
|
||||
{"container": container, "key": key, "count": count})
|
||||
|
||||
def clear_inventory(self, character_id, container):
|
||||
return self._post("/dnf/gm/characters/%s/inventory/clear" % character_id,
|
||||
{"container": container})
|
||||
|
||||
# ------------------------------------------------------------------ 装备
|
||||
def unequip(self, character_id, key):
|
||||
return self._post("/dnf/gm/characters/%s/equipment/unequip" % character_id, {"key": key})
|
||||
|
||||
def delete_equipment(self, character_id, key):
|
||||
return self._post("/dnf/gm/characters/%s/equipment/delete" % character_id, {"key": key})
|
||||
|
||||
def extra_equipment_slots(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s/equipment-slots" % character_id)
|
||||
|
||||
def unlock_equipment_slots(self, character_id, slots):
|
||||
return self._post("/dnf/gm/characters/%s/equipment-slots/unlock" % character_id,
|
||||
{"slots": slots})
|
||||
|
||||
def equipment_move(self, character_id, source_key, target_slot):
|
||||
return self._post("/local/equipment-move", {
|
||||
"character_id": str(character_id), "key": source_key, "target": target_slot})
|
||||
|
||||
# ------------------------------------------------------------------ 任务
|
||||
def quests(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s/quests" % character_id)
|
||||
|
||||
def search_quests(self, character_id, q="", limit=40):
|
||||
return self._get("/dnf/gm/characters/%s/quests/search" % character_id,
|
||||
params={"q": q, "limit": limit})
|
||||
|
||||
def quest_detail(self, character_id, quest_id):
|
||||
return self._get("/dnf/gm/characters/%s/quests/%d/detail" % (character_id, quest_id))
|
||||
|
||||
def quest_action(self, character_id, quest_id, action):
|
||||
return self._post("/dnf/gm/characters/%s/quests/%d/%s" % (character_id, quest_id, action))
|
||||
|
||||
def quest_complete_batch(self, character_id, quest_ids):
|
||||
return self._post("/dnf/gm/characters/%s/quests/complete-batch" % character_id,
|
||||
{"quest_ids": quest_ids})
|
||||
|
||||
def quest_resync(self, character_id):
|
||||
return self._post("/local/quest-resync", {"character_id": int(character_id)})
|
||||
|
||||
# ------------------------------------------------------------------ 副本
|
||||
def dungeons(self, character_id):
|
||||
return self._get("/dnf/gm/characters/%s/dungeons" % character_id)
|
||||
|
||||
def unlock_dungeon_difficulties(self, character_id):
|
||||
return self._post("/dnf/gm/characters/%s/dungeons/unlock-difficulties" % character_id)
|
||||
|
||||
def unlock_hell(self, character_id):
|
||||
return self._post("/dnf/gm/characters/%s/dungeons/unlock-hell" % character_id)
|
||||
|
||||
def dungeon_control(self, character_id):
|
||||
return self._get("/local/dungeon-control", params={"character_id": int(character_id)})
|
||||
|
||||
def dungeon_return_town(self, character_id):
|
||||
return self._post("/local/dungeon-control", {
|
||||
"character_id": int(character_id), "action": "return_to_town"})
|
||||
|
||||
# ------------------------------------------------------------------ 钱包
|
||||
def wallet_query(self, character_id):
|
||||
return self._post("/local/dnf-gm/wallet/query", {"character_id": str(character_id)})
|
||||
|
||||
def wallet_update(self, character_id, field, value):
|
||||
return self._post("/local/dnf-gm/wallet/update",
|
||||
{"character_id": str(character_id), field: value})
|
||||
|
||||
def revive_coin(self, character_id, delta):
|
||||
return self._post("/local/dnf-gm/character/revive-coin",
|
||||
{"character_id": str(character_id), "delta": delta})
|
||||
|
||||
def account_cera(self, account_id, delta):
|
||||
return self._post("/local/dnf-gm/account/cera",
|
||||
{"account_id": str(account_id), "delta": delta})
|
||||
|
||||
def black_diamond_status(self, character_id="", name=""):
|
||||
params = {}
|
||||
if character_id:
|
||||
params["id"] = character_id
|
||||
if name:
|
||||
params["name"] = name
|
||||
return self._get("/local/dnf-gm/character/black-diamond", params=params)
|
||||
|
||||
def activate_black_diamond(self, character_id, days):
|
||||
return self._post("/local/dnf-gm/character/black-diamond",
|
||||
{"character_id": str(character_id), "days": days})
|
||||
|
||||
# ------------------------------------------------------------------ 玩法
|
||||
def set_hell_drop_rate(self, percent):
|
||||
return self._post("/dnf/gm/gameplay/hell-drop-rate", {"percent": percent})
|
||||
|
||||
def set_epic_potion_multiplier(self, percent):
|
||||
return self._post("/dnf/gm/gameplay/epic-potion-test-multiplier", {"percent": percent})
|
||||
|
||||
def reload_channels(self):
|
||||
return self._post("/dnf/gm/channels/reload")
|
||||
|
||||
# ------------------------------------------------------------------ 其他
|
||||
def kick_sessions(self, ban_type, ban_value):
|
||||
return self._post("/local/dnf-gm/kick-sessions",
|
||||
{"ban_type": ban_type, "ban_value": ban_value})
|
||||
|
||||
def clone_character(self, source_character_id, name):
|
||||
return self._post("/local/character-clone",
|
||||
{"source_character_id": int(source_character_id), "name": name})
|
||||
|
||||
def record_meta(self):
|
||||
return self._get("/dnf/gm/records/meta")
|
||||
|
||||
def query_records(self, **kwargs):
|
||||
return self._post("/dnf/gm/records/query", kwargs)
|
||||
|
||||
|
||||
# 全局单例(懒加载,首次调用时自动发现)
|
||||
_CLIENT = None
|
||||
_CLIENT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_client():
|
||||
global _CLIENT
|
||||
with _CLIENT_LOCK:
|
||||
if _CLIENT is None:
|
||||
_CLIENT = GMClient()
|
||||
return _CLIENT
|
||||
|
||||
|
||||
def reconnect(host="127.0.0.1", port=DEFAULT_ADMIN_PORT, token=None):
|
||||
global _CLIENT
|
||||
with _CLIENT_LOCK:
|
||||
_CLIENT = GMClient(host, port, token)
|
||||
return _CLIENT
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DNF90 GM 工具 —— 后端服务。
|
||||
|
||||
基于 FastAPI,作为浏览器前端与服务端本地 GM API 之间的代理,
|
||||
同时提供服务端自动发现、状态轮询、以及静态资源托管。
|
||||
"""
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gm_client import get_client, reconnect, GMClient
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||
|
||||
app = FastAPI(title="DNF90 GM 工具", version="2.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
def _unwrap(result):
|
||||
"""把 gm_client 返回的 {'ok':.., 'data':..} 统一为 HTTP 响应。"""
|
||||
if not isinstance(result, dict):
|
||||
return JSONResponse({"ok": False, "error": "未知响应"}, status_code=502)
|
||||
if result.get("ok"):
|
||||
return JSONResponse({"ok": True, "data": result.get("data")})
|
||||
err = result.get("error") or "操作失败"
|
||||
return JSONResponse({"ok": False, "error": err}, status_code=400)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 系统
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
client = get_client()
|
||||
return {"ok": True, "base_url": client.base_url, "token_ok": bool(client.token)}
|
||||
|
||||
|
||||
@app.post("/api/connect")
|
||||
def connect(payload: dict):
|
||||
host = payload.get("host", "127.0.0.1")
|
||||
port = int(payload.get("port", 18111))
|
||||
token = payload.get("token") or None
|
||||
client = reconnect(host, port, token)
|
||||
res = client.status()
|
||||
if not res.get("ok"):
|
||||
return JSONResponse({"ok": False, "error": "无法连接服务端:%s" % res.get("error")},
|
||||
status_code=400)
|
||||
return {"ok": True, "base_url": client.base_url, "status": res.get("data")}
|
||||
|
||||
|
||||
@app.get("/api/discover")
|
||||
def discover():
|
||||
base_url, token = GMClient.discover()
|
||||
if base_url:
|
||||
# 成功发现则建立连接
|
||||
host = base_url.split("://")[1].split(":")[0]
|
||||
port = int(base_url.rsplit(":", 1)[1])
|
||||
reconnect(host, port, token)
|
||||
return {"ok": True, "base_url": base_url, "token": token}
|
||||
return {"ok": False, "error": "未发现运行中的服务端"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 状态 / 频道
|
||||
@app.get("/api/status")
|
||||
def status():
|
||||
return _unwrap(get_client().status())
|
||||
|
||||
|
||||
@app.get("/api/channels")
|
||||
def channels():
|
||||
return _unwrap(get_client().channels())
|
||||
|
||||
|
||||
@app.post("/api/channels/reload")
|
||||
def reload_channels():
|
||||
return _unwrap(get_client().reload_channels())
|
||||
|
||||
|
||||
@app.get("/api/gameplay")
|
||||
def gameplay():
|
||||
return _unwrap(get_client().gameplay())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 账号
|
||||
@app.get("/api/accounts")
|
||||
def accounts(q: str = "", limit: int = 50):
|
||||
return _unwrap(get_client().list_accounts(q, limit))
|
||||
|
||||
|
||||
@app.get("/api/accounts/{account_id}")
|
||||
def account(account_id: str):
|
||||
return _unwrap(get_client().account(account_id))
|
||||
|
||||
|
||||
@app.get("/api/accounts/{account_id}/characters")
|
||||
def account_characters(account_id: str):
|
||||
return _unwrap(get_client().account_characters(account_id))
|
||||
|
||||
|
||||
@app.post("/api/accounts/{account_id}/value")
|
||||
def set_account_value(account_id: str, payload: dict):
|
||||
return _unwrap(get_client().set_account_value(
|
||||
account_id, payload.get("field"), payload.get("value")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 角色
|
||||
@app.get("/api/characters")
|
||||
def characters(account_id: str = "", character_name: str = "", username: str = ""):
|
||||
return _unwrap(get_client().characters(account_id, character_name, username))
|
||||
|
||||
|
||||
@app.get("/api/characters/{character_id}")
|
||||
def character(character_id: str):
|
||||
return _unwrap(get_client().character(character_id))
|
||||
|
||||
|
||||
@app.get("/api/characters/{character_id}/detail")
|
||||
def character_detail(character_id: str):
|
||||
return _unwrap(get_client().character_detail(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/gold")
|
||||
def set_gold(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().set_gold(character_id, payload.get("value", 0)))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/level")
|
||||
def set_level(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().set_level(character_id, payload.get("level", 1)))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/skill-points")
|
||||
def set_skill(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().set_skill_points(
|
||||
character_id, payload.get("bonus_sp", 0), payload.get("bonus_tp", 0)))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/career")
|
||||
def set_career(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().set_career(
|
||||
character_id, payload.get("job", 0), payload.get("first", 0),
|
||||
payload.get("awakening", 0)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 邮件
|
||||
@app.get("/api/characters/{character_id}/mailbox")
|
||||
def mailbox(character_id: str):
|
||||
return _unwrap(get_client().mailbox(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/mailbox/delete")
|
||||
def delete_mail(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().delete_mail(
|
||||
character_id, payload.get("mail_ids", []), payload.get("force", False)))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/mail")
|
||||
def send_mail(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().send_mail(
|
||||
character_id,
|
||||
payload.get("item_id", 0),
|
||||
payload.get("count", 1),
|
||||
payload.get("title", ""),
|
||||
payload.get("body", ""),
|
||||
expire_days=payload.get("expire_days", 7),
|
||||
reinforce=payload.get("reinforce", 0),
|
||||
amplify_type=payload.get("amplify_type", 0),
|
||||
equipment_quality=payload.get("equipment_quality", ""),
|
||||
forging=payload.get("forging", 0),
|
||||
emblem_item_ids=payload.get("emblem_item_ids", []),
|
||||
enchant_bead_item_id=payload.get("enchant_bead_item_id", 0),
|
||||
))
|
||||
|
||||
|
||||
@app.post("/api/system-mail")
|
||||
def system_mail(payload: dict):
|
||||
return _unwrap(get_client().system_mail(
|
||||
payload.get("character_name", ""),
|
||||
payload.get("title", ""),
|
||||
payload.get("body", ""),
|
||||
gold=payload.get("gold", 0),
|
||||
attachments=payload.get("attachments", []),
|
||||
))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 道具
|
||||
@app.get("/api/items/search")
|
||||
def search_items(q: str = "", limit: int = 30):
|
||||
return _unwrap(get_client().search_items(q, limit))
|
||||
|
||||
|
||||
@app.get("/api/items/page")
|
||||
def item_page(q: str = "", kind: str = "", tag: str = "", segment: str = "",
|
||||
group: str = "", job: str = "", min_level: int = 0, max_level: int = 0,
|
||||
rarity: int = -1, expiration: str = "", limit: int = 100, offset: int = 0):
|
||||
return _unwrap(get_client().item_page(
|
||||
q=q, kind=kind, tag=tag, segment=segment, group=group, job=job,
|
||||
min_level=min_level, max_level=max_level, rarity=rarity,
|
||||
expiration=expiration, limit=limit, offset=offset))
|
||||
|
||||
|
||||
@app.get("/api/items/{item_id}/detail")
|
||||
def item_detail(item_id: int):
|
||||
return _unwrap(get_client().item_detail(item_id))
|
||||
|
||||
|
||||
@app.get("/api/items/categories")
|
||||
def item_categories():
|
||||
return _unwrap(get_client().item_categories())
|
||||
|
||||
|
||||
@app.get("/api/items/{item_id}/mail-options")
|
||||
def item_mail_options(item_id: int, mode: str = "", q: str = "", rarity: int = -1,
|
||||
limit: int = 40, offset: int = 0):
|
||||
return _unwrap(get_client().item_mail_options(item_id, mode, q, rarity, limit, offset))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/grant")
|
||||
def grant_item(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().grant_item(
|
||||
character_id, payload.get("item_id", 0), payload.get("count", 1)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 背包 / 装备
|
||||
@app.get("/api/characters/{character_id}/inventory")
|
||||
def inventory(character_id: str):
|
||||
return _unwrap(get_client().inventory(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/inventory/delete")
|
||||
def delete_inventory_item(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().delete_inventory_item(
|
||||
character_id, payload.get("container", ""), payload.get("key", ""),
|
||||
payload.get("count", 1)))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/inventory/clear")
|
||||
def clear_inventory(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().clear_inventory(character_id, payload.get("container", "")))
|
||||
|
||||
|
||||
@app.get("/api/characters/{character_id}/equipment-slots")
|
||||
def equipment_slots(character_id: str):
|
||||
return _unwrap(get_client().extra_equipment_slots(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/equipment-slots/unlock")
|
||||
def unlock_equipment_slots(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().unlock_equipment_slots(character_id, payload.get("slots", [])))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/equipment/unequip")
|
||||
def unequip(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().unequip(character_id, payload.get("key", "")))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/equipment/delete")
|
||||
def delete_equipment(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().delete_equipment(character_id, payload.get("key", "")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 任务
|
||||
@app.get("/api/characters/{character_id}/quests")
|
||||
def quests(character_id: str):
|
||||
return _unwrap(get_client().quests(character_id))
|
||||
|
||||
|
||||
@app.get("/api/characters/{character_id}/quests/search")
|
||||
def search_quests(character_id: str, q: str = "", limit: int = 40):
|
||||
return _unwrap(get_client().search_quests(character_id, q, limit))
|
||||
|
||||
|
||||
@app.get("/api/characters/{character_id}/quests/{quest_id}/detail")
|
||||
def quest_detail(character_id: str, quest_id: int):
|
||||
return _unwrap(get_client().quest_detail(character_id, quest_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/quests/{quest_id}/{action}")
|
||||
def quest_action(character_id: str, quest_id: int, action: str):
|
||||
allowed = {"accept", "ready", "give-up", "complete", "uncomplete"}
|
||||
if action not in allowed:
|
||||
raise HTTPException(400, "不支持的操作")
|
||||
return _unwrap(get_client().quest_action(character_id, quest_id, action))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/quests/complete-batch")
|
||||
def quest_batch(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().quest_complete_batch(
|
||||
character_id, payload.get("quest_ids", [])))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/quest-resync")
|
||||
def quest_resync(character_id: str):
|
||||
return _unwrap(get_client().quest_resync(character_id))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 副本
|
||||
@app.get("/api/characters/{character_id}/dungeons")
|
||||
def dungeons(character_id: str):
|
||||
return _unwrap(get_client().dungeons(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/dungeons/unlock-difficulties")
|
||||
def unlock_difficulties(character_id: str):
|
||||
return _unwrap(get_client().unlock_dungeon_difficulties(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/dungeons/unlock-hell")
|
||||
def unlock_hell(character_id: str):
|
||||
return _unwrap(get_client().unlock_hell(character_id))
|
||||
|
||||
|
||||
@app.get("/api/characters/{character_id}/dungeon-control")
|
||||
def dungeon_control(character_id: str):
|
||||
return _unwrap(get_client().dungeon_control(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/dungeon-return")
|
||||
def dungeon_return(character_id: str):
|
||||
return _unwrap(get_client().dungeon_return_town(character_id))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 钱包
|
||||
@app.post("/api/characters/{character_id}/wallet/query")
|
||||
def wallet_query(character_id: str):
|
||||
return _unwrap(get_client().wallet_query(character_id))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/wallet/update")
|
||||
def wallet_update(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().wallet_update(
|
||||
character_id, payload.get("field"), payload.get("value")))
|
||||
|
||||
|
||||
@app.post("/api/characters/{character_id}/revive-coin")
|
||||
def revive_coin(character_id: str, payload: dict):
|
||||
return _unwrap(get_client().revive_coin(character_id, payload.get("delta", 0)))
|
||||
|
||||
|
||||
@app.get("/api/black-diamond")
|
||||
def black_diamond(id: str = "", name: str = ""):
|
||||
return _unwrap(get_client().black_diamond_status(id, name))
|
||||
|
||||
|
||||
@app.post("/api/black-diamond/activate")
|
||||
def activate_black_diamond(payload: dict):
|
||||
return _unwrap(get_client().activate_black_diamond(
|
||||
payload.get("character_id", ""), payload.get("days", 30)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 玩法
|
||||
@app.post("/api/gameplay/hell-drop-rate")
|
||||
def hell_drop_rate(payload: dict):
|
||||
return _unwrap(get_client().set_hell_drop_rate(payload.get("percent", 0)))
|
||||
|
||||
|
||||
@app.post("/api/gameplay/epic-potion-multiplier")
|
||||
def epic_potion(payload: dict):
|
||||
return _unwrap(get_client().set_epic_potion_multiplier(payload.get("percent", 0)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 其他
|
||||
@app.post("/api/kick")
|
||||
def kick(payload: dict):
|
||||
return _unwrap(get_client().kick_sessions(
|
||||
payload.get("ban_type", "account"), payload.get("ban_value", "")))
|
||||
|
||||
|
||||
@app.post("/api/clone")
|
||||
def clone(payload: dict):
|
||||
return _unwrap(get_client().clone_character(
|
||||
payload.get("source_character_id", 0), payload.get("name", "")))
|
||||
|
||||
|
||||
@app.get("/api/records/meta")
|
||||
def record_meta():
|
||||
return _unwrap(get_client().record_meta())
|
||||
|
||||
|
||||
@app.post("/api/records/query")
|
||||
def record_query(payload: dict):
|
||||
return _unwrap(get_client().query_records(**payload))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 静态资源
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
print("=" * 60)
|
||||
print(" DNF90 GM 工具后端已启动")
|
||||
print(" 访问: http://127.0.0.1:9527")
|
||||
print("=" * 60)
|
||||
uvicorn.run(app, host="127.0.0.1", port=9527, log_level="info")
|
||||
@@ -0,0 +1,424 @@
|
||||
/* ============================================================
|
||||
DNF90 GM 工具 — 精美深色主题样式
|
||||
============================================================ */
|
||||
:root {
|
||||
--bg-0: #0a0a12;
|
||||
--bg-1: #10101c;
|
||||
--bg-2: #16162a;
|
||||
--bg-3: #1d1d36;
|
||||
--panel: rgba(22, 22, 42, 0.72);
|
||||
--panel-solid: #16162a;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text-0: #f2f3f8;
|
||||
--text-1: #c3c6d8;
|
||||
--text-2: #8a8ea8;
|
||||
--text-3: #5c6078;
|
||||
|
||||
--accent: #7c5cff;
|
||||
--accent-2: #b45cff;
|
||||
--accent-glow: rgba(124, 92, 255, 0.35);
|
||||
--cyan: #38d9f0;
|
||||
--gold: #ffc44d;
|
||||
--red: #ff5d6c;
|
||||
--green: #35d07f;
|
||||
--blue: #4d9fff;
|
||||
--orange: #ff8a4d;
|
||||
|
||||
--rarity-0: #9aa0b5; /* 普通 */
|
||||
--rarity-1: #5fd3ff; /* 高级 */
|
||||
--rarity-2: #6ea8ff; /* 稀有 */
|
||||
--rarity-3: #c37bff; /* 神器 */
|
||||
--rarity-4: #ffb84d; /* 传说 */
|
||||
--rarity-5: #ff7a59; /* 史诗 */
|
||||
--rarity-6: #ff5d6c; /* 神话 */
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
--font: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
color: var(--text-0);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 15% -10%, rgba(124, 92, 255, 0.16), transparent 55%),
|
||||
radial-gradient(1000px 600px at 100% 0%, rgba(56, 217, 240, 0.10), transparent 50%),
|
||||
radial-gradient(900px 900px at 50% 120%, rgba(180, 92, 255, 0.12), transparent 55%),
|
||||
var(--bg-0);
|
||||
background-attachment: fixed;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); background-clip: padding-box; }
|
||||
|
||||
button { font-family: var(--font); cursor: pointer; }
|
||||
input, select, textarea {
|
||||
font-family: var(--font);
|
||||
color: var(--text-0);
|
||||
}
|
||||
|
||||
/* ---------------- 布局 ---------------- */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, rgba(22, 22, 42, 0.9), rgba(13, 13, 26, 0.95));
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 20px;
|
||||
}
|
||||
.logo-mark {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 18px; color: #fff;
|
||||
box-shadow: 0 0 22px var(--accent-glow);
|
||||
}
|
||||
.logo-title { font-size: 17px; font-weight: 700; letter-spacing: 0.5px; }
|
||||
.logo-sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
.nav-group {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
padding: 16px 12px 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-1);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-0); }
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, rgba(124, 92, 255, 0.22), rgba(180, 92, 255, 0.12));
|
||||
color: #fff;
|
||||
border-color: rgba(124, 92, 255, 0.4);
|
||||
box-shadow: 0 0 18px rgba(124, 92, 255, 0.15);
|
||||
}
|
||||
.nav-item .ico { width: 20px; text-align: center; font-size: 16px; }
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.conn-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.conn-dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.conn-dot.off { background: var(--red); box-shadow: 0 0 8px var(--red); }
|
||||
|
||||
/* 主内容 */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 58px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(16, 16, 28, 0.6);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.topbar h1 { font-size: 17px; font-weight: 700; }
|
||||
.topbar .crumb { color: var(--text-3); font-size: 12px; }
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
/* ---------------- 通用组件 ---------------- */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.card + .card { margin-top: 16px; }
|
||||
.card-title {
|
||||
font-size: 15px; font-weight: 700; margin-bottom: 14px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.card-title .dot { width: 4px; height: 16px; border-radius: 2px; background: linear-gradient(180deg, var(--accent), var(--accent-2)); }
|
||||
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
@media (max-width: 1100px) { .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 720px) { .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card .label { font-size: 12.5px; color: var(--text-2); }
|
||||
.stat-card .value { font-size: 28px; font-weight: 800; margin-top: 6px; letter-spacing: -0.5px; }
|
||||
.stat-card .unit { font-size: 13px; color: var(--text-2); margin-left: 3px; font-weight: 500; }
|
||||
.stat-card .ico-bg {
|
||||
position: absolute; right: -10px; bottom: -14px;
|
||||
font-size: 70px; opacity: 0.08;
|
||||
}
|
||||
.stat-card.accent { border-color: rgba(124, 92, 255, 0.4); }
|
||||
.stat-card.accent .value { color: var(--accent); }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-0);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
transition: all 0.16s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--bg-2); border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
|
||||
.btn:active { transform: translateY(0); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 18px var(--accent-glow);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.12); background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
|
||||
.btn-danger { background: linear-gradient(135deg, #ff5d6c, #ff3d5a); border-color: transparent; }
|
||||
.btn-danger:hover { filter: brightness(1.1); background: linear-gradient(135deg, #ff5d6c, #ff3d5a); }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12.5px; }
|
||||
.btn-xs { padding: 4px 9px; font-size: 11.5px; border-radius: 7px; }
|
||||
|
||||
/* 输入 */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 12.5px; color: var(--text-2); font-weight: 600; }
|
||||
.input, .select, .textarea {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-0);
|
||||
outline: none;
|
||||
transition: all 0.16s;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.15);
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
|
||||
.select option { background: var(--bg-2); color: var(--text-0); }
|
||||
.textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
||||
|
||||
/* 表格 */
|
||||
.table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th {
|
||||
text-align: left; padding: 11px 14px;
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
font-weight: 600; font-size: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
position: sticky; top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td { padding: 11px 14px; border-top: 1px solid var(--border); color: var(--text-1); }
|
||||
tbody tr { transition: background 0.12s; }
|
||||
tbody tr:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 3px 9px; border-radius: 20px;
|
||||
font-size: 11.5px; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-green { background: rgba(53, 208, 127, 0.15); color: var(--green); }
|
||||
.tag-red { background: rgba(255, 93, 108, 0.15); color: var(--red); }
|
||||
.tag-gray { background: rgba(255, 255, 255, 0.08); color: var(--text-2); }
|
||||
.tag-gold { background: rgba(255, 196, 77, 0.15); color: var(--gold); }
|
||||
.tag-purple { background: rgba(124, 92, 255, 0.16); color: #b79bff; }
|
||||
|
||||
/* 稀有度 */
|
||||
.rarity-0 { color: var(--rarity-0); }
|
||||
.rarity-1 { color: var(--rarity-1); }
|
||||
.rarity-2 { color: var(--rarity-2); }
|
||||
.rarity-3 { color: var(--rarity-3); }
|
||||
.rarity-4 { color: var(--rarity-4); }
|
||||
.rarity-5 { color: var(--rarity-5); }
|
||||
.rarity-6 { color: var(--rarity-6); text-shadow: 0 0 10px rgba(255, 93, 108, 0.5); }
|
||||
|
||||
/* Toast */
|
||||
.toast-wrap {
|
||||
position: fixed; top: 20px; right: 20px; z-index: 999;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.toast {
|
||||
min-width: 240px; max-width: 360px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
font-size: 13.5px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
animation: slideIn 0.28s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
.toast.ok { border-left: 3px solid var(--green); }
|
||||
.toast.err { border-left: 3px solid var(--red); }
|
||||
.toast.info { border-left: 3px solid var(--blue); }
|
||||
@keyframes slideIn { from { transform: translateX(30px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* Modal */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; z-index: 900;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
.modal {
|
||||
width: 560px; max-width: 92vw; max-height: 86vh;
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||
display: flex; flex-direction: column;
|
||||
animation: popIn 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal.wide { width: 760px; }
|
||||
.modal-head {
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-weight: 700; font-size: 16px;
|
||||
}
|
||||
.modal-head .close {
|
||||
background: none; border: none; color: var(--text-2);
|
||||
font-size: 22px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.modal-head .close:hover { color: var(--text-0); }
|
||||
.modal-body { padding: 22px; overflow-y: auto; }
|
||||
.modal-foot {
|
||||
padding: 16px 22px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes popIn { from { transform: scale(0.95); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* 加载 */
|
||||
.loading {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 60px 0; gap: 16px; color: var(--text-2);
|
||||
}
|
||||
.spinner {
|
||||
width: 34px; height: 34px;
|
||||
border: 3px solid rgba(255,255,255,0.12);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.empty {
|
||||
text-align: center; padding: 50px 20px; color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 徽章数值 */
|
||||
.num-gold { color: var(--gold); font-weight: 700; }
|
||||
.num-cyan { color: var(--cyan); font-weight: 700; }
|
||||
|
||||
/* 搜索栏 */
|
||||
.search-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.search-row .input { flex: 1; min-width: 160px; }
|
||||
|
||||
/* 角色头像占位 */
|
||||
.avatar {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 工具条 */
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
|
||||
/* 空状态提示 */
|
||||
.hint { font-size: 12px; color: var(--text-3); margin-top: 6px; }
|
||||
|
||||
/* 连接面板 */
|
||||
.conn-panel {
|
||||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||
padding: 14px 16px; border-radius: 12px;
|
||||
background: var(--bg-1); border: 1px solid var(--border);
|
||||
}
|
||||
.conn-panel .input { width: auto; }
|
||||
|
||||
/* 双列表单 */
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.form-row .full { grid-column: 1 / -1; }
|
||||
@media (max-width: 620px) { .form-row { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DNF90 GM 管理工具</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<div class="logo-mark">D</div>
|
||||
<div>
|
||||
<div class="logo-title">DNF90 GM</div>
|
||||
<div class="logo-sub">九零轮回 · 管理工具</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">总览</div>
|
||||
<div class="nav-item active" data-page="dashboard">
|
||||
<span class="ico">📊</span>控制台
|
||||
</div>
|
||||
<div class="nav-item" data-page="characters">
|
||||
<span class="ico">👥</span>角色管理
|
||||
</div>
|
||||
|
||||
<div class="nav-group">资源</div>
|
||||
<div class="nav-item" data-page="items">
|
||||
<span class="ico">🎁</span>道具发放
|
||||
</div>
|
||||
<div class="nav-item" data-page="mail">
|
||||
<span class="ico">✉️</span>邮件系统
|
||||
</div>
|
||||
<div class="nav-item" data-page="inventory">
|
||||
<span class="ico">🎒</span>背包装备
|
||||
</div>
|
||||
|
||||
<div class="nav-group">进度</div>
|
||||
<div class="nav-item" data-page="quests">
|
||||
<span class="ico">📜</span>任务管理
|
||||
</div>
|
||||
<div class="nav-item" data-page="dungeons">
|
||||
<span class="ico">⚔️</span>副本控制
|
||||
</div>
|
||||
|
||||
<div class="nav-group">系统</div>
|
||||
<div class="nav-item" data-page="wallet">
|
||||
<span class="ico">💰</span>钱包点券
|
||||
</div>
|
||||
<div class="nav-item" data-page="gameplay">
|
||||
<span class="ico">🎮</span>玩法配置
|
||||
</div>
|
||||
<div class="nav-item" data-page="channels">
|
||||
<span class="ico">📡</span>频道管理
|
||||
</div>
|
||||
<div class="nav-item" data-page="settings">
|
||||
<span class="ico">⚙️</span>连接设置
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<div><span class="conn-dot off"></span><span class="conn-text">未连接服务端</span></div>
|
||||
<div style="color:#4a4e66">版本 v2.0.0</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<h1 id="pageTitle">控制台</h1>
|
||||
<span class="crumb" id="pageCrumb">/ 服务端总览</span>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button class="btn btn-sm btn-ghost" id="btnDiscover">🔍 自动发现服务端</button>
|
||||
<button class="btn btn-sm btn-primary" id="btnRefresh">↻ 刷新</button>
|
||||
</header>
|
||||
<div class="content" id="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/pages.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
// ============================================================
|
||||
// DNF90 GM 工具 — API 客户端与通用工具
|
||||
// ============================================================
|
||||
|
||||
const API = {
|
||||
async request(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
let data;
|
||||
try { data = await res.json(); }
|
||||
catch (e) { data = { ok: false, error: '响应解析失败' }; }
|
||||
if (!res.ok || data.ok === false) {
|
||||
const err = new Error(data.error || `请求失败 (${res.status})`);
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data.data;
|
||||
},
|
||||
get(path) { return this.request('GET', path); },
|
||||
post(path, body) { return this.request('POST', path, body ?? {}); },
|
||||
};
|
||||
|
||||
// ---------- Toast ----------
|
||||
function toast(msg, type = 'info', ms = 3200) {
|
||||
let wrap = document.querySelector('.toast-wrap');
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'toast-wrap';
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
const icon = type === 'ok' ? '✓' : type === 'err' ? '✕' : 'ℹ';
|
||||
el.innerHTML = `<span style="font-weight:700">${icon}</span><span>${escapeHtml(msg)}</span>`;
|
||||
wrap.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.style.transition = 'opacity 0.3s, transform 0.3s';
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateX(30px)';
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n === null || n === undefined || n === '') return '—';
|
||||
const num = Number(n);
|
||||
if (isNaN(num)) return String(n);
|
||||
return num.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function rarityColor(r) { return `rarity-${r}`; }
|
||||
function rarityName(r) {
|
||||
const names = ['普通', '高级', '稀有', '神器', '传说', '史诗', '神话'];
|
||||
return names[r] || '未知';
|
||||
}
|
||||
|
||||
// ---------- Modal ----------
|
||||
function openModal({ title, body, foot, wide = false }) {
|
||||
const mask = document.createElement('div');
|
||||
mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal ${wide ? 'wide' : ''}">
|
||||
<div class="modal-head">
|
||||
<span>${escapeHtml(title)}</span>
|
||||
<button class="close" data-close>×</button>
|
||||
</div>
|
||||
<div class="modal-body"></div>
|
||||
<div class="modal-foot"></div>
|
||||
</div>`;
|
||||
const modal = mask.querySelector('.modal');
|
||||
const bodyEl = mask.querySelector('.modal-body');
|
||||
const footEl = mask.querySelector('.modal-foot');
|
||||
if (typeof body === 'string') bodyEl.innerHTML = body;
|
||||
else if (body) bodyEl.appendChild(body);
|
||||
if (typeof foot === 'string') footEl.innerHTML = foot;
|
||||
else if (foot) footEl.appendChild(foot);
|
||||
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', e => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-close]').addEventListener('click', close);
|
||||
document.body.appendChild(mask);
|
||||
return { mask, modal, bodyEl, footEl, close };
|
||||
}
|
||||
|
||||
// ---------- Loading ----------
|
||||
function loadingHtml(text = '加载中…') {
|
||||
return `<div class="loading"><div class="spinner"></div><div>${escapeHtml(text)}</div></div>`;
|
||||
}
|
||||
|
||||
// 连接状态
|
||||
let CONN = { connected: false, base_url: '' };
|
||||
|
||||
function updateConnDot() {
|
||||
const dots = document.querySelectorAll('.conn-dot');
|
||||
dots.forEach(d => {
|
||||
d.className = 'conn-dot ' + (CONN.connected ? 'on' : 'off');
|
||||
});
|
||||
const txt = document.querySelector('.conn-text');
|
||||
if (txt) txt.textContent = CONN.connected
|
||||
? `已连接 ${CONN.base_url || ''}`
|
||||
: '未连接服务端';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// ============================================================
|
||||
// DNF90 GM 工具 — 应用初始化与路由
|
||||
// ============================================================
|
||||
|
||||
const PAGE_TITLES = {
|
||||
dashboard: ['控制台', '/ 服务端总览'],
|
||||
characters: ['角色管理', '/ 查询与编辑角色'],
|
||||
items: ['道具发放', '/ 搜索并发放道具'],
|
||||
mail: ['邮件系统', '/ 发送系统邮件'],
|
||||
inventory: ['背包装备', '/ 查看与清理背包'],
|
||||
quests: ['任务管理', '/ 查询与操作任务'],
|
||||
dungeons: ['副本控制', '/ 副本状态与回城'],
|
||||
wallet: ['钱包点券', '/ 钱包与黑钻'],
|
||||
gameplay: ['玩法配置', '/ 掉率与倍率'],
|
||||
channels: ['频道管理', '/ 频道列表与热重载'],
|
||||
settings: ['连接设置', '/ 服务端连接'],
|
||||
};
|
||||
|
||||
let currentPage = 'dashboard';
|
||||
|
||||
function renderPage(page) {
|
||||
currentPage = page;
|
||||
const [title, crumb] = PAGE_TITLES[page] || [page, ''];
|
||||
document.getElementById('pageTitle').textContent = title;
|
||||
document.getElementById('pageCrumb').textContent = crumb;
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
|
||||
const content = document.getElementById('content');
|
||||
if (Pages[page]) {
|
||||
Pages[page](content);
|
||||
} else {
|
||||
content.innerHTML = '<div class="empty">页面不存在</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentPage() {
|
||||
renderPage(currentPage);
|
||||
}
|
||||
|
||||
// ---------- 导航绑定 ----------
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => renderPage(item.dataset.page));
|
||||
});
|
||||
|
||||
// ---------- 顶部按钮 ----------
|
||||
document.getElementById('btnRefresh').addEventListener('click', () => renderCurrentPage());
|
||||
document.getElementById('btnDiscover').addEventListener('click', doDiscover);
|
||||
|
||||
// ---------- 启动 ----------
|
||||
async function boot() {
|
||||
// 尝试自动连接
|
||||
try {
|
||||
const health = await API.get('/api/health');
|
||||
if (health.base_url) {
|
||||
CONN.connected = true;
|
||||
CONN.base_url = health.base_url;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
// 尝试自动发现(若未连接)
|
||||
if (!CONN.connected) {
|
||||
try {
|
||||
const r = await API.get('/api/discover');
|
||||
if (r.ok) {
|
||||
CONN.connected = true;
|
||||
CONN.base_url = r.base_url;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
updateConnDot();
|
||||
renderPage('dashboard');
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,844 @@
|
||||
// ============================================================
|
||||
// DNF90 GM 工具 — 页面模块
|
||||
// ============================================================
|
||||
|
||||
const Pages = {};
|
||||
|
||||
// 当前选中的角色(跨页面共享)
|
||||
let STATE = {
|
||||
currentCharacter: null, // {character_id, name, ...}
|
||||
};
|
||||
|
||||
function setCurrentCharacter(c) {
|
||||
STATE.currentCharacter = c;
|
||||
const hint = document.getElementById('selectedCharHint');
|
||||
if (hint) {
|
||||
hint.innerHTML = c
|
||||
? `当前角色:<span style="color:#b79bff;font-weight:700">${escapeHtml(c.name)}</span> (ID ${c.character_id})`
|
||||
: '未选择角色';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ 控制台
|
||||
Pages.dashboard = async (el) => {
|
||||
el.innerHTML = loadingHtml('正在获取服务端状态…');
|
||||
try {
|
||||
const [status, gameplay, channels] = await Promise.all([
|
||||
API.get('/api/status').catch(() => null),
|
||||
API.get('/api/gameplay').catch(() => null),
|
||||
API.get('/api/channels').catch(() => null),
|
||||
]);
|
||||
CONN.connected = !!status;
|
||||
updateConnDot();
|
||||
|
||||
const s = status || {};
|
||||
el.innerHTML = `
|
||||
<div class="grid grid-4">
|
||||
<div class="stat-card accent">
|
||||
<div class="label">在线角色</div>
|
||||
<div class="value">${s.online_characters ?? 0}</div>
|
||||
<div class="ico-bg">👥</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">等级上限</div>
|
||||
<div class="value">${s.level_cap ?? '—'}</div>
|
||||
<div class="ico-bg">⬆️</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">频道总数</div>
|
||||
<div class="value">${channels?.total_channels ?? '—'}</div>
|
||||
<div class="ico-bg">📡</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">仓库状态</div>
|
||||
<div class="value" style="font-size:20px">${s.repository_ready ? '✅ 就绪' : '⚠️ 异常'}</div>
|
||||
<div class="ico-bg">🗄️</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-title"><span class="dot"></span>服务端健康</div>
|
||||
<div class="grid grid-2">
|
||||
${kv('仓库仓储', s.repository_ready ? '就绪' : '不可用', s.repository_ready)}
|
||||
${kv('道具目录', s.item_catalog_ready ? '就绪' : '不可用', s.item_catalog_ready)}
|
||||
${kv('服务端地址', CONN.base_url || '未知', true)}
|
||||
${kv('等级上限', s.level_cap ?? '—', true)}
|
||||
</div>
|
||||
${s.error ? `<div class="hint" style="color:var(--red)">⚠ ${escapeHtml(s.error)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-title"><span class="dot"></span>玩法配置</div>
|
||||
${gameplay ? `
|
||||
<div class="grid grid-2">
|
||||
${kv('深渊史诗掉率', (gameplay.hell_drop_rate ?? 0) + '%', true)}
|
||||
${kv('史诗药剂测试倍率', (gameplay.epic_potion_test_multiplier ?? 0) + '%', true)}
|
||||
</div>` : '<div class="empty">暂无玩法配置数据</div>'}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-title"><span class="dot"></span>快速入口</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="Pages.goto('characters')">👥 角色管理</button>
|
||||
<button class="btn" onclick="Pages.goto('items')">🎁 道具发放</button>
|
||||
<button class="btn" onclick="Pages.goto('mail')">✉️ 邮件系统</button>
|
||||
<button class="btn" onclick="Pages.goto('quests')">📜 任务管理</button>
|
||||
<button class="btn" onclick="Pages.goto('dungeons')">⚔️ 副本控制</button>
|
||||
<button class="btn" onclick="Pages.goto('wallet')">💰 钱包点券</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = connectPanel();
|
||||
}
|
||||
};
|
||||
|
||||
function kv(label, value, ok) {
|
||||
const color = ok ? 'var(--text-0)' : 'var(--red)';
|
||||
return `<div style="padding:10px 0;border-bottom:1px solid var(--border)">
|
||||
<span style="color:var(--text-2);font-size:13px">${escapeHtml(label)}</span>
|
||||
<span style="float:right;color:${color};font-weight:600;font-size:13.5px">${escapeHtml(value)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function connectPanel() {
|
||||
return `
|
||||
<div class="card" style="max-width:640px;margin:40px auto">
|
||||
<div class="card-title"><span class="dot"></span>连接服务端</div>
|
||||
<p style="color:var(--text-2);font-size:13.5px;margin-bottom:18px;line-height:1.6">
|
||||
无法连接到本地 DNF90 服务端。请确认服务端已启动,或手动填写连接信息。
|
||||
</p>
|
||||
<div class="conn-panel">
|
||||
<div class="field" style="flex:1;min-width:120px">
|
||||
<label>主机</label>
|
||||
<input class="input" id="connHost" value="127.0.0.1">
|
||||
</div>
|
||||
<div class="field" style="width:110px">
|
||||
<label>端口</label>
|
||||
<input class="input" id="connPort" value="18111">
|
||||
</div>
|
||||
<div class="field" style="flex:1;min-width:160px">
|
||||
<label>Admin Token</label>
|
||||
<input class="input" id="connToken" placeholder="local-admin-token">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
|
||||
<button class="btn btn-primary" onclick="doConnect()">连接</button>
|
||||
<button class="btn" onclick="doDiscover()">🔍 自动发现</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function doConnect() {
|
||||
const host = document.getElementById('connHost').value.trim() || '127.0.0.1';
|
||||
const port = parseInt(document.getElementById('connPort').value) || 18111;
|
||||
const token = document.getElementById('connToken').value.trim();
|
||||
try {
|
||||
await API.post('/api/connect', { host, port, token });
|
||||
toast('连接成功', 'ok');
|
||||
CONN.connected = true;
|
||||
updateConnDot();
|
||||
renderCurrentPage();
|
||||
} catch (e) {
|
||||
toast(e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function doDiscover() {
|
||||
toast('正在自动发现服务端…', 'info');
|
||||
try {
|
||||
const r = await API.get('/api/discover');
|
||||
toast('发现服务端:' + r.base_url, 'ok');
|
||||
CONN.connected = true;
|
||||
updateConnDot();
|
||||
renderCurrentPage();
|
||||
} catch (e) {
|
||||
toast(e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ 角色管理
|
||||
Pages.characters = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>查询角色</div>
|
||||
<div class="search-row">
|
||||
<select class="select" id="charSearchType" style="width:130px">
|
||||
<option value="account">按账号</option>
|
||||
<option value="name">按角色名</option>
|
||||
<option value="username">按登录用户名</option>
|
||||
</select>
|
||||
<input class="input" id="charSearch" placeholder="输入账号 / 角色名 / 用户名,留空查全部">
|
||||
<button class="btn btn-primary" id="btnCharSearch">🔍 查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="charList" class="card" style="margin-top:16px"></div>
|
||||
`;
|
||||
const doSearch = async () => {
|
||||
const type = document.getElementById('charSearchType').value;
|
||||
const q = document.getElementById('charSearch').value.trim();
|
||||
const listEl = document.getElementById('charList');
|
||||
listEl.innerHTML = loadingHtml('查询角色中…');
|
||||
try {
|
||||
const params = {};
|
||||
if (q) {
|
||||
if (type === 'account') params.account_id = q;
|
||||
else if (type === 'name') params.character_name = q;
|
||||
else params.username = q;
|
||||
}
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const chars = await API.get('/api/characters' + (qs ? '?' + qs : ''));
|
||||
renderCharList(listEl, chars);
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="empty">查询失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
document.getElementById('btnCharSearch').addEventListener('click', doSearch);
|
||||
document.getElementById('charSearch').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||||
// 默认加载
|
||||
doSearch();
|
||||
};
|
||||
|
||||
function renderCharList(el, chars) {
|
||||
const list = Array.isArray(chars) ? chars : (chars?.data || []);
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<div class="empty">没有找到角色</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>角色列表 (${list.length})</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>角色</th><th>ID</th><th>账号ID</th><th>等级</th><th>金币</th><th>点券</th><th>状态</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${list.map(c => `
|
||||
<tr>
|
||||
<td><div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="avatar">${escapeHtml((c.name || '?')[0])}</div>
|
||||
<span style="font-weight:600">${escapeHtml(c.name)}</span>
|
||||
</div></td>
|
||||
<td>${c.character_id}</td>
|
||||
<td style="color:var(--text-2)">${escapeHtml(c.account_id || '—')}</td>
|
||||
<td><span class="tag tag-purple">Lv.${c.level ?? 0}</span></td>
|
||||
<td class="num-gold">${fmt(c.gold)}</td>
|
||||
<td class="num-cyan">${fmt(c.cera)}</td>
|
||||
<td>${c.online ? '<span class="tag tag-green">在线</span>' : '<span class="tag tag-gray">离线</span>'}</td>
|
||||
<td><div style="display:flex;gap:6px">
|
||||
<button class="btn btn-xs btn-primary" onclick="openCharDetail('${c.character_id}')">详情</button>
|
||||
<button class="btn btn-xs" onclick="openCharEdit('${c.character_id}','${escapeHtml(c.name||'')}',${c.level||0},${c.gold||0})">编辑</button>
|
||||
</div></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function openCharDetail(id) {
|
||||
openModal({ title: '角色详情', wide: true, body: loadingHtml('加载角色详情…') });
|
||||
const bodyEl = document.querySelector('.modal:last-of-type .modal-body');
|
||||
try {
|
||||
const [detail, inv] = await Promise.all([
|
||||
API.get(`/api/characters/${id}/detail`),
|
||||
API.get(`/api/characters/${id}/inventory`).catch(() => null),
|
||||
]);
|
||||
const c = detail.character || detail;
|
||||
bodyEl.innerHTML = `
|
||||
<div class="grid grid-4" style="margin-bottom:18px">
|
||||
${statMini('等级', c.level)}
|
||||
${statMini('金币', fmt(c.gold))}
|
||||
${statMini('职业', c.profession_name || c.job || '—')}
|
||||
${statMini('状态', c.online ? '在线' : '离线')}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
${kv('角色ID', c.character_id)}
|
||||
${kv('账号ID', c.account_id || '—')}
|
||||
${kv('角色名', c.name)}
|
||||
${kv('成长类型', c.grow_type ?? '—')}
|
||||
</div>
|
||||
<pre style="margin-top:16px;background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:12px;color:var(--text-2);max-height:300px">${escapeHtml(JSON.stringify(detail, null, 2))}</pre>`;
|
||||
} catch (e) {
|
||||
bodyEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function statMini(label, val) {
|
||||
return `<div class="stat-card" style="padding:12px 16px">
|
||||
<div class="label">${escapeHtml(label)}</div>
|
||||
<div class="value" style="font-size:20px">${escapeHtml(String(val ?? '—'))}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function openCharEdit(id, name, level, gold) {
|
||||
openModal({
|
||||
title: `编辑角色 · ${name}`,
|
||||
body: `
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>等级</label>
|
||||
<input class="input" id="editLevel" type="number" value="${level}" min="1" max="110">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>金币</label>
|
||||
<input class="input" id="editGold" type="number" value="${gold}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>技能点加成 (SP)</label>
|
||||
<input class="input" id="editSp" type="number" value="0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>技能点加成 (TP)</label>
|
||||
<input class="input" id="editTp" type="number" value="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">修改后角色需重新上线才完全生效。</div>`,
|
||||
foot: `<button class="btn" data-close>取消</button>
|
||||
<button class="btn btn-primary" onclick="saveCharEdit('${id}')">保存</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveCharEdit(id) {
|
||||
const level = parseInt(document.getElementById('editLevel').value);
|
||||
const gold = parseInt(document.getElementById('editGold').value);
|
||||
const sp = parseInt(document.getElementById('editSp').value);
|
||||
const tp = parseInt(document.getElementById('editTp').value);
|
||||
try {
|
||||
if (level > 0) await API.post(`/api/characters/${id}/level`, { level });
|
||||
if (!isNaN(gold)) await API.post(`/api/characters/${id}/gold`, { value: gold });
|
||||
if (sp || tp) await API.post(`/api/characters/${id}/skill-points`, { bonus_sp: sp, bonus_tp: tp });
|
||||
toast('修改成功', 'ok');
|
||||
document.querySelector('.modal:last-of-type').remove();
|
||||
renderCurrentPage();
|
||||
} catch (e) {
|
||||
toast(e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ 道具发放
|
||||
Pages.items = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>道具搜索</div>
|
||||
<div class="search-row">
|
||||
<input class="input" id="itemSearch" placeholder="输入道具名称或 ID 搜索">
|
||||
<button class="btn btn-primary" id="btnItemSearch">🔍 搜索</button>
|
||||
</div>
|
||||
<div id="itemSelected" class="hint" style="margin-top:10px"></div>
|
||||
</div>
|
||||
<div id="itemList" class="card" style="margin-top:16px"></div>
|
||||
`;
|
||||
|
||||
let selectedItem = null;
|
||||
const selEl = document.getElementById('itemSelected');
|
||||
|
||||
function renderSelected() {
|
||||
if (!selectedItem) { selEl.innerHTML = '未选择道具'; return; }
|
||||
selEl.innerHTML = `
|
||||
<span style="color:var(--text-1)">已选择:</span>
|
||||
<span class="${rarityColor(selectedItem.rarity)}" style="font-weight:700">
|
||||
${escapeHtml(selectedItem.name || selectedItem.item_id)}
|
||||
</span>
|
||||
<span class="tag tag-gray" style="margin-left:8px">ID ${selectedItem.item_id}</span>`;
|
||||
}
|
||||
|
||||
const doSearch = async () => {
|
||||
const q = document.getElementById('itemSearch').value.trim();
|
||||
const listEl = document.getElementById('itemList');
|
||||
listEl.innerHTML = loadingHtml('搜索道具中…');
|
||||
try {
|
||||
const items = await API.get('/api/items/search?q=' + encodeURIComponent(q) + '&limit=50');
|
||||
const list = Array.isArray(items) ? items : (items?.items || items?.data || []);
|
||||
if (!list.length) { listEl.innerHTML = '<div class="empty">没有找到道具</div>'; return; }
|
||||
listEl.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>搜索结果 (${list.length})</div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>道具</th><th>ID</th><th>稀有度</th><th>类型</th><th>等级</th><th>操作</th></tr></thead>
|
||||
<tbody>${list.map(it => `
|
||||
<tr>
|
||||
<td><span class="${rarityColor(it.rarity)}" style="font-weight:600">${escapeHtml(it.name || it.item_id)}</span></td>
|
||||
<td>${it.item_id}</td>
|
||||
<td><span class="${rarityColor(it.rarity)}">${rarityName(it.rarity)}</span></td>
|
||||
<td style="color:var(--text-2)">${escapeHtml(it.kind || it.type || '—')}</td>
|
||||
<td>${it.level ?? '—'}</td>
|
||||
<td><button class="btn btn-xs btn-primary" onclick="selectItem('${it.item_id}')">选择</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="empty">搜索失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
window.selectItem = (id) => {
|
||||
// 简单方式:重新查一次详情
|
||||
API.get(`/api/items/${id}/detail`).then(detail => {
|
||||
selectedItem = detail.item || detail || { item_id: id, name: '道具#' + id, rarity: 0 };
|
||||
renderSelected();
|
||||
toast('已选择道具', 'ok');
|
||||
}).catch(() => {
|
||||
selectedItem = { item_id: id, name: '道具#' + id, rarity: 0 };
|
||||
renderSelected();
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('btnItemSearch').addEventListener('click', doSearch);
|
||||
document.getElementById('itemSearch').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||||
};
|
||||
|
||||
// ============================================================ 邮件系统
|
||||
Pages.mail = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>发送邮件</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>收件角色名</label>
|
||||
<input class="input" id="mailName" placeholder="角色名">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>邮件标题</label>
|
||||
<input class="input" id="mailTitle" placeholder="标题">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>邮件正文</label>
|
||||
<textarea class="textarea" id="mailBody" placeholder="正文内容"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>附带金币</label>
|
||||
<input class="input" id="mailGold" type="number" value="0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>附件道具ID(可选)</label>
|
||||
<input class="input" id="mailItemId" type="number" placeholder="道具ID">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>道具数量</label>
|
||||
<input class="input" id="mailItemCount" type="number" value="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>有效期(天)</label>
|
||||
<input class="input" id="mailExpire" type="number" value="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
|
||||
<button class="btn btn-primary" onclick="sendSystemMail()">📨 发送系统邮件</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-title"><span class="dot"></span>指定角色发件</div>
|
||||
<div class="search-row">
|
||||
<input class="input" id="mailCharId" placeholder="角色ID">
|
||||
<button class="btn btn-primary" onclick="sendCharMail()">发送到角色</button>
|
||||
</div>
|
||||
<div class="hint">指定角色发件使用上方填写的标题、正文、道具与有效期。</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
async function sendSystemMail() {
|
||||
const payload = {
|
||||
character_name: document.getElementById('mailName').value.trim(),
|
||||
title: document.getElementById('mailTitle').value.trim(),
|
||||
body: document.getElementById('mailBody').value.trim(),
|
||||
gold: parseInt(document.getElementById('mailGold').value) || 0,
|
||||
};
|
||||
const itemId = parseInt(document.getElementById('mailItemId').value);
|
||||
if (itemId) {
|
||||
payload.attachments = [{
|
||||
item_id: itemId,
|
||||
count: parseInt(document.getElementById('mailItemCount').value) || 1,
|
||||
bind: false,
|
||||
}];
|
||||
}
|
||||
if (!payload.character_name) { toast('请输入收件角色名', 'err'); return; }
|
||||
try {
|
||||
await API.post('/api/system-mail', payload);
|
||||
toast('邮件已发送', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function sendCharMail() {
|
||||
const cid = document.getElementById('mailCharId').value.trim();
|
||||
if (!cid) { toast('请输入角色ID', 'err'); return; }
|
||||
const payload = {
|
||||
item_id: parseInt(document.getElementById('mailItemId').value) || 0,
|
||||
count: parseInt(document.getElementById('mailItemCount').value) || 1,
|
||||
title: document.getElementById('mailTitle').value.trim(),
|
||||
body: document.getElementById('mailBody').value.trim(),
|
||||
expire_days: parseInt(document.getElementById('mailExpire').value) || 7,
|
||||
};
|
||||
try {
|
||||
await API.post(`/api/characters/${cid}/mail`, payload);
|
||||
toast('邮件已发送', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ============================================================ 背包装备
|
||||
Pages.inventory = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>查看背包</div>
|
||||
<div class="search-row">
|
||||
<input class="input" id="invCharId" placeholder="输入角色ID">
|
||||
<button class="btn btn-primary" id="btnInvLoad">查看</button>
|
||||
<button class="btn btn-danger" id="btnInvClear">清空背包</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="invList" class="card" style="margin-top:16px"></div>
|
||||
`;
|
||||
document.getElementById('btnInvLoad').addEventListener('click', loadInventory);
|
||||
document.getElementById('btnInvClear').addEventListener('click', () => {
|
||||
const cid = document.getElementById('invCharId').value.trim();
|
||||
if (!cid) { toast('请输入角色ID', 'err'); return; }
|
||||
if (!confirm('确定要清空该角色背包吗?此操作不可逆!')) return;
|
||||
API.post(`/api/characters/${cid}/inventory/clear`, { container: 'inventory' })
|
||||
.then(() => { toast('背包已清空', 'ok'); loadInventory(); })
|
||||
.catch(e => toast(e.message, 'err'));
|
||||
});
|
||||
};
|
||||
|
||||
async function loadInventory() {
|
||||
const cid = document.getElementById('invCharId').value.trim();
|
||||
if (!cid) { toast('请输入角色ID', 'err'); return; }
|
||||
const listEl = document.getElementById('invList');
|
||||
listEl.innerHTML = loadingHtml('加载背包…');
|
||||
try {
|
||||
const inv = await API.get(`/api/characters/${cid}/inventory`);
|
||||
const items = inv.items || inv.data || [];
|
||||
listEl.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>背包内容 (${items.length})</div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>道具</th><th>ID</th><th>数量</th><th>稀有度</th><th>操作</th></tr></thead>
|
||||
<tbody>${items.map(it => `
|
||||
<tr>
|
||||
<td style="font-weight:600">${escapeHtml(it.name || it.item_name || it.item_id)}</td>
|
||||
<td>${it.item_id}</td>
|
||||
<td>${it.count ?? 1}</td>
|
||||
<td><span class="${rarityColor(it.rarity)}">${rarityName(it.rarity)}</span></td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="delInvItem('${cid}','${escapeHtml(it.key||it.item_id)}')">删除</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function delInvItem(cid, key) {
|
||||
if (!confirm('确定删除该道具?')) return;
|
||||
try {
|
||||
await API.post(`/api/characters/${cid}/inventory/delete`, { container: 'inventory', key, count: 1 });
|
||||
toast('已删除', 'ok');
|
||||
loadInventory();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ============================================================ 任务管理
|
||||
Pages.quests = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>任务查询</div>
|
||||
<div class="search-row">
|
||||
<input class="input" id="questCharId" placeholder="角色ID">
|
||||
<input class="input" id="questSearch" placeholder="任务名/ID(可选)">
|
||||
<button class="btn btn-primary" id="btnQuestLoad">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="questList" class="card" style="margin-top:16px"></div>
|
||||
`;
|
||||
document.getElementById('btnQuestLoad').addEventListener('click', loadQuests);
|
||||
};
|
||||
|
||||
async function loadQuests() {
|
||||
const cid = document.getElementById('questCharId').value.trim();
|
||||
if (!cid) { toast('请输入角色ID', 'err'); return; }
|
||||
const q = document.getElementById('questSearch').value.trim();
|
||||
const listEl = document.getElementById('questList');
|
||||
listEl.innerHTML = loadingHtml('加载任务…');
|
||||
try {
|
||||
let quests;
|
||||
if (q) {
|
||||
quests = await API.get(`/api/characters/${cid}/quests/search?q=${encodeURIComponent(q)}&limit=100`);
|
||||
} else {
|
||||
quests = await API.get(`/api/characters/${cid}/quests`);
|
||||
}
|
||||
const active = quests.active || [];
|
||||
const completed = quests.completed || [];
|
||||
listEl.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>进行中任务 (${active.length})</div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>任务</th><th>ID</th><th>类型</th><th>等级</th><th>操作</th></tr></thead>
|
||||
<tbody>${active.map(q => `
|
||||
<tr>
|
||||
<td style="font-weight:600">${escapeHtml(q.name)}</td>
|
||||
<td>${q.quest_id}</td>
|
||||
<td style="color:var(--text-2)">${escapeHtml(q.type || '—')}</td>
|
||||
<td>${q.level_min ?? '—'}</td>
|
||||
<td><div style="display:flex;gap:5px">
|
||||
<button class="btn btn-xs btn-primary" onclick="questAct('${cid}',${q.quest_id},'complete')">完成</button>
|
||||
<button class="btn btn-xs" onclick="questAct('${cid}',${q.quest_id},'give-up')">放弃</button>
|
||||
</div></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>
|
||||
${completed.length ? `
|
||||
<div class="card-title" style="margin-top:20px"><span class="dot"></span>已完成任务 (${completed.length})</div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>任务</th><th>ID</th><th>类型</th><th>操作</th></tr></thead>
|
||||
<tbody>${completed.map(q => `
|
||||
<tr>
|
||||
<td>${escapeHtml(q.name)}</td>
|
||||
<td>${q.quest_id}</td>
|
||||
<td style="color:var(--text-2)">${escapeHtml(q.type || '—')}</td>
|
||||
<td><button class="btn btn-xs" onclick="questAct('${cid}',${q.quest_id},'uncomplete')">撤销完成</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>` : ''}`;
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function questAct(cid, qid, action) {
|
||||
const labels = { complete: '完成', 'give-up': '放弃', uncomplete: '撤销完成', accept: '接受', ready: '就绪' };
|
||||
if (!confirm(`确定要「${labels[action]}」该任务吗?`)) return;
|
||||
try {
|
||||
await API.post(`/api/characters/${cid}/quests/${qid}/${action}`);
|
||||
toast('操作成功', 'ok');
|
||||
loadQuests();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ============================================================ 副本控制
|
||||
Pages.dungeons = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>副本控制</div>
|
||||
<div class="search-row">
|
||||
<input class="input" id="dungeonCharId" placeholder="角色ID">
|
||||
<button class="btn btn-primary" id="btnDungeonLoad">查看副本状态</button>
|
||||
<button class="btn" id="btnDungeonUnlockDiff">解锁全部难度</button>
|
||||
<button class="btn" id="btnDungeonUnlockHell">解锁深渊模式</button>
|
||||
<button class="btn btn-danger" id="btnDungeonReturn">安全回城</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dungeonInfo" class="card" style="margin-top:16px"></div>
|
||||
`;
|
||||
const cid = () => document.getElementById('dungeonCharId').value.trim();
|
||||
const load = async () => {
|
||||
if (!cid()) { toast('请输入角色ID', 'err'); return; }
|
||||
const infoEl = document.getElementById('dungeonInfo');
|
||||
infoEl.innerHTML = loadingHtml('加载副本状态…');
|
||||
try {
|
||||
const [dungeons, control] = await Promise.all([
|
||||
API.get(`/api/characters/${cid()}/dungeons`),
|
||||
API.get(`/api/characters/${cid()}/dungeon-control`).catch(() => null),
|
||||
]);
|
||||
const st = control?.state || {};
|
||||
infoEl.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>副本状态</div>
|
||||
<div class="grid grid-3">
|
||||
${statMini('是否在线', st.online ? '在线' : '离线')}
|
||||
${statMini('是否在副本', st.in_dungeon ? '是' : '否')}
|
||||
${statMini('运行状态', st.run_status || '—')}
|
||||
</div>
|
||||
${st.in_dungeon ? `
|
||||
<div class="form-row" style="margin-top:16px">
|
||||
${kv('副本ID', st.dungeon_id ?? '—')}
|
||||
${kv('地图ID', st.map_id ?? '—')}
|
||||
${kv('当前房间', st.current_room ?? '—')}
|
||||
${kv('已访问房间', st.visited_room_count ?? '—')}
|
||||
${kv('已清怪数', st.defeated_object_count ?? '—')}
|
||||
${kv('可回城', st.can_return_to_town ? '是' : '否')}
|
||||
</div>` : ''}
|
||||
<pre style="margin-top:16px;background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:12px;color:var(--text-2);max-height:260px">${escapeHtml(JSON.stringify(dungeons, null, 2))}</pre>`;
|
||||
} catch (e) {
|
||||
infoEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
document.getElementById('btnDungeonLoad').addEventListener('click', load);
|
||||
document.getElementById('btnDungeonUnlockDiff').addEventListener('click', async () => {
|
||||
if (!cid()) return toast('请输入角色ID', 'err');
|
||||
try { await API.post(`/api/characters/${cid()}/dungeons/unlock-difficulties`); toast('已解锁全部难度', 'ok'); } catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
document.getElementById('btnDungeonUnlockHell').addEventListener('click', async () => {
|
||||
if (!cid()) return toast('请输入角色ID', 'err');
|
||||
try { await API.post(`/api/characters/${cid()}/dungeons/unlock-hell`); toast('已解锁深渊模式', 'ok'); } catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
document.getElementById('btnDungeonReturn').addEventListener('click', async () => {
|
||||
if (!cid()) return toast('请输入角色ID', 'err');
|
||||
if (!confirm('确定要将该角色安全送回城镇吗?')) return;
|
||||
try { await API.post(`/api/characters/${cid()}/dungeon-return`); toast('已请求回城', 'ok'); load(); } catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================ 钱包点券
|
||||
Pages.wallet = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>钱包点券</div>
|
||||
<div class="search-row">
|
||||
<input class="input" id="walletCharId" placeholder="角色ID">
|
||||
<button class="btn btn-primary" id="btnWalletQuery">查询钱包</button>
|
||||
<button class="btn" onclick="bdStatus()">黑钻状态</button>
|
||||
<button class="btn btn-primary" onclick="bdActivate()">开通黑钻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="walletInfo" class="card" style="margin-top:16px"></div>
|
||||
`;
|
||||
document.getElementById('btnWalletQuery').addEventListener('click', queryWallet);
|
||||
};
|
||||
|
||||
async function queryWallet() {
|
||||
const cid = document.getElementById('walletCharId').value.trim();
|
||||
if (!cid) { toast('请输入角色ID', 'err'); return; }
|
||||
const infoEl = document.getElementById('walletInfo');
|
||||
infoEl.innerHTML = loadingHtml('查询钱包…');
|
||||
try {
|
||||
const wallet = await API.post(`/api/characters/${cid}/wallet/query`);
|
||||
infoEl.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>钱包信息</div>
|
||||
<pre style="background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:13px;color:var(--text-1)">${escapeHtml(JSON.stringify(wallet, null, 2))}</pre>`;
|
||||
} catch (e) {
|
||||
infoEl.innerHTML = `<div class="empty">查询失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function bdStatus() {
|
||||
const cid = document.getElementById('walletCharId').value.trim();
|
||||
try {
|
||||
const r = await API.get('/api/black-diamond' + (cid ? '?id=' + cid : ''));
|
||||
const infoEl = document.getElementById('walletInfo');
|
||||
infoEl.innerHTML = `<div class="card-title"><span class="dot"></span>黑钻状态</div>
|
||||
<pre style="background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:13px;color:var(--text-1)">${escapeHtml(JSON.stringify(r, null, 2))}</pre>`;
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function bdActivate() {
|
||||
const cid = document.getElementById('walletCharId').value.trim();
|
||||
if (!cid) { toast('请输入角色ID', 'err'); return; }
|
||||
const days = prompt('开通黑钻天数(默认30天)', '30');
|
||||
if (days === null) return;
|
||||
try {
|
||||
await API.post('/api/black-diamond/activate', { character_id: cid, days: parseInt(days) || 30 });
|
||||
toast('黑钻已开通', 'ok');
|
||||
bdStatus();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ============================================================ 玩法配置
|
||||
Pages.gameplay = async (el) => {
|
||||
el.innerHTML = `<div class="card"><div class="card-title"><span class="dot"></span>玩法配置</div></div>`;
|
||||
const card = el.querySelector('.card');
|
||||
try {
|
||||
const gp = await API.get('/api/gameplay');
|
||||
card.innerHTML = `
|
||||
<div class="card-title"><span class="dot"></span>玩法配置</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>深渊史诗掉率(%)</label>
|
||||
<input class="input" id="gpHellRate" type="number" value="${gp.hell_drop_rate ?? 0}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>史诗药剂测试倍率(%)</label>
|
||||
<input class="input" id="gpPotion" type="number" value="${gp.epic_potion_test_multiplier ?? 0}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
|
||||
<button class="btn btn-primary" onclick="saveGameplay()">保存配置</button>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
card.innerHTML = `<div class="card-title"><span class="dot"></span>玩法配置</div>
|
||||
<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
async function saveGameplay() {
|
||||
const hellRate = parseInt(document.getElementById('gpHellRate').value) || 0;
|
||||
const potion = parseInt(document.getElementById('gpPotion').value) || 0;
|
||||
try {
|
||||
await API.post('/api/gameplay/hell-drop-rate', { percent: hellRate });
|
||||
await API.post('/api/gameplay/epic-potion-multiplier', { percent: potion });
|
||||
toast('配置已保存', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ============================================================ 频道管理
|
||||
Pages.channels = async (el) => {
|
||||
el.innerHTML = loadingHtml('加载频道…');
|
||||
try {
|
||||
const channels = await API.get('/api/channels');
|
||||
const list = channels.channels || [];
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>频道总览</div>
|
||||
<div class="grid grid-4">
|
||||
${statMini('频道总数', channels.total_channels)}
|
||||
${statMini('已启用', channels.enabled_channels)}
|
||||
${statMini('游戏端口', channels.game_ports)}
|
||||
${statMini('单机频道', (channels.single_player_ids || []).join(', ') || '—')}
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
|
||||
<button class="btn btn-primary" onclick="reloadChannels()">🔄 热重载频道</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-title"><span class="dot"></span>频道列表</div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>分组</th><th>端口</th><th>类型</th><th>状态</th></tr></thead>
|
||||
<tbody>${list.map(c => `
|
||||
<tr>
|
||||
<td>${c.id}</td>
|
||||
<td style="font-weight:600">${escapeHtml(c.name || '—')}</td>
|
||||
<td style="color:var(--text-2)">${escapeHtml(c.group || '—')}</td>
|
||||
<td>${c.port ?? '—'}</td>
|
||||
<td>${c.type === 0 ? '普通' : '特殊'}</td>
|
||||
<td>${c.enabled ? '<span class="tag tag-green">启用</span>' : '<span class="tag tag-gray">禁用</span>'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="card"><div class="empty">加载失败:${escapeHtml(e.message)}</div></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
async function reloadChannels() {
|
||||
try {
|
||||
await API.post('/api/channels/reload');
|
||||
toast('频道已热重载', 'ok');
|
||||
renderCurrentPage();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ============================================================ 连接设置
|
||||
Pages.settings = async (el) => {
|
||||
el.innerHTML = `
|
||||
<div class="card" style="max-width:640px">
|
||||
<div class="card-title"><span class="dot"></span>连接设置</div>
|
||||
<div class="conn-panel">
|
||||
<div class="field" style="flex:1;min-width:120px">
|
||||
<label>主机</label>
|
||||
<input class="input" id="connHost" value="127.0.0.1">
|
||||
</div>
|
||||
<div class="field" style="width:110px">
|
||||
<label>端口</label>
|
||||
<input class="input" id="connPort" value="18111">
|
||||
</div>
|
||||
<div class="field" style="flex:1;min-width:160px">
|
||||
<label>Admin Token</label>
|
||||
<input class="input" id="connToken" placeholder="local-admin-token">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
|
||||
<button class="btn btn-primary" onclick="doConnect()">连接</button>
|
||||
<button class="btn" onclick="doDiscover()">🔍 自动发现</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:14px">当前后端地址:${CONN.base_url || '未连接'}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
Pages.goto = (page) => {
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
|
||||
renderPage(page);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = [('index.html', '.'), ('style.css', '.'), ('shell.css', '.'), ('app.js', '.'), ('shell.js', '.'), ('sponsor.png', '.'), ('db.py', '.'), ('native.py', '.'), ('launcher.ico', '.')]
|
||||
binaries = []
|
||||
hiddenimports = ['clr_loader', 'pythonnet']
|
||||
tmp_ret = collect_all('webview')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['server.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='DNF90启动器',
|
||||
icon='launcher.ico',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
# 整合启动器 + GM 管理工具
|
||||
|
||||
单 EXE 的 DNF90 启动器,内置完整 GM 管理工具(角色管理、道具发放、邮件、背包装备、拍卖行、任务、副本、钱包点券、删档等)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Python 3.15**(32 位)+ **pywebview 6.x**(Edge WebView2 内嵌窗口)
|
||||
- 纯标准库 HTTP 后端 + 原生 HTML/CSS/JS 前端
|
||||
- **PyInstaller** 打包单文件 EXE
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server.py` | 整合后端(启动器 + GM 工具,纯标准库) |
|
||||
| `db.py` | SQLite 数据访问层(删角色/删档/疲劳/物品名缓存) |
|
||||
| `native.py` | Native API 客户端(完整 GM 端点) |
|
||||
| `index.html` / `style.css` / `app.js` | 双视图前端 |
|
||||
| `DNF90启动器.spec` | PyInstaller 打包配置 |
|
||||
| `sponsor.png` / `sponsor_qr.png` | 赞助二维码 |
|
||||
|
||||
## 打包
|
||||
|
||||
```bash
|
||||
cd launcher
|
||||
python -m pip install pywebview pythonnet clr-loader pyinstaller
|
||||
python -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
|
||||
# 产物:dist/DNF90启动器.exe
|
||||
```
|
||||
|
||||
> 详见 [`docs/开发文档.md`](../docs/开发文档.md) 完整说明(环境搭建、架构、API 契约、常见问题)。
|
||||
+2324
File diff suppressed because it is too large.
Load diff
+801
@@ -0,0 +1,801 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SQLite data-access layer for the DNF90 GM tool.
|
||||
|
||||
The server stores almost everything as JSON blobs in dnf_repository_records
|
||||
(record_kind + record_key -> payload). This module reads/writes those blobs
|
||||
with a small amount of schema-aware convenience on top.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def _resource_dir():
|
||||
"""资源目录(PyInstaller 打包时资源在 _MEIPASS,否则脚本目录)。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
# 物品名称映射缓存(item_id -> name),从外部 GM配置 目录的 dnf_items.json 惰性加载。
|
||||
# 该文件由 pvfexport.exe 从 Script.pvf 导出,离线也能显示物品名称。
|
||||
# 服务端(SERVER_ROOT)与 GM配置 目录由 server.py 启动时通过 set_gm_config 注入。
|
||||
_ITEM_NAMES = None
|
||||
_ITEM_NAMES_LOADED = False
|
||||
_GM_CONFIG_DIR = ""
|
||||
_PVF_PATH = ""
|
||||
_EXPORT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def set_gm_config(gm_config_dir, pvf_path):
|
||||
"""由 server.py 启动时注入 GM配置 目录与 Script.pvf 路径(可多次调用重置)。"""
|
||||
global _GM_CONFIG_DIR, _PVF_PATH, _ITEM_NAMES, _ITEM_NAMES_LOADED
|
||||
_GM_CONFIG_DIR = gm_config_dir or ""
|
||||
_PVF_PATH = pvf_path or ""
|
||||
_ITEM_NAMES = None
|
||||
_ITEM_NAMES_LOADED = False
|
||||
|
||||
|
||||
def _items_json_path():
|
||||
return os.path.join(_GM_CONFIG_DIR, "dnf_items.json") if _GM_CONFIG_DIR else ""
|
||||
|
||||
|
||||
def _find_pvf_path():
|
||||
"""返回 Script.pvf 路径;优先 _PVF_PATH,否则在 GM配置 上级的 Client/ 下查找。"""
|
||||
if _PVF_PATH and os.path.exists(_PVF_PATH):
|
||||
return _PVF_PATH
|
||||
# 兜底:GM配置 目录上级的 Client/Script.pvf 或 Server/deploy/assets/Script.pvf
|
||||
if _GM_CONFIG_DIR:
|
||||
root = os.path.dirname(_GM_CONFIG_DIR)
|
||||
for cand in (
|
||||
os.path.join(root, "Client", "Script.pvf"),
|
||||
os.path.join(root, "Server", "deploy", "assets", "Script.pvf"),
|
||||
):
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
return _PVF_PATH
|
||||
|
||||
|
||||
def _run_pvfexport(items_path, pvf_path):
|
||||
"""调用 pvfexport.exe 重新导出名称表,成功返回 True。"""
|
||||
exe = os.path.join(_GM_CONFIG_DIR, "pvfexport.exe")
|
||||
if not os.path.exists(exe):
|
||||
return False
|
||||
try:
|
||||
# 用 subprocess 调 Windows 程序,路径须为 Windows 风格(反斜杠/盘符)。
|
||||
exe_win = exe
|
||||
pvf_win = pvf_path.replace("/", "\\")
|
||||
out_win = items_path.replace("/", "\\")
|
||||
proc = subprocess.run(
|
||||
[exe_win, "-pvf", pvf_win, "-out", out_win, "-compact"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
return proc.returncode == 0 and os.path.exists(items_path)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_item_names():
|
||||
"""加载 dnf_items.json({item_id: name} 映射),失败返回空 dict。
|
||||
|
||||
PVF 比名称表新(或名称表缺失)时,自动调用 pvfexport.exe 重新导出。
|
||||
"""
|
||||
global _ITEM_NAMES, _ITEM_NAMES_LOADED
|
||||
if _ITEM_NAMES_LOADED:
|
||||
return _ITEM_NAMES
|
||||
_ITEM_NAMES_LOADED = True
|
||||
_ITEM_NAMES = {}
|
||||
items_path = _items_json_path()
|
||||
pvf_path = _find_pvf_path()
|
||||
if not items_path or not pvf_path:
|
||||
# 回退到打包资源目录(旧行为,兼容无 GM配置 目录的情况)
|
||||
candidate = os.path.join(_resource_dir(), "dnf_items.json")
|
||||
if os.path.exists(candidate):
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
_ITEM_NAMES = json.load(f)
|
||||
except Exception:
|
||||
_ITEM_NAMES = {}
|
||||
return _ITEM_NAMES
|
||||
|
||||
# 需要重导出:名称表缺失,或 PVF 修改时间晚于名称表
|
||||
need_export = not os.path.exists(items_path)
|
||||
if not need_export:
|
||||
try:
|
||||
need_export = os.path.getmtime(pvf_path) > os.path.getmtime(items_path)
|
||||
except OSError:
|
||||
need_export = True
|
||||
|
||||
if need_export:
|
||||
with _EXPORT_LOCK:
|
||||
# 二次确认(避免并发重复触发)
|
||||
need_export = not os.path.exists(items_path)
|
||||
if not need_export:
|
||||
try:
|
||||
need_export = os.path.getmtime(pvf_path) > os.path.getmtime(items_path)
|
||||
except OSError:
|
||||
need_export = True
|
||||
if need_export:
|
||||
_run_pvfexport(items_path, pvf_path)
|
||||
|
||||
if os.path.exists(items_path):
|
||||
try:
|
||||
with open(items_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
_ITEM_NAMES = data
|
||||
except Exception:
|
||||
_ITEM_NAMES = {}
|
||||
return _ITEM_NAMES
|
||||
|
||||
|
||||
def item_name(item_id):
|
||||
"""返回 item_id 对应的物品名称,未知则返回空字符串。"""
|
||||
names = _load_item_names()
|
||||
return names.get(str(item_id), "")
|
||||
|
||||
|
||||
class DNFDatabase(object):
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.con = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def connect(self):
|
||||
if self.con is None:
|
||||
self.con = sqlite3.connect(self.path, check_same_thread=False)
|
||||
self.con.row_factory = sqlite3.Row
|
||||
return self.con
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
if self.con is not None:
|
||||
self.con.close()
|
||||
self.con = None
|
||||
|
||||
def _exec(self, fn):
|
||||
"""run fn(connection) under the thread lock."""
|
||||
with self._lock:
|
||||
return fn(self.connect())
|
||||
|
||||
# ---- low level -------------------------------------------------
|
||||
def list_tables(self):
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
return [r["name"] for r in cur.fetchall()]
|
||||
return self._exec(fn)
|
||||
|
||||
def list_records(self):
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT record_kind, record_key, length(payload) AS size "
|
||||
"FROM dnf_repository_records ORDER BY record_kind, record_key")
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
return self._exec(fn)
|
||||
|
||||
def get_record(self, kind, key):
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT payload, checksum, updated_at FROM dnf_repository_records "
|
||||
"WHERE record_kind=? AND record_key=?", (kind, key))
|
||||
r = cur.fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
return {"payload": r["payload"], "checksum": r["checksum"],
|
||||
"updated_at": r["updated_at"]}
|
||||
return self._exec(fn)
|
||||
|
||||
def get_record_json(self, kind, key):
|
||||
r = self.get_record(kind, key)
|
||||
if r is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(r["payload"].decode("utf-8"))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
def put_record_json(self, kind, key, obj):
|
||||
"""overwrite a record's JSON payload, recomputing the sha256 checksum."""
|
||||
blob = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
checksum = hashlib.sha256(blob).hexdigest()
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute("UPDATE dnf_repository_records SET payload=?, checksum=? "
|
||||
"WHERE record_kind=? AND record_key=?",
|
||||
(sqlite3.Binary(blob), checksum, kind, key))
|
||||
if cur.rowcount == 0:
|
||||
cur.execute("INSERT INTO dnf_repository_records "
|
||||
"(record_kind, record_key, payload, checksum, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(kind, key, sqlite3.Binary(blob), checksum, ""))
|
||||
con.commit()
|
||||
self._exec(fn)
|
||||
|
||||
# ---- domain helpers -------------------------------------------
|
||||
def list_accounts(self):
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT account_id, username, state, created_at, last_login_at "
|
||||
"FROM dnf_login_accounts ORDER BY username")
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
return self._exec(fn)
|
||||
|
||||
def list_characters(self):
|
||||
"""character records (record_kind='character') with parsed summary."""
|
||||
def fn(con):
|
||||
out = []
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT record_key, payload FROM dnf_repository_records "
|
||||
"WHERE record_kind='character' ORDER BY record_key")
|
||||
for r in cur.fetchall():
|
||||
try:
|
||||
ch = json.loads(r["payload"].decode("utf-8"))
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
out.append({
|
||||
"character_id": ch.get("character_id", r["record_key"]),
|
||||
"account_id": ch.get("account_id", ""),
|
||||
"name": ch.get("name", ""),
|
||||
"job": ch.get("job", "0"),
|
||||
"level": ch.get("level", 0),
|
||||
"updated_at": ch.get("updated_at", ""),
|
||||
})
|
||||
return out
|
||||
return self._exec(fn)
|
||||
|
||||
def get_character(self, char_id):
|
||||
return self.get_record_json("character", str(char_id))
|
||||
|
||||
def get_character_stats(self, char_id):
|
||||
ch = self.get_character(str(char_id))
|
||||
return (ch or {}).get("stats", {}) if ch else None
|
||||
|
||||
def set_character_field(self, char_id, field, value):
|
||||
"""set a top-level field (name/job/level) on a character record."""
|
||||
ch = self.get_character(str(char_id))
|
||||
if ch is None:
|
||||
raise KeyError("角色 %s 不存在" % char_id)
|
||||
ch[field] = value
|
||||
self.put_record_json("character", str(char_id), ch)
|
||||
|
||||
def set_character_stat(self, char_id, stat_name, value):
|
||||
"""set a value inside character.stats."""
|
||||
ch = self.get_character(str(char_id))
|
||||
if ch is None:
|
||||
raise KeyError("角色 %s 不存在" % char_id)
|
||||
ch.setdefault("stats", {})[stat_name] = value
|
||||
self.put_record_json("character", str(char_id), ch)
|
||||
|
||||
def grant_item(self, char_id, item_id, count=1):
|
||||
"""add an item to a character's inventory (SQLite mode).
|
||||
|
||||
slot keys are "{container}:{slot}" (e.g. "0:23"). We append to the
|
||||
next free slot of container 0 (the main bag)."""
|
||||
inv = self.get_record_json("inventory", str(char_id)) or {}
|
||||
slots = inv.setdefault("slots", {})
|
||||
used = set()
|
||||
for k in slots:
|
||||
if ":" in k:
|
||||
cont, _, slot = k.partition(":")
|
||||
if cont == "0" and slot.isdigit():
|
||||
used.add(int(slot))
|
||||
slot = 1
|
||||
while slot in used:
|
||||
slot += 1
|
||||
slots["0:%d" % slot] = {"item_id": item_id, "count": count,
|
||||
"expire_at": "0001-01-01T00:00:00Z"}
|
||||
inv["updated_at"] = self._now_iso()
|
||||
self.put_record_json("inventory", str(char_id), inv)
|
||||
return {"slot": "0:%d" % slot}
|
||||
|
||||
def inventory_list_slots(self, char_id):
|
||||
"""列出角色背包所有行(SQLite 直读),供「背包管理」展示。
|
||||
|
||||
返回 [{"collection":"slots","entry_key":key,"item_id":..,"count":..,"bind":..}, ...]。
|
||||
key 格式 "{container}:{slot}"(如 "0:23")。"""
|
||||
inv = self.get_record_json("inventory", str(char_id)) or {}
|
||||
slots = inv.get("slots", {})
|
||||
out = []
|
||||
for key, v in sorted(slots.items()):
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
iid = v.get("item_id", 0)
|
||||
out.append({
|
||||
"collection": "slots",
|
||||
"entry_key": key,
|
||||
"item_id": iid,
|
||||
"count": int(v.get("count", 0) or 0),
|
||||
"bind": bool(v.get("bind", False)),
|
||||
"item_name": item_name(iid),
|
||||
})
|
||||
return out
|
||||
|
||||
def inventory_delete_slot(self, char_id, entry_key):
|
||||
"""删除背包里指定的一行(SQLite 直读),key 格式 "{container}:{slot}"。"""
|
||||
inv = self.get_record_json("inventory", str(char_id))
|
||||
if inv is None:
|
||||
raise KeyError("角色 %s 的背包记录不存在" % char_id)
|
||||
slots = inv.get("slots", {})
|
||||
if entry_key not in slots:
|
||||
raise KeyError("背包行 %s 不存在" % entry_key)
|
||||
removed = slots.pop(entry_key)
|
||||
inv["slots"] = slots
|
||||
inv["updated_at"] = self._now_iso()
|
||||
self.put_record_json("inventory", str(char_id), inv)
|
||||
return {
|
||||
"entry_key": entry_key,
|
||||
"item_id": removed.get("item_id", 0) if isinstance(removed, dict) else 0,
|
||||
"removed": removed.get("count", 1) if isinstance(removed, dict) else 1,
|
||||
}
|
||||
|
||||
def set_fatigue(self, char_id, fatigue, fatigue_limit=None):
|
||||
"""设置疲劳值(fatigue)与疲劳上限(fatigue_limit)。
|
||||
|
||||
- fatigue : 当前疲劳值(剩余疲劳)
|
||||
- fatigue_limit : 疲劳上限(每天 6 点重置回该值,默认 156 / 黑钻 188)
|
||||
|
||||
所谓「无限疲劳」即把两者都设成一个很大的值,同时把已用疲劳
|
||||
(fatigue_display_update)清零,保证客户端显示正确。
|
||||
"""
|
||||
ch = self.get_character(str(char_id))
|
||||
if ch is None:
|
||||
raise KeyError("角色 %s 不存在" % char_id)
|
||||
stats = ch.setdefault("stats", {})
|
||||
fatigue = int(fatigue)
|
||||
if fatigue < 0:
|
||||
fatigue = 0
|
||||
stats["fatigue"] = fatigue
|
||||
if fatigue_limit is not None:
|
||||
limit = int(fatigue_limit)
|
||||
if limit < 0:
|
||||
limit = 0
|
||||
stats["fatigue_limit"] = limit
|
||||
# 已用疲劳 = 上限 - 当前,客户端据此显示;拉满时清零
|
||||
limit = stats.get("fatigue_limit", 0)
|
||||
used = max(0, limit - fatigue)
|
||||
stats["fatigue_display_update"] = used
|
||||
ch["updated_at"] = self._now_iso()
|
||||
self.put_record_json("character", str(char_id), ch)
|
||||
return {"fatigue": stats["fatigue"],
|
||||
"fatigue_limit": stats.get("fatigue_limit", 0),
|
||||
"used": used}
|
||||
|
||||
def get_fatigue(self, char_id):
|
||||
"""读取角色的疲劳相关信息。"""
|
||||
ch = self.get_character(str(char_id))
|
||||
if ch is None:
|
||||
return None
|
||||
stats = (ch or {}).get("stats", {})
|
||||
fatigue = stats.get("fatigue")
|
||||
if fatigue is None:
|
||||
# 兼容旧字段名
|
||||
for key in ("fp", "疲劳"):
|
||||
if key in stats:
|
||||
fatigue = stats[key]
|
||||
break
|
||||
return {
|
||||
"fatigue": fatigue,
|
||||
"fatigue_limit": stats.get("fatigue_limit"),
|
||||
"used": stats.get("fatigue_display_update"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _now_iso():
|
||||
import datetime
|
||||
return datetime.datetime.now().isoformat()
|
||||
|
||||
# ---- delete / wipe (dangerous, irreversible) ------------------
|
||||
def delete_character(self, char_id):
|
||||
"""Delete a character and every record that belongs to it.
|
||||
|
||||
Record kinds whose key is the raw character id (character, equipment,
|
||||
inventory, mailbox, quest, skill, dungeon_permission) plus the
|
||||
`character:{id}:...` settings keys are removed. Account-scoped data
|
||||
(account, account_inventory, account-v1:* dungeon permission) is kept.
|
||||
Returns the number of rows deleted.
|
||||
"""
|
||||
cid = str(char_id)
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
# character-scoped records keyed directly by the id
|
||||
kinds = ("character", "equipment", "inventory", "mailbox",
|
||||
"quest", "skill", "dungeon_permission")
|
||||
qmarks = ",".join("?" for _ in kinds)
|
||||
cur.execute(
|
||||
"DELETE FROM dnf_repository_records "
|
||||
"WHERE (record_kind IN (%s) AND record_key=?) "
|
||||
" OR record_key LIKE ?" % qmarks,
|
||||
tuple(kinds) + (cid, "character:" + cid + ":%"))
|
||||
deleted = cur.rowcount
|
||||
con.commit()
|
||||
return deleted
|
||||
return self._exec(fn)
|
||||
|
||||
def delete_account(self, account_id):
|
||||
"""Delete an account and all records referencing it.
|
||||
|
||||
Removes the dnf_login_accounts row and every record whose key is the
|
||||
account id, the `account:{id}:...` settings keys, or an `account-v1:`
|
||||
dungeon-permission key belonging to that account (the latter requires
|
||||
reading the account-v1 key to resolve its owner, so here we delete by
|
||||
exact key match plus the account-scoped prefix matches we know).
|
||||
"""
|
||||
aid = str(account_id)
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
# login account row
|
||||
cur.execute("DELETE FROM dnf_login_accounts WHERE account_id=?", (aid,))
|
||||
n1 = cur.rowcount
|
||||
# repository records: exact key, or account-prefixed settings
|
||||
cur.execute(
|
||||
"DELETE FROM dnf_repository_records "
|
||||
"WHERE record_key=? OR record_key LIKE ?",
|
||||
(aid, "account:" + aid + ":%"))
|
||||
n2 = cur.rowcount
|
||||
con.commit()
|
||||
return {"account_rows": n1, "record_rows": n2}
|
||||
return self._exec(fn)
|
||||
|
||||
def list_character_record_kinds(self, char_id):
|
||||
"""Return the record_kind/record_key rows belonging to a character,
|
||||
for the confirmation UI to show exactly what will be deleted."""
|
||||
cid = str(char_id)
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute(
|
||||
"SELECT record_kind, record_key FROM dnf_repository_records "
|
||||
"WHERE record_key=? OR record_key LIKE ? "
|
||||
"ORDER BY record_kind",
|
||||
(cid, "character:" + cid + ":%"))
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
return self._exec(fn)
|
||||
|
||||
def list_account_record_kinds(self, account_id):
|
||||
"""Return records belonging to an account (for confirmation UI)."""
|
||||
aid = str(account_id)
|
||||
def fn(con):
|
||||
cur = con.cursor()
|
||||
cur.execute(
|
||||
"SELECT record_kind, record_key FROM dnf_repository_records "
|
||||
"WHERE record_key=? OR record_key LIKE ? "
|
||||
"ORDER BY record_kind",
|
||||
(aid, "account:" + aid + ":%"))
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
return self._exec(fn)
|
||||
|
||||
# ==================================================================
|
||||
# 拍卖行(上下架工具)
|
||||
#
|
||||
# 服务端把挂单全部存放在托管角色 system:auction:escrow 的 inventory
|
||||
# 记录里,slots 的 key 为 auction:listing:<id>,价格/卖家/时间等字段
|
||||
# 编码在 ItemStack.extra 里(见 90server go-server 的 auction/owner.go)。
|
||||
# 这里按同一套数据结构直接读写 SQLite,行为与游戏内一致。
|
||||
# ==================================================================
|
||||
AUCTION_ESCROW_CHAR = "system:auction:escrow"
|
||||
AUCTION_LISTING_PREFIX = "auction:listing:"
|
||||
AUCTION_DELIVERY_PREFIX = "auction:delivery:"
|
||||
AUCTION_REFUND_PREFIX = "auction:refund:"
|
||||
# 上架押金 / 手续费(与服务端常量一致)
|
||||
AUCTION_DEPOSIT = 10000 # 上架押金(取消上架不退)
|
||||
AUCTION_SALE_FEE_PERCENT = 5 # 成交手续费(%)
|
||||
# GM 挂单 ID 使用高位段,避免与服务端进程内计数器(重启后从 1 递增)碰撞
|
||||
AUCTION_GM_ID_BASE = 9000000000000000000
|
||||
|
||||
def _auction_escrow(self):
|
||||
"""读取(或初始化)拍卖行托管角色的 inventory 记录。"""
|
||||
return self.get_record_json("inventory", self.AUCTION_ESCROW_CHAR) or {}
|
||||
|
||||
def _auction_save_escrow(self, escrow):
|
||||
escrow.setdefault("character_id", self.AUCTION_ESCROW_CHAR)
|
||||
escrow.setdefault("slots", {})
|
||||
escrow["updated_at"] = self._now_iso()
|
||||
self.put_record_json("inventory", self.AUCTION_ESCROW_CHAR, escrow)
|
||||
|
||||
@staticmethod
|
||||
def _auction_next_id(slots):
|
||||
"""返回一个未被占用、且落在高位段的挂单 ID。
|
||||
|
||||
服务端的挂单 ID 是进程内计数器(重启后从 1 重新开始,碰撞时重试 8 次),
|
||||
因此 GM 工具造的挂单若占用小 ID,会让重启后的服务端连续撞号。这里直接
|
||||
使用一个极大的固定段,既不会与进程内计数器冲突,也能通过单调递增避免
|
||||
已产生过投递/退款的 ID 被复用。"""
|
||||
best = DNFDatabase.AUCTION_GM_ID_BASE - 1
|
||||
for key in slots:
|
||||
if not key.startswith(DNFDatabase.AUCTION_LISTING_PREFIX):
|
||||
continue
|
||||
try:
|
||||
best = max(best, int(key[len(DNFDatabase.AUCTION_LISTING_PREFIX):]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return best + 1
|
||||
|
||||
def auction_list_listings(self):
|
||||
"""返回当前所有挂单(含已过期的),解析成前端友好的结构。"""
|
||||
escrow = self._auction_escrow()
|
||||
slots = escrow.get("slots", {})
|
||||
out = []
|
||||
for key, stack in slots.items():
|
||||
if not key.startswith(self.AUCTION_LISTING_PREFIX):
|
||||
continue
|
||||
if not isinstance(stack, dict):
|
||||
continue
|
||||
extra = stack.get("extra", {}) or {}
|
||||
listing_id = key[len(self.AUCTION_LISTING_PREFIX):]
|
||||
try:
|
||||
unit_price = int(extra.get("unit_price", "0") or 0)
|
||||
start_price = int(extra.get("start_price", "-1") or -1)
|
||||
buyout_price = int(extra.get("buyout_price", "-1") or -1)
|
||||
deposit = int(extra.get("deposit", "0") or 0)
|
||||
fee_percent = int(extra.get("fee_percent", "0") or 0)
|
||||
bid_total = int(extra.get("bid_total", "0") or 0)
|
||||
created_at = int(extra.get("created_at", "0") or 0)
|
||||
expires_at = int(extra.get("expires_at", "0") or 0)
|
||||
source_slot = extra.get("source_slot", "")
|
||||
list_type = extra.get("list_type", "0")
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
count = int(stack.get("count", 1) or 1)
|
||||
now = int(time.time())
|
||||
out.append({
|
||||
"listing_id": listing_id,
|
||||
"item_id": stack.get("item_id", 0),
|
||||
"count": count,
|
||||
"seller_id": extra.get("seller_id", ""),
|
||||
"seller_name": extra.get("seller_name", ""),
|
||||
"list_type": list_type,
|
||||
"source_slot": source_slot,
|
||||
"unit_price": unit_price,
|
||||
"start_price": start_price,
|
||||
"buyout_price": buyout_price,
|
||||
"deposit": deposit,
|
||||
"fee_percent": fee_percent,
|
||||
"bid_total": bid_total,
|
||||
"bidder_id": extra.get("bidder_id", ""),
|
||||
"created_at": created_at,
|
||||
"expires_at": expires_at,
|
||||
"expired": expires_at > 0 and expires_at < now,
|
||||
})
|
||||
# 新→旧排序(GM 高位段 id 天然更大,排前面)
|
||||
out.sort(key=lambda x: x["listing_id"], reverse=True)
|
||||
return out
|
||||
|
||||
def auction_escrow_gold(self):
|
||||
"""返回托管角色的金币(用于核对)。"""
|
||||
ch = self.get_character(self.AUCTION_ESCROW_CHAR)
|
||||
if not ch:
|
||||
return 0
|
||||
return int((ch.get("stats") or {}).get("gold", 0) or 0)
|
||||
|
||||
def auction_list(self, seller_id, item_id, count, unit_price,
|
||||
start_price=None, buyout_price=None, duration_hours=24):
|
||||
"""上架一件物品。
|
||||
|
||||
行为对齐服务端 registerWithin:
|
||||
- 从卖家背包移除物品,写入 escrow 挂单槽
|
||||
- 押金从卖家金币扣除,转入 escrow 角色金币
|
||||
- 挂单 Extra 记录全部价格/时间/卖家字段
|
||||
|
||||
:param start_price: 每件起拍价(-1 表示不设竞拍,一口价模式)
|
||||
:param buyout_price: 每件一口价(-1 表示不设一口价)
|
||||
"""
|
||||
seller_id = str(seller_id)
|
||||
item_id = int(item_id)
|
||||
count = int(count)
|
||||
unit_price = int(unit_price)
|
||||
if unit_price <= 0:
|
||||
raise ValueError("单价必须大于 0")
|
||||
if count <= 0:
|
||||
raise ValueError("数量必须大于 0")
|
||||
|
||||
# 一口价:未显式给定时默认 = 单价;起拍价:未给定时默认 -1(无竞拍)
|
||||
if buyout_price is None:
|
||||
buyout_price = unit_price
|
||||
else:
|
||||
buyout_price = int(buyout_price)
|
||||
if start_price is None:
|
||||
start_price = -1
|
||||
else:
|
||||
start_price = int(start_price)
|
||||
# 校验:至少一口价或起拍价其一有效
|
||||
if buyout_price <= 0 and start_price <= 0:
|
||||
raise ValueError("一口价和起拍价不能同时为空")
|
||||
if start_price > 0 and start_price > unit_price:
|
||||
raise ValueError("起拍价不能高于单价")
|
||||
|
||||
# 卖家角色
|
||||
seller = self.get_character(seller_id)
|
||||
if seller is None:
|
||||
raise KeyError("卖家角色 %s 不存在" % seller_id)
|
||||
seller_name = seller.get("name", "")
|
||||
seller_account = seller.get("account_id", "")
|
||||
|
||||
# 卖家背包
|
||||
inv = self.get_record_json("inventory", seller_id) or {}
|
||||
slots = inv.setdefault("slots", {})
|
||||
|
||||
# 找到包含该物品的背包槽(主背包 0:*)
|
||||
source_key = None
|
||||
source_stack = None
|
||||
for k, v in slots.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if str(v.get("item_id")) == str(item_id) and int(v.get("count", 0) or 0) >= count:
|
||||
source_key = k
|
||||
source_stack = v
|
||||
break
|
||||
if source_key is None:
|
||||
raise ValueError("卖家背包中该物品数量不足")
|
||||
|
||||
# 托管角色与 escrow
|
||||
escrow = self._auction_escrow()
|
||||
escrow_slots = escrow.setdefault("slots", {})
|
||||
listing_id = self._auction_next_id(escrow_slots)
|
||||
listing_key = self.AUCTION_LISTING_PREFIX + str(listing_id)
|
||||
|
||||
now = int(time.time())
|
||||
expires = now + int(duration_hours) * 3600
|
||||
|
||||
# 挂单 ItemStack:复制原物品(含 extra),再写入挂单字段
|
||||
listed = dict(source_stack)
|
||||
listed["count"] = count
|
||||
extra = dict(source_stack.get("extra", {}) or {})
|
||||
extra["seller_id"] = seller_id
|
||||
extra["seller_name"] = seller_name or ""
|
||||
extra["list_type"] = "0"
|
||||
extra["source_slot"] = source_key.split(":", 1)[-1] if ":" in source_key else source_key
|
||||
extra["unit_price"] = str(unit_price)
|
||||
extra["start_price"] = str(start_price)
|
||||
extra["buyout_price"] = str(buyout_price)
|
||||
extra["deposit"] = str(self.AUCTION_DEPOSIT)
|
||||
extra["fee_percent"] = str(self.AUCTION_SALE_FEE_PERCENT)
|
||||
extra["created_at"] = str(now)
|
||||
extra["expires_at"] = str(expires)
|
||||
listed["extra"] = extra
|
||||
|
||||
# 卖家背包:数量扣减或整槽删除
|
||||
remaining = int(source_stack.get("count", 0) or 0) - count
|
||||
if remaining <= 0:
|
||||
del slots[source_key]
|
||||
else:
|
||||
source_stack["count"] = remaining
|
||||
slots[source_key] = source_stack
|
||||
inv["updated_at"] = self._now_iso()
|
||||
|
||||
# 押金:卖家 -> escrow
|
||||
stats = seller.setdefault("stats", {})
|
||||
gold = int(stats.get("gold", 0) or 0)
|
||||
if gold < self.AUCTION_DEPOSIT:
|
||||
raise ValueError("卖家金币不足,无法缴纳上架押金 %d" % self.AUCTION_DEPOSIT)
|
||||
stats["gold"] = gold - self.AUCTION_DEPOSIT
|
||||
seller["updated_at"] = self._now_iso()
|
||||
|
||||
escrow_char = self.get_character(self.AUCTION_ESCROW_CHAR)
|
||||
if escrow_char is None:
|
||||
escrow_char = {
|
||||
"character_id": self.AUCTION_ESCROW_CHAR,
|
||||
"account_id": "system:auction",
|
||||
"name": "拍卖行托管",
|
||||
"job": "0", "level": 0, "stats": {"gold": 0},
|
||||
"created_at": self._now_iso(), "updated_at": self._now_iso(),
|
||||
}
|
||||
escrow_stats = escrow_char.setdefault("stats", {})
|
||||
escrow_stats["gold"] = int(escrow_stats.get("gold", 0) or 0) + self.AUCTION_DEPOSIT
|
||||
escrow_char["updated_at"] = self._now_iso()
|
||||
|
||||
# 写入挂单槽
|
||||
escrow_slots[listing_key] = listed
|
||||
escrow["updated_at"] = self._now_iso()
|
||||
|
||||
# 持久化(顺序:背包 -> 角色 -> escrow 角色 -> escrow 背包)
|
||||
self.put_record_json("inventory", seller_id, inv)
|
||||
self.put_record_json("character", seller_id, seller)
|
||||
self.put_record_json("character", self.AUCTION_ESCROW_CHAR, escrow_char)
|
||||
self._auction_save_escrow(escrow)
|
||||
|
||||
return {
|
||||
"listing_id": listing_id,
|
||||
"item_id": item_id,
|
||||
"count": count,
|
||||
"unit_price": unit_price,
|
||||
"start_price": start_price,
|
||||
"buyout_price": buyout_price,
|
||||
"deposit": self.AUCTION_DEPOSIT,
|
||||
"expires_at": expires,
|
||||
"seller_gold_after": stats["gold"],
|
||||
}
|
||||
|
||||
def auction_cancel(self, listing_id):
|
||||
"""下架(取消)一件挂单。
|
||||
|
||||
行为对齐服务端 Cancel:
|
||||
- 从 escrow 移除挂单槽
|
||||
- 物品退回卖家背包(游戏内经邮件,这里直接写入背包,GM 工具便于核对)
|
||||
- 押金不退(取消上架押金没收,服务端注释明确此为 gold sink)
|
||||
- 若有竞价,退款给竞价者(这里按相同 refund 槽逻辑写入)
|
||||
"""
|
||||
listing_id = str(listing_id)
|
||||
escrow = self._auction_escrow()
|
||||
slots = escrow.setdefault("slots", {})
|
||||
listing_key = self.AUCTION_LISTING_PREFIX + listing_id
|
||||
stack = slots.get(listing_key)
|
||||
if stack is None:
|
||||
raise KeyError("挂单 %s 不存在" % listing_id)
|
||||
|
||||
extra = stack.get("extra", {}) or {}
|
||||
seller_id = extra.get("seller_id", "")
|
||||
item_id = stack.get("item_id", 0)
|
||||
count = int(stack.get("count", 1) or 1)
|
||||
|
||||
# 移除挂单
|
||||
del slots[listing_key]
|
||||
|
||||
# 物品退回卖家背包(找空闲槽位)
|
||||
if seller_id:
|
||||
inv = self.get_record_json("inventory", seller_id) or {}
|
||||
inv_slots = inv.setdefault("slots", {})
|
||||
returned = dict(stack)
|
||||
returned.pop("extra", None) # 去掉挂单字段,还原为普通物品
|
||||
# 尝试放回原槽位,否则找空闲槽
|
||||
target_key = None
|
||||
source_slot = extra.get("source_slot", "")
|
||||
preferred = "0:" + source_slot if source_slot else None
|
||||
if preferred and preferred not in inv_slots:
|
||||
target_key = preferred
|
||||
else:
|
||||
used = set()
|
||||
for k in inv_slots:
|
||||
if ":" in k:
|
||||
c, _, s = k.partition(":")
|
||||
if c == "0" and s.isdigit():
|
||||
used.add(int(s))
|
||||
slot = 1
|
||||
while slot in used:
|
||||
slot += 1
|
||||
target_key = "0:%d" % slot
|
||||
inv_slots[target_key] = returned
|
||||
inv["updated_at"] = self._now_iso()
|
||||
self.put_record_json("inventory", seller_id, inv)
|
||||
|
||||
# 竞价退款:bid_total > 0 时退回竞价者(写 refund 槽,与 releaseListingBid 一致)
|
||||
bid_total = int(extra.get("bid_total", "0") or 0)
|
||||
bidder_id = extra.get("bidder_id", "")
|
||||
refund = None
|
||||
if bid_total > 0 and bidder_id:
|
||||
refund_key = self.AUCTION_REFUND_PREFIX + listing_id + ":" + bidder_id
|
||||
slots[refund_key] = {
|
||||
"item_id": item_id,
|
||||
"count": bid_total,
|
||||
"extra": {
|
||||
"seller_id": bidder_id,
|
||||
"listing_id": listing_id,
|
||||
"sold_count": "0",
|
||||
"refund": "1",
|
||||
},
|
||||
}
|
||||
# 退款金币写回竞价者角色
|
||||
bidder = self.get_character(bidder_id)
|
||||
if bidder:
|
||||
bstats = bidder.setdefault("stats", {})
|
||||
bstats["gold"] = int(bstats.get("gold", 0) or 0) + bid_total
|
||||
bidder["updated_at"] = self._now_iso()
|
||||
self.put_record_json("character", bidder_id, bidder)
|
||||
refund = {"bidder_id": bidder_id, "gold": bid_total}
|
||||
|
||||
escrow["updated_at"] = self._now_iso()
|
||||
self._auction_save_escrow(escrow)
|
||||
|
||||
return {
|
||||
"listing_id": listing_id,
|
||||
"item_id": item_id,
|
||||
"count": count,
|
||||
"seller_id": seller_id,
|
||||
"returned_to": seller_id,
|
||||
"deposit_forfeited": self.AUCTION_DEPOSIT,
|
||||
"refund": refund,
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>90轮回</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/shell.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 兼容旧 app.js 的隐藏控件 -->
|
||||
<div id="launcherView" class="hidden" aria-hidden="true">
|
||||
<div id="logBody"></div>
|
||||
<span id="statusBadge"><span id="statusText"></span></span>
|
||||
<span id="footInfo"></span>
|
||||
<button id="btnStart"></button><button id="btnGm"></button><button id="btnStop"></button>
|
||||
<button id="btnSponsor"></button><button id="btnWinLoadout"></button><button id="btnWinSetting"></button>
|
||||
<button id="btnClearLog"></button>
|
||||
</div>
|
||||
|
||||
<div id="appShell">
|
||||
<header class="shell-topnav">
|
||||
<div class="shell-brand">
|
||||
<div class="shell-logo">90</div>
|
||||
<div><b>90轮回</b><span>启动器 <em>v2.1.0</em></span></div>
|
||||
</div>
|
||||
<nav class="shell-nav">
|
||||
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
|
||||
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
|
||||
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
|
||||
<button class="shell-nav-item" data-shell-page="servertool"><span class="nav-ico">🧰</span>服务端工具</button>
|
||||
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
|
||||
<button class="shell-nav-item" data-shell-page="plugins"><span class="nav-ico">🧩</span>插件</button>
|
||||
<div class="shell-nav-gap"></div>
|
||||
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
|
||||
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>更新日志</button>
|
||||
</nav>
|
||||
<div class="shell-win-controls">
|
||||
<button class="tb-btn" id="shellWinMin" title="最小化">—</button>
|
||||
<button class="tb-btn tb-close" id="shellWinClose" title="关闭">×</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shell-main">
|
||||
<header class="shell-topbar">
|
||||
<div class="shell-crumb" id="shellCrumb">开始 / 本地服务端</div>
|
||||
</header>
|
||||
|
||||
<section class="shell-page active" id="page-start">
|
||||
<div class="hero">
|
||||
<div class="hero-bg"></div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-kicker">DNF · 90级 · 本地服务端</div>
|
||||
<h1 id="shellServerName">Server90轮回</h1>
|
||||
<div class="hero-status">
|
||||
<span class="live-dot" id="shellStatusDot"></span>
|
||||
<span id="shellStatusText">检测中…</span>
|
||||
<span class="muted" id="shellPid">PID —</span>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="start-btn" id="shellStart"><span>▶</span> 开始游戏</button>
|
||||
<button class="start-menu" id="shellStartMenu">▾</button>
|
||||
<div class="start-menu-pop hidden" id="shellStartPop">
|
||||
<button data-shell-action="start">启动服务端并登录</button>
|
||||
<button data-shell-action="launch">仅启动客户端</button>
|
||||
<button data-shell-action="stop">关闭服务端</button>
|
||||
</div>
|
||||
<button class="account-login" id="shellAccount" title="管理并选择游戏账号">
|
||||
<span class="account-ico">👤</span>
|
||||
<span><b>账号管理</b><small id="shellAccountName">加载中…</small></span>
|
||||
</button>
|
||||
<div class="account-picker">
|
||||
<span class="account-picker-label">当前登录账号</span>
|
||||
<select id="shellAccountSelect" title="选择登录账号"></select>
|
||||
</div>
|
||||
<button class="stop-server-btn" id="shellStop"><span>■</span> 关闭服务端</button>
|
||||
</div>
|
||||
<div class="hero-hint">服务端和客户端会一起启动,首次约 10 秒;切换账号后再点“开始游戏”可多开。</div>
|
||||
</div>
|
||||
<div class="hero-clock" id="shellClock">检查于 --:--:--</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card"><div class="stat-label">版本</div><div class="stat-row"><span>本地</span><b id="statVersion">v2026.11.03</b></div><div class="stat-row"><span>远程最新</span><b class="green" id="statRemote">检查中</b></div><div class="stat-row"><span>状态</span><b class="green" id="statState">—</b></div></div>
|
||||
<div class="stat-card"><div class="stat-label">运行</div><div class="stat-row"><span>客户端</span><b id="statClient">—</b></div><div class="stat-row"><span>游戏端口</span><b id="statPorts">—</b></div><div class="stat-row"><span>资源</span><b id="statResource">—</b></div></div>
|
||||
<div class="stat-card"><div class="stat-label">数据</div><div class="stat-row"><span>PVF 文件</span><b id="statPvf">—</b></div><div class="stat-row"><span>客户端目录</span><b class="small" id="statClientDir">—</b></div><div class="stat-row"><span>版本记录</span><b id="statDb">—</b></div></div>
|
||||
<div class="stat-card"><div class="stat-label">工具</div><div class="stat-row"><span>内置 GM</span><b class="green">已开启</b></div><div class="stat-row"><span>GM 端口</span><b id="statGmPort">18111</b></div><div class="stat-row"><span>接口端口</span><b id="statApiPort">19527</b></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-loadout">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>一键换装</h2><p class="muted">方案保存在 LoadoutTool/config.json;全局热键由启动器后端注册。</p></div><button class="shell-btn primary" id="loSaveConfig">保存配置</button></div>
|
||||
<div class="loadout-layout">
|
||||
<div class="loadout-side">
|
||||
<div class="lo-pages" id="loPages"></div>
|
||||
<div class="lo-list" id="loList"></div>
|
||||
<div class="lo-side-actions"><button class="shell-btn" id="loAddPage">新增换装页</button><button class="shell-btn" id="loAddScheme">新增方案</button><button class="shell-btn" id="loDeleteScheme">删除方案</button></div>
|
||||
</div>
|
||||
<div class="loadout-main">
|
||||
<div class="lo-form">
|
||||
<label>方案名称<input id="loName" class="shell-input"></label>
|
||||
<label>绑定角色<input id="loCharacter" class="shell-input" placeholder="角色游戏内名称"></label>
|
||||
<label>热键<input id="loHotkey" class="shell-input" placeholder="例如 Ctrl+F1"></label>
|
||||
<button class="shell-btn" id="loCaptureHotkey">录制热键</button>
|
||||
</div>
|
||||
<div class="lo-toolbar"><button class="shell-btn" id="loRefreshChars">刷新角色</button><button class="shell-btn" id="loSaveCurrent">保存当前装备</button><button class="shell-btn primary" id="loApply">应用方案</button><span id="loStatus" class="muted"></span><span id="loCooldown" class="lo-cooldown"></span></div>
|
||||
<div class="lo-slots" id="loSlots"><div class="empty-box">选择方案后显示装备快照</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-servertool">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>服务端工具</h2><p class="muted">按原 ServerTool 页签组织,配置写入 server-features.json / activity.json。</p></div><span class="tag" id="stSource">文件配置</span></div>
|
||||
<div class="st-tabs" id="stTabs">
|
||||
<button class="st-tab active" data-st="global">全局游戏参数</button>
|
||||
<button class="st-tab" data-st="activity">在线活动</button>
|
||||
<button class="st-tab" data-st="gameplay">游戏参数</button>
|
||||
<button class="st-tab" data-st="growth">疲劳/成长</button>
|
||||
<button class="st-tab" data-st="raid">团本配置</button>
|
||||
<button class="st-tab" data-st="launch">启动控制</button>
|
||||
</div>
|
||||
<div class="st-subtabs" id="stSubTabs"></div>
|
||||
<div class="st-toolbar"><button class="shell-btn primary" id="stSave">保存当前页</button><button class="shell-btn" id="stReload">重新读取</button><span id="stStatus" class="muted"></span></div>
|
||||
<div class="st-body" id="stBody"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-companion">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
|
||||
<div class="cp-toolbar">
|
||||
<label>账号<select id="cpAccount" class="shell-input"></select></label>
|
||||
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
|
||||
<button class="shell-btn" id="cpReload">刷新</button>
|
||||
<span id="cpStatus" class="muted"></span>
|
||||
</div>
|
||||
<div class="cp-flags">
|
||||
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
|
||||
</div>
|
||||
<h3 class="cp-title">选择支援兵角色</h3>
|
||||
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
|
||||
<div class="cp-ai">
|
||||
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
|
||||
<div class="st-form">
|
||||
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
|
||||
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
|
||||
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
|
||||
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
|
||||
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
|
||||
</div>
|
||||
<details class="st-json">
|
||||
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
|
||||
<div class="st-form" id="cpAITuning">
|
||||
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
|
||||
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
|
||||
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
|
||||
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
|
||||
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
|
||||
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
|
||||
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
|
||||
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
|
||||
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
|
||||
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
|
||||
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
|
||||
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
|
||||
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
|
||||
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
|
||||
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
|
||||
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
|
||||
</div>
|
||||
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-plugins">
|
||||
<div class="page-pad"><h2>插件</h2><div class="empty-box">插件目录:启动器扩展位,后续接入 LoadoutTool / ServerTool 之外的模块。</div></div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-settings">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>设置</h2><p class="muted">当前启动器实际绑定的服务端、数据库和游戏端。</p></div><button class="shell-btn" id="setRefresh">刷新</button></div>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-card"><div class="settings-label">服务端根目录</div><div class="settings-value" id="setServerRoot">—</div></div>
|
||||
<div class="settings-card"><div class="settings-label">服务端控制</div><div class="settings-value" id="setControl">—</div></div>
|
||||
<div class="settings-card"><div class="settings-label">数据库</div><div class="settings-value" id="setDb">—</div></div>
|
||||
<div class="settings-card"><div class="settings-label">游戏客户端</div><div class="settings-value" id="setClient">—</div></div>
|
||||
<div class="settings-card"><div class="settings-label">服务端状态</div><div class="settings-value" id="setRun">—</div></div>
|
||||
<div class="settings-card"><div class="settings-label">Native API</div><div class="settings-value" id="setNative">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-changelog">
|
||||
<div class="page-pad"><h2>更新日志</h2><p class="muted">启动器与工具集成记录。</p><div class="empty-box">2.1.0 · 单窗口整合启动器、GM、一键换装、服务端工具</div></div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-gm">
|
||||
<div id="gmView" class="gm-app">
|
||||
<aside class="gm-sidebar">
|
||||
<div class="gm-logo"><div class="gm-logo-mark">D</div><div><div class="gm-logo-title">DNF90 GM</div><div class="gm-logo-sub">九零轮回 · 管理工具</div></div></div>
|
||||
<div class="gm-nav-group">总览</div>
|
||||
<div class="gm-nav-item active" data-page="dashboard"><span class="ico">📊</span>控制台</div>
|
||||
<div class="gm-nav-item" data-page="characters"><span class="ico">👥</span>角色管理</div>
|
||||
<div class="gm-nav-group">资源</div>
|
||||
<div class="gm-nav-item" data-page="items"><span class="ico">🎁</span>道具发放</div>
|
||||
<div class="gm-nav-item" data-page="mail"><span class="ico">✉️</span>邮件系统</div>
|
||||
<div class="gm-nav-item" data-page="inventory"><span class="ico">🎒</span>背包装备</div>
|
||||
<div class="gm-nav-item" data-page="auction"><span class="ico">🏷️</span>拍卖行</div>
|
||||
<div class="gm-nav-group">进度</div>
|
||||
<div class="gm-nav-item" data-page="quests"><span class="ico">📜</span>任务管理</div>
|
||||
<div class="gm-nav-item" data-page="dungeons"><span class="ico">⚔️</span>副本控制</div>
|
||||
<div class="gm-nav-group">系统</div>
|
||||
<div class="gm-nav-item" data-page="wallet"><span class="ico">💰</span>钱包点券</div>
|
||||
<div class="gm-nav-item" data-page="wipe"><span class="ico">🗑️</span>删档管理</div>
|
||||
<div class="gm-nav-item" data-page="settings"><span class="ico">⚙️</span>连接设置</div>
|
||||
<div class="gm-sidebar-foot"><div><span class="conn-dot off" id="gmConnDot"></span><span class="conn-text" id="gmConnText">数据源:SQLite</span></div><button class="gm-back" id="btnBackLauncher">← 返回启动器</button></div>
|
||||
</aside>
|
||||
<div class="gm-main"><header class="gm-topbar"><h1 id="gmPageTitle">控制台</h1><span class="gm-crumb" id="gmPageCrumb">/ 服务端总览</span><div class="topbar-spacer"></div><button class="gm-btn gm-btn-sm gm-btn-ghost" id="gmBtnRefresh">↻ 刷新</button><button class="gm-win-btn gm-win-min" id="gmWinMin" title="最小化">—</button><button class="gm-win-btn gm-win-close" id="gmWinClose" title="关闭">×</button></header><div class="gm-content" id="gmContent"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="shell-statusbar"><span><i class="live-dot"></i> <b id="shellBottomStatus">运行中</b></span><span>端口 <b id="shellBottomPort">—</b></span><span>内存 <b id="shellBottomMem">—</b></span><span>CPU <b>0.0%</b></span><span class="grow"></span><span id="shellBottomLog">日志 就绪</span></div>
|
||||
|
||||
<div class="win-resize n" data-edge="top"></div><div class="win-resize s" data-edge="bottom"></div>
|
||||
<div class="win-resize w" data-edge="left"></div><div class="win-resize e" data-edge="right"></div>
|
||||
<div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div>
|
||||
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
|
||||
|
||||
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 单机模拟器!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
|
||||
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1,637 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Native API (127.0.0.1:18111) client for the DNF90 GM tool.
|
||||
|
||||
本文件依据服务端源码 (go-server/internal/app/dnf90/gm_tool_admin.go、
|
||||
gm_admin.go、gm_tool_compat.go 以及 internal/services/dnfbridge/*) 精确实现
|
||||
全部 GM 端点,字段名/类型/校验规则均与源码一致。
|
||||
|
||||
两类端点:
|
||||
- 读端点 GET /dnf/gm/... (返回 {"success":true,"data":...})
|
||||
- 写端点 POST /dnf/gm/... 或 /local/dnf-gm/... (需要 X-Admin-Confirm)
|
||||
|
||||
认证头:
|
||||
X-Admin-Token : adminToken(来自 instance.json)
|
||||
X-Admin-Confirm: 写操作设为 "1"
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class NativeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NativeClient(object):
|
||||
def __init__(self, base_url="http://127.0.0.1:18111", token=""):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
# ---- low-level HTTP ------------------------------------------
|
||||
def _request(self, method, path, body=None, confirm=False):
|
||||
url = self.base_url + path
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.token:
|
||||
headers["X-Admin-Token"] = self.token
|
||||
if confirm:
|
||||
headers["X-Admin-Confirm"] = "1"
|
||||
headers["X-Admin-Idempotency-Key"] = uuid.uuid4().hex
|
||||
if body is not None:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", "replace")
|
||||
# 尝试提取服务端返回的 error 字段,给出更友好的提示
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
msg = parsed.get("error") or raw[:200]
|
||||
except Exception:
|
||||
msg = raw[:200]
|
||||
raise NativeError("HTTP %d: %s" % (e.code, msg))
|
||||
except urllib.error.URLError as e:
|
||||
raise NativeError("连接失败: %s" % e.reason)
|
||||
|
||||
def _get(self, path):
|
||||
return self._request("GET", path)
|
||||
|
||||
def _post(self, path, body=None, confirm=True):
|
||||
return self._request("POST", path, body=body, confirm=confirm)
|
||||
|
||||
def _unwrap(self, raw):
|
||||
"""取服务端统一响应里的 data 字段。"""
|
||||
if isinstance(raw, dict) and "data" in raw:
|
||||
return raw["data"]
|
||||
return raw
|
||||
|
||||
# ---- 状态 / 账号 / 角色 --------------------------------------
|
||||
def ping(self):
|
||||
return self._get("/dnf/gm/status")
|
||||
|
||||
def list_accounts(self, query="", limit=50):
|
||||
qs = urlencode({"q": query, "limit": limit})
|
||||
try:
|
||||
return self._unwrap(self._get("/dnf/gm/accounts?" + qs)) or []
|
||||
except NativeError:
|
||||
return []
|
||||
|
||||
def get_account(self, account_id):
|
||||
return self._unwrap(self._get("/dnf/gm/accounts/%s" % account_id))
|
||||
|
||||
def set_account_value(self, account_id, field, value):
|
||||
"""POST /dnf/gm/accounts/{account_id}/value {field, value:uint64}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/accounts/%s/value" % account_id,
|
||||
{"field": field, "value": int(value)}))
|
||||
|
||||
def list_characters(self, account_id=None):
|
||||
"""角色按账号枚举:/dnf/gm/accounts/{id}/characters。
|
||||
无 account_id 时遍历所有账号。"""
|
||||
account_ids = []
|
||||
if account_id:
|
||||
account_ids = [account_id]
|
||||
else:
|
||||
accs = self.list_accounts()
|
||||
account_ids = [a.get("account_id") for a in accs
|
||||
if isinstance(a, dict) and a.get("account_id")]
|
||||
out = []
|
||||
for aid in account_ids:
|
||||
try:
|
||||
raw = self._unwrap(self._get("/dnf/gm/accounts/%s/characters" % aid))
|
||||
except NativeError:
|
||||
continue
|
||||
if not isinstance(raw, list):
|
||||
raw = [raw] if raw else []
|
||||
for c in raw:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("character_id") or c.get("id")
|
||||
out.append({
|
||||
"character_id": str(cid),
|
||||
"account_id": c.get("account_id", aid),
|
||||
"name": c.get("name", ""),
|
||||
"job": c.get("job", "0"),
|
||||
"level": c.get("level", 0),
|
||||
"gold": c.get("gold", 0),
|
||||
"profession_name": c.get("profession_name", ""),
|
||||
"grow_type": c.get("grow_type", 0),
|
||||
"first_grow": c.get("first_grow", 0),
|
||||
"awakening": c.get("awakening", 0),
|
||||
"online": c.get("online", False),
|
||||
"raw": c,
|
||||
})
|
||||
return out
|
||||
|
||||
def get_character(self, character_id):
|
||||
raw = self._unwrap(self._get("/dnf/gm/characters/%s" % character_id))
|
||||
if not isinstance(raw, dict):
|
||||
return raw
|
||||
stats = {k: v for k, v in raw.items()
|
||||
if k not in ("character_id", "account_id", "name", "job",
|
||||
"level", "slot", "profession_name", "grow_type",
|
||||
"first_grow", "awakening", "online", "gold", "cera")}
|
||||
if "gold" in raw:
|
||||
stats["gold"] = raw["gold"]
|
||||
out = {
|
||||
"character_id": str(raw.get("character_id") or character_id),
|
||||
"account_id": raw.get("account_id", ""),
|
||||
"name": raw.get("name", ""),
|
||||
"job": raw.get("job", "0"),
|
||||
"level": raw.get("level", 0),
|
||||
"gold": raw.get("gold", 0),
|
||||
"profession_name": raw.get("profession_name", ""),
|
||||
"grow_type": raw.get("grow_type", 0),
|
||||
"first_grow": raw.get("first_grow", 0),
|
||||
"awakening": raw.get("awakening", 0),
|
||||
"online": raw.get("online", False),
|
||||
"stats": stats,
|
||||
}
|
||||
return out
|
||||
|
||||
def get_character_detail(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/detail" % character_id))
|
||||
|
||||
def get_inventory(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/inventory" % character_id))
|
||||
|
||||
def get_equipment(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/equipment-slots" % character_id))
|
||||
|
||||
def get_mailbox(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/mailbox" % character_id))
|
||||
|
||||
def get_account_cubes(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/account-cubes" % character_id))
|
||||
|
||||
# ---- 物品目录 / 搜索 -----------------------------------------
|
||||
def get_item_detail(self, item_id):
|
||||
return self._unwrap(self._get("/dnf/gm/items/%s/detail" % item_id))
|
||||
|
||||
def get_item_mail_options(self, item_id, mode="", query="", rarity=-1, limit=40, offset=0):
|
||||
qs = urlencode({"mode": mode, "q": query, "rarity": rarity,
|
||||
"limit": limit, "offset": offset})
|
||||
return self._unwrap(self._get("/dnf/gm/items/%s/mail-options?%s" % (item_id, qs)))
|
||||
|
||||
def get_item_categories(self):
|
||||
return self._unwrap(self._get("/dnf/gm/items/categories"))
|
||||
|
||||
def search_items(self, query="", kind="", tag="", segment="", group="", job="",
|
||||
min_level=0, max_level=0, rarity=-1, expiration="",
|
||||
offset=0, limit=50):
|
||||
"""GET /dnf/gm/items/page —— 完整筛选参数(与源码 GMItemQuery 一致)。
|
||||
返回归一化结构 {items:[...], total, limit, offset}。"""
|
||||
params = {
|
||||
"q": query, "kind": kind, "tag": tag, "segment": segment,
|
||||
"group": group, "job": job, "min_level": min_level,
|
||||
"max_level": max_level, "rarity": rarity, "expiration": expiration,
|
||||
"offset": offset, "limit": limit,
|
||||
}
|
||||
# 去掉空值参数,避免干扰
|
||||
params = {k: v for k, v in params.items() if v not in ("", -1, 0) or k in ("offset", "limit", "q", "rarity")}
|
||||
qs = urlencode(params)
|
||||
raw = self._unwrap(self._get("/dnf/gm/items/page?" + qs))
|
||||
if isinstance(raw, dict) and "items" in raw:
|
||||
return raw
|
||||
return {"items": raw if isinstance(raw, list) else [], "total": 0,
|
||||
"limit": limit, "offset": offset}
|
||||
|
||||
def search_avatar_sets(self, query="", job="", limit=24, offset=0):
|
||||
qs = urlencode({"q": query, "job": job, "limit": limit, "offset": offset})
|
||||
return self._unwrap(self._get("/dnf/gm/avatar-sets?" + qs))
|
||||
|
||||
# ---- 物品发放 -------------------------------------------------
|
||||
# 注意:服务端有两个发放端点:
|
||||
# 1) POST /local/dnf-gm/item/grant —— 直接进背包(服务端决定格位)
|
||||
# 请求体 {character_id:string, item_id:uint32, count:uint32}
|
||||
# 2) POST /local/dnf-gm/item/grant-options —— 带强化/增幅/锻造/品质/耐久/附魔/徽章
|
||||
# 3) POST /local/dnf-gm/item/grant-with-emblems —— 带附魔宝珠 + 徽章(装备/时装)
|
||||
# 4) POST /dnf/gm/characters/{id}/mail —— 邮件发放(带完整选项)
|
||||
|
||||
def grant_item(self, character_id, item_id, count=1):
|
||||
body = {"character_id": str(character_id),
|
||||
"item_id": int(item_id),
|
||||
"count": int(count)}
|
||||
return self._unwrap(self._post("/local/dnf-gm/item/grant", body))
|
||||
|
||||
def grant_item_options(self, character_id, item_id, count=1, reinforce=0,
|
||||
amplify_type=0, forging=0, equipment_quality="",
|
||||
durability=None, expire_days=0, emblem_item_ids=None,
|
||||
enchant_bead_item_id=0):
|
||||
"""带强化/增幅/锻造/品质/耐久/附魔的发放。
|
||||
校验范围(源码 gm_tool_items_extra.go):
|
||||
reinforce 0..31, amplify_type 0..4, forging 0..8, expire_days 0..3650"""
|
||||
body = {"character_id": str(character_id), "item_id": int(item_id),
|
||||
"count": int(count)}
|
||||
if reinforce:
|
||||
body["reinforce"] = int(reinforce)
|
||||
if amplify_type:
|
||||
body["amplify_type"] = int(amplify_type)
|
||||
if forging:
|
||||
body["forging"] = int(forging)
|
||||
if equipment_quality:
|
||||
body["equipment_quality"] = equipment_quality
|
||||
if durability is not None:
|
||||
body["durability"] = int(durability)
|
||||
if expire_days:
|
||||
body["expire_days"] = int(expire_days)
|
||||
if emblem_item_ids:
|
||||
body["emblem_item_ids"] = [int(x) for x in emblem_item_ids]
|
||||
if enchant_bead_item_id:
|
||||
body["enchant_bead_item_id"] = int(enchant_bead_item_id)
|
||||
return self._unwrap(self._post("/local/dnf-gm/item/grant-options", body))
|
||||
|
||||
def grant_item_with_emblems(self, character_id, item_id, enchant_bead_item_id=0,
|
||||
enchant_upgrade_count=None, emblems=None, open_sockets=None):
|
||||
"""带附魔宝珠 + 徽章发放(仅装备/时装)。
|
||||
emblems: [{socket_index:int, emblem_item_id:int, emblem_slot:int}, ...]"""
|
||||
body = {"character_id": str(character_id), "item_id": int(item_id)}
|
||||
if enchant_bead_item_id:
|
||||
body["enchant_bead_item_id"] = int(enchant_bead_item_id)
|
||||
if enchant_upgrade_count is not None:
|
||||
body["enchant_upgrade_count"] = int(enchant_upgrade_count)
|
||||
if emblems:
|
||||
body["emblems"] = emblems
|
||||
if open_sockets is not None:
|
||||
body["open_sockets"] = int(open_sockets)
|
||||
return self._unwrap(self._post("/local/dnf-gm/item/grant-with-emblems", body))
|
||||
|
||||
def send_item_mail(self, character_id, item_id, count=1, title="", body="",
|
||||
expire_days=0, reinforce=0, amplify_type=0,
|
||||
equipment_quality="", forging=0, emblem_item_ids=None,
|
||||
enchant_bead_item_id=0):
|
||||
"""POST /dnf/gm/characters/{id}/mail —— 邮件发放(完整选项)。"""
|
||||
req = {"item_id": int(item_id), "count": int(count)}
|
||||
if title:
|
||||
req["title"] = title
|
||||
if body:
|
||||
req["body"] = body
|
||||
if expire_days:
|
||||
req["expire_days"] = int(expire_days)
|
||||
if reinforce:
|
||||
req["reinforce"] = int(reinforce)
|
||||
if amplify_type:
|
||||
req["amplify_type"] = int(amplify_type)
|
||||
if equipment_quality:
|
||||
req["equipment_quality"] = equipment_quality
|
||||
if forging:
|
||||
req["forging"] = int(forging)
|
||||
if emblem_item_ids:
|
||||
req["emblem_item_ids"] = [int(x) for x in emblem_item_ids]
|
||||
if enchant_bead_item_id:
|
||||
req["enchant_bead_item_id"] = int(enchant_bead_item_id)
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/mail" % character_id, req))
|
||||
|
||||
def delete_item(self, character_id, collection="", list_type=None, slot=None,
|
||||
entry_key=""):
|
||||
"""POST /local/dnf-gm/item/delete —— 删除背包行或穿戴装备行。
|
||||
collection: "slots"(背包,默认) 或 "wear"(穿戴装备)"""
|
||||
req = {"character_id": str(character_id)}
|
||||
if collection:
|
||||
req["collection"] = collection
|
||||
if entry_key:
|
||||
req["entry_key"] = entry_key
|
||||
else:
|
||||
req["list_type"] = int(list_type)
|
||||
req["slot"] = int(slot)
|
||||
return self._unwrap(self._post("/local/dnf-gm/item/delete", req))
|
||||
|
||||
# ---- 属性修改 -------------------------------------------------
|
||||
def set_gold(self, character_id, amount):
|
||||
"""POST /dnf/gm/characters/{id}/gold {value:int64}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/gold" % str(character_id), {"value": int(amount)}))
|
||||
|
||||
def set_level(self, character_id, level):
|
||||
"""POST /dnf/gm/characters/{id}/level {level:int}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/level" % str(character_id), {"level": int(level)}))
|
||||
|
||||
def set_skill_bonus(self, character_id, bonus_sp, bonus_tp):
|
||||
"""POST /dnf/gm/characters/{id}/skill-points {bonus_sp, bonus_tp:int64}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/skill-points" % str(character_id),
|
||||
{"bonus_sp": int(bonus_sp), "bonus_tp": int(bonus_tp)}))
|
||||
|
||||
def get_career(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/career" % character_id))
|
||||
|
||||
def set_career(self, character_id, job, first, awakening):
|
||||
"""POST /dnf/gm/characters/{id}/career {job:int, first:int, awakening:int}
|
||||
这是修改职业/转职的正确接口(旧 /local/dnf-gm/character/job 语义不同)。"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/career" % str(character_id),
|
||||
{"job": int(job), "first": int(first), "awakening": int(awakening)}))
|
||||
|
||||
def set_cera(self, account_id, amount=None, value=None, currency=""):
|
||||
"""POST /local/dnf-gm/account/cera
|
||||
{account_id, currency, amount(增量) 或 value(覆写)}
|
||||
currency: cera / token_cera / happy_token_cera,默认 cera"""
|
||||
req = {"account_id": str(account_id)}
|
||||
if currency:
|
||||
req["currency"] = currency
|
||||
if amount is not None:
|
||||
req["amount"] = int(amount)
|
||||
elif value is not None:
|
||||
req["value"] = int(value)
|
||||
return self._unwrap(self._post("/local/dnf-gm/account/cera", req))
|
||||
|
||||
def adjust_revive_coin(self, character_id, amount=None, value=None, source=""):
|
||||
"""POST /local/dnf-gm/character/revive-coin
|
||||
amount(增量) 或 value(覆写),二者互斥"""
|
||||
req = {"character_id": str(character_id)}
|
||||
if amount is not None:
|
||||
req["amount"] = int(amount)
|
||||
elif value is not None:
|
||||
req["value"] = int(value)
|
||||
if source:
|
||||
req["source"] = source
|
||||
return self._unwrap(self._post("/local/dnf-gm/character/revive-coin", req))
|
||||
|
||||
def query_wallet(self, character_id="", character_name="", currency=""):
|
||||
"""POST /local/dnf-gm/wallet/query"""
|
||||
req = {}
|
||||
if character_id:
|
||||
req["character_id"] = str(character_id)
|
||||
if character_name:
|
||||
req["character_name"] = character_name
|
||||
if currency:
|
||||
req["currency"] = currency
|
||||
return self._unwrap(self._post("/local/dnf-gm/wallet/query", req))
|
||||
|
||||
def update_wallet(self, character_id="", character_name="", currency="",
|
||||
mode="", amount=0):
|
||||
"""POST /local/dnf-gm/wallet/update
|
||||
currency + mode + amount;mode 语义见源码 gm_admin_state.go"""
|
||||
req = {}
|
||||
if character_id:
|
||||
req["character_id"] = str(character_id)
|
||||
if character_name:
|
||||
req["character_name"] = character_name
|
||||
if currency:
|
||||
req["currency"] = currency
|
||||
if mode:
|
||||
req["mode"] = mode
|
||||
if amount:
|
||||
req["amount"] = int(amount)
|
||||
return self._unwrap(self._post("/local/dnf-gm/wallet/update", req))
|
||||
|
||||
# ---- 背包 / 装备 ---------------------------------------------
|
||||
def inventory_list(self, character_id):
|
||||
"""GET /local/dnf-gm/inventory/list —— 列角色背包全部行 + 穿戴装备行。
|
||||
返回 {"character_id":..., "rows":[{collection,entry_key,item_id,count,bind,item_name},...]}"""
|
||||
return self._unwrap(self._get(
|
||||
"/local/dnf-gm/inventory/list?character_id=%s" % character_id))
|
||||
|
||||
def delete_inventory_item(self, character_id, container, key, count):
|
||||
"""POST /dnf/gm/characters/{id}/inventory/delete {container, key, count}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/inventory/delete" % str(character_id),
|
||||
{"container": container, "key": key, "count": int(count)}))
|
||||
|
||||
def clear_inventory(self, character_id, container="", keep_wallet=True, collection=""):
|
||||
"""POST /local/dnf-gm/inventory/clear {character_id, collection, keep_wallet}
|
||||
工具端版本会保留主背包 0:0~0:2 的固定钱包槽(金币/复活币/点券载体)。"""
|
||||
req = {"character_id": str(character_id)}
|
||||
if collection:
|
||||
req["collection"] = collection
|
||||
req["keep_wallet"] = bool(keep_wallet)
|
||||
return self._unwrap(self._post("/local/dnf-gm/inventory/clear", req))
|
||||
|
||||
def unequip_equipment(self, character_id, key):
|
||||
"""POST /dnf/gm/characters/{id}/equipment/unequip {key}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/equipment/unequip" % str(character_id), {"key": key}))
|
||||
|
||||
def delete_equipment(self, character_id, key):
|
||||
"""POST /dnf/gm/characters/{id}/equipment/delete {key}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/equipment/delete" % str(character_id), {"key": key}))
|
||||
|
||||
def unlock_equip_slots(self, character_id, slots):
|
||||
"""POST /dnf/gm/characters/{id}/equipment-slots/unlock {slots:[]string}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/equipment-slots/unlock" % str(character_id),
|
||||
{"slots": slots}))
|
||||
|
||||
def set_account_cube(self, character_id, selection, count):
|
||||
"""POST /dnf/gm/characters/{id}/account-cubes {selection:int, count:int64}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/account-cubes" % str(character_id),
|
||||
{"selection": int(selection), "count": int(count)}))
|
||||
|
||||
# ---- 邮件 -----------------------------------------------------
|
||||
def send_system_mail(self, character_id="", character_name="", title="",
|
||||
body="", gold=0, attachments=None):
|
||||
"""POST /local/dnf-gm/mail {character_id/name, title, body, gold, attachments}
|
||||
attachments: [{item_id, count, bind, reinforce_level, amplify_level, amplify_type}]"""
|
||||
req = {}
|
||||
if character_id:
|
||||
req["character_id"] = str(character_id)
|
||||
if character_name:
|
||||
req["character_name"] = character_name
|
||||
if title:
|
||||
req["title"] = title
|
||||
if body:
|
||||
req["body"] = body
|
||||
if gold:
|
||||
req["gold"] = int(gold)
|
||||
if attachments:
|
||||
req["attachments"] = attachments
|
||||
return self._unwrap(self._post("/local/dnf-gm/mail", req))
|
||||
|
||||
def clear_mailbox(self, character_id="", character_name="", confirm=True):
|
||||
"""POST /local/dnf-gm/mail/clear {character_id/name, confirm}"""
|
||||
req = {"confirm": bool(confirm)}
|
||||
if character_id:
|
||||
req["character_id"] = str(character_id)
|
||||
if character_name:
|
||||
req["character_name"] = character_name
|
||||
return self._unwrap(self._post("/local/dnf-gm/mail/clear", req))
|
||||
|
||||
def delete_mailbox_mails(self, character_id, mail_ids, force=False):
|
||||
"""POST /dnf/gm/characters/{id}/mailbox/delete {mail_ids, force}"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/mailbox/delete" % str(character_id),
|
||||
{"mail_ids": mail_ids, "force": bool(force)}))
|
||||
|
||||
# ---- 拍卖行(服务端 GM 接口,线上上架/下架)------------------
|
||||
def auction_listings(self):
|
||||
"""GET /local/dnf-gm/auction/listings —— 列出所有挂单(含过期)。
|
||||
返回 {"listings":[...], "escrow_gold":int}。"""
|
||||
return self._unwrap(self._get("/local/dnf-gm/auction/listings"))
|
||||
|
||||
def auction_list(self, seller_id, item_id, count, unit_price,
|
||||
start_price=None, buyout_price=None, duration_hours=24,
|
||||
grant_if_missing=False):
|
||||
"""POST /local/dnf-gm/auction/list —— 上架。
|
||||
服务端 GMAuctionListRequest 字段:
|
||||
seller_id / seller_name / item_id / count / unit_price /
|
||||
start_price(每件起拍,-1=不设) / buyout_price(每件一口价,-1=不设) /
|
||||
duration_hours(24 或 48) / grant_if_missing(背包不足自动发放差额)"""
|
||||
req = {
|
||||
"seller_id": str(seller_id),
|
||||
"item_id": int(item_id),
|
||||
"count": int(count),
|
||||
"unit_price": int(unit_price),
|
||||
}
|
||||
# start/buyout:None 或空 -> -1(服务端约定 -1 表示不设)
|
||||
req["start_price"] = int(start_price) if start_price not in (None, "") else -1
|
||||
req["buyout_price"] = int(buyout_price) if buyout_price not in (None, "") else -1
|
||||
req["duration_hours"] = int(duration_hours) if duration_hours else 24
|
||||
if grant_if_missing:
|
||||
req["grant_if_missing"] = True
|
||||
return self._unwrap(self._post("/local/dnf-gm/auction/list", req))
|
||||
|
||||
def auction_cancel(self, listing_id):
|
||||
"""POST /local/dnf-gm/auction/cancel —— 下架。
|
||||
GMAuctionCancelRequest: {listing_id:uint64}。"""
|
||||
return self._unwrap(self._post(
|
||||
"/local/dnf-gm/auction/cancel", {"listing_id": int(listing_id)}))
|
||||
|
||||
# ---- 任务 -----------------------------------------------------
|
||||
def get_quest_overview(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/quests" % character_id))
|
||||
|
||||
def search_quests(self, character_id, query, limit=40):
|
||||
qs = urlencode({"q": query, "limit": limit})
|
||||
return self._unwrap(self._get(
|
||||
"/dnf/gm/characters/%s/quests/search?%s" % (character_id, qs)))
|
||||
|
||||
def get_quest_detail(self, character_id, quest_id):
|
||||
return self._unwrap(self._get(
|
||||
"/dnf/gm/characters/%s/quests/%s/detail" % (character_id, quest_id)))
|
||||
|
||||
def _quest_mutate(self, character_id, quest_id, action):
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/quests/%s/%s" % (character_id, quest_id, action), {}))
|
||||
|
||||
def accept_quest(self, character_id, quest_id):
|
||||
return self._quest_mutate(character_id, quest_id, "accept")
|
||||
|
||||
def mark_quest_ready(self, character_id, quest_id):
|
||||
return self._quest_mutate(character_id, quest_id, "ready")
|
||||
|
||||
def give_up_quest(self, character_id, quest_id):
|
||||
return self._quest_mutate(character_id, quest_id, "give-up")
|
||||
|
||||
def complete_quest(self, character_id, quest_id):
|
||||
return self._quest_mutate(character_id, quest_id, "complete")
|
||||
|
||||
def uncomplete_quest(self, character_id, quest_id):
|
||||
return self._quest_mutate(character_id, quest_id, "uncomplete")
|
||||
|
||||
def complete_quest_batch(self, character_id, quest_ids):
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/quests/complete-batch" % character_id,
|
||||
{"quest_ids": [int(x) for x in quest_ids]}))
|
||||
|
||||
# ---- 副本 -----------------------------------------------------
|
||||
def get_dungeons(self, character_id):
|
||||
return self._unwrap(self._get("/dnf/gm/characters/%s/dungeons" % character_id))
|
||||
|
||||
def unlock_dungeon_difficulties(self, character_id):
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/dungeons/unlock-difficulties" % character_id, {}))
|
||||
|
||||
def unlock_hell_challenges(self, character_id):
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/dungeons/unlock-hell" % character_id, {}))
|
||||
|
||||
# ---- 黑钻 -----------------------------------------------------
|
||||
def get_black_diamond(self, character_id="", character_name=""):
|
||||
qs = urlencode({"id": character_id, "name": character_name})
|
||||
return self._unwrap(self._get("/local/dnf-gm/character/black-diamond?" + qs))
|
||||
|
||||
def activate_black_diamond(self, character_id, days=0):
|
||||
req = {"character_id": str(character_id)}
|
||||
if days:
|
||||
req["days"] = int(days)
|
||||
return self._unwrap(self._post("/local/dnf-gm/character/black-diamond", req))
|
||||
|
||||
# ---- 运营参数 / 爆率(Native,服务端在线) ----------------------
|
||||
def get_gameplay_settings(self):
|
||||
"""GET /dnf/gm/gameplay -> GMGameplaySettings(深渊史诗掉率等运营参数)"""
|
||||
return self._unwrap(self._get("/dnf/gm/gameplay"))
|
||||
|
||||
def set_hell_drop_rate(self, percent):
|
||||
"""POST /dnf/gm/gameplay/hell-drop-rate {percent:int}(深渊史诗掉落率百分比)"""
|
||||
return self._unwrap(self._post("/dnf/gm/gameplay/hell-drop-rate",
|
||||
{"percent": int(percent)}))
|
||||
|
||||
def set_epic_potion_multiplier(self, percent):
|
||||
"""POST /dnf/gm/gameplay/epic-potion-test-multiplier {percent:int}
|
||||
城镇增益药剂测试倍率,0 = 关闭按 PVF 表走。"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/gameplay/epic-potion-test-multiplier",
|
||||
{"percent": int(percent)}))
|
||||
|
||||
def set_fatigue_mode(self, mode):
|
||||
"""POST /dnf/gm/gameplay/fatigue-mode {mode:str}
|
||||
mode: none(正常)/ free(无限不扣)/ lock(锁死高值)/ refill(一键回满)。"""
|
||||
return self._unwrap(self._post("/dnf/gm/gameplay/fatigue-mode",
|
||||
{"mode": str(mode)}))
|
||||
|
||||
def set_burning(self, enabled):
|
||||
"""POST /dnf/gm/gameplay/burning {enabled:bool}
|
||||
燃烧疲劳:双倍消耗 + 通关经验翻倍。"""
|
||||
return self._unwrap(self._post("/dnf/gm/gameplay/burning",
|
||||
{"enabled": bool(enabled)}))
|
||||
|
||||
def refill_fatigue(self, character_id):
|
||||
"""POST /dnf/gm/characters/{id}/fatigue/refill 一键回满角色疲劳。"""
|
||||
return self._unwrap(self._post(
|
||||
"/dnf/gm/characters/%s/fatigue/refill" % str(character_id), {}))
|
||||
|
||||
# ---- 宠物 -----------------------------------------------------
|
||||
def update_pet(self, character_id, pet_key="", level=None, exp=None,
|
||||
satiety=None, clear_satiety=False, pet_index=None):
|
||||
req = {"character_id": str(character_id)}
|
||||
if pet_key:
|
||||
req["pet_key"] = pet_key
|
||||
if level is not None:
|
||||
req["level"] = int(level)
|
||||
if exp is not None:
|
||||
req["exp"] = int(exp)
|
||||
if satiety is not None:
|
||||
req["satiety"] = int(satiety)
|
||||
if clear_satiety:
|
||||
req["clear_satiety"] = True
|
||||
if pet_index is not None:
|
||||
req["pet_index"] = int(pet_index)
|
||||
return self._unwrap(self._post("/local/dnf-gm/pet/update", req))
|
||||
|
||||
# ---- 启动器工具扩展(换装 / 服务端工具 / 支援兵)----------------
|
||||
def loadout_characters(self):
|
||||
return self._unwrap(self._get("/local/dnf-gm/characters")) or []
|
||||
|
||||
def loadout_equipment(self, character_id):
|
||||
qs = urlencode({"character_id": str(character_id)})
|
||||
return self._unwrap(self._get("/local/dnf-gm/loadout/equipment?" + qs))
|
||||
|
||||
def loadout_apply(self, character_id, slots):
|
||||
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
|
||||
"character_id": str(character_id), "slots": slots}))
|
||||
|
||||
def get_server_features(self):
|
||||
return self._unwrap(self._get("/dnf/gm/features"))
|
||||
|
||||
def save_server_features(self, features):
|
||||
return self._unwrap(self._post("/dnf/gm/features", features))
|
||||
|
||||
def companion_state(self, character_id):
|
||||
qs = urlencode({"characterId": str(character_id)})
|
||||
return self._unwrap(self._get("/local/companion?" + qs))
|
||||
|
||||
def companion_save(self, payload):
|
||||
return self._unwrap(self._post("/local/companion", payload))
|
||||
+2394
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,438 @@
|
||||
/* 95JP 启动器 · 单窗口整合版 */
|
||||
:root{
|
||||
--bg:#08110f; --bg2:#0d1a16; --panel:#0f211b; --panel2:#132a22;
|
||||
--line:#1d3b31; --text:#dbe9e1; --muted:#7f9c90;
|
||||
--green:#7fe3b0; --green2:#45c98e; --dim:#4e7b68;
|
||||
--danger:#e07a6a; --gold:#e6c36a;
|
||||
}
|
||||
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);
|
||||
font:14px/1.5 "Microsoft YaHei","Segoe UI",sans-serif;overflow:hidden}
|
||||
*{box-sizing:border-box}
|
||||
button,input,select{font:inherit}
|
||||
.hidden{display:none!important}
|
||||
#launcherView{display:none!important}
|
||||
#appShell{display:flex;flex-direction:column;height:calc(100vh - 30px);background:var(--bg)}
|
||||
|
||||
/* top nav(横向标签页导航,替代原左侧竖排侧栏) */
|
||||
.shell-topnav{flex:none;height:56px;display:flex;align-items:center;gap:18px;
|
||||
background:#0a1512;border-bottom:1px solid #14291f;padding:0 16px}
|
||||
.shell-brand{height:100%;display:flex;align-items:center;gap:10px;padding:0 4px;border-bottom:0;flex:none}
|
||||
.shell-logo{width:30px;height:30px;border:1px solid var(--green2);color:var(--green);
|
||||
display:grid;place-items:center;font-weight:700;font-size:12px;border-radius:2px}
|
||||
.shell-brand b{font-size:15px;letter-spacing:.5px;white-space:nowrap}
|
||||
.shell-brand span{display:block;font-size:10px;color:var(--muted)}
|
||||
.shell-brand em{font-style:normal;color:var(--dim);margin-left:4px}
|
||||
.shell-nav{display:flex;flex-direction:row;align-items:center;flex:1;gap:2px;padding:0}
|
||||
.shell-nav-item{appearance:none;background:transparent;border:0;color:#a9c1b6;text-align:left;
|
||||
padding:8px 14px;border-left:0;border-bottom:2px solid transparent;cursor:pointer;border-radius:3px 3px 0 0;
|
||||
white-space:nowrap}
|
||||
.shell-nav-item:hover{background:#10241d;color:var(--text)}
|
||||
.shell-nav-item.active{background:#12352a;color:var(--green);border-bottom-color:var(--green2)}
|
||||
.shell-nav-gap{width:1px;height:22px;border-top:0;border-left:1px solid #14291f;margin:0 6px;flex:none}
|
||||
.shell-side-foot{flex:none;padding:0 4px;color:#4b6b5c;font-size:11px}
|
||||
|
||||
/* main */
|
||||
.shell-main{flex:1;min-width:0;overflow:auto;background:
|
||||
radial-gradient(900px 400px at 70% -10%,#123329 0,transparent 60%),var(--bg)}
|
||||
.shell-topbar{height:42px;display:flex;align-items:center;justify-content:space-between;
|
||||
padding:0 18px;border-bottom:1px solid #14291f;color:var(--muted);font-size:12px;
|
||||
cursor:move;user-select:none}
|
||||
.shell-topbar button,.shell-topbar select,.shell-topbar input{cursor:pointer}
|
||||
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:10px;margin-top:14px}
|
||||
.settings-card{border:1px solid #1d3a2d;background:#0b1712;padding:12px 14px;border-radius:4px}
|
||||
.settings-label{font-size:11px;color:#83a393;margin-bottom:5px}
|
||||
.settings-value{font:11px/1.6 Consolas,"Microsoft YaHei",monospace;color:#d5e8de;word-break:break-all}
|
||||
.shell-top-actions{display:flex;gap:8px;align-items:center}
|
||||
.shell-top-actions select{background:#0c1b16;border:1px solid #25483a;color:#bfe4d2;padding:4px 8px;border-radius:2px}
|
||||
.tb-btn{width:28px;height:24px;border:1px solid #25483a;background:#0c1b16;color:#a8c7b6;cursor:pointer}
|
||||
.tb-close:hover,.tb-btn:hover{background:#1a3a2c;color:#fff}
|
||||
.shell-page{display:none;padding:0 20px 20px}
|
||||
.shell-page.active{display:block}
|
||||
.page-pad{padding-top:18px}
|
||||
.page-pad h2{margin:0 0 4px;font-size:20px;font-weight:500}
|
||||
.page-heading{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px}
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:11px!important;font-weight:400!important}
|
||||
.green{color:var(--green)!important}
|
||||
.tag{border:1px solid #2c5a46;color:var(--green);padding:2px 8px;font-size:11px;border-radius:2px}
|
||||
.empty-box{border:1px dashed #244638;color:#5f8272;padding:26px;text-align:center;margin-top:14px;background:#0b1813}
|
||||
|
||||
/* hero */
|
||||
.hero{position:relative;margin:16px 0 12px;min-height:252px;border:1px solid #1e3d30;
|
||||
overflow:hidden;background:#0b1914}
|
||||
.hero-bg{position:absolute;inset:0;background:
|
||||
linear-gradient(90deg,#07110e 0%,rgba(7,17,14,.94) 32%,rgba(10,28,21,.55) 62%,rgba(8,18,14,.9)),
|
||||
repeating-linear-gradient(115deg,rgba(80,180,130,.05) 0 1px,transparent 1px 26px),
|
||||
radial-gradient(500px 300px at 82% 35%,#245a43 0,#0a1914 65%);
|
||||
filter:saturate(.85)}
|
||||
.hero-bg:after{content:"";position:absolute;right:3%;top:-20%;width:42%;height:130%;
|
||||
background:radial-gradient(ellipse at 50% 40%,rgba(140,210,175,.12),transparent 65%);
|
||||
transform:rotate(-8deg)}
|
||||
.hero-content{position:relative;padding:26px 30px}
|
||||
.hero-kicker{font-size:11px;color:#769887;letter-spacing:.4px}
|
||||
.hero h1{margin:4px 0 2px;font-size:34px;font-weight:400;color:#e5f2ea;letter-spacing:.4px}
|
||||
.hero-status{display:flex;align-items:center;gap:10px;color:#b7d2c4;font-size:12px}
|
||||
.live-dot{width:7px;height:7px;border-radius:50%;background:#3dd68b;display:inline-block;
|
||||
box-shadow:0 0 10px #3dd68b}
|
||||
.hero-actions{display:flex;align-items:stretch;gap:0;margin:16px 0 8px}
|
||||
.start-btn{width:225px;height:46px;background:linear-gradient(180deg,#bff3d8,#8ee5b9);
|
||||
border:1px solid #c9ffe2;color:#0b2a1c;font-size:17px;font-weight:600;cursor:pointer}
|
||||
.start-btn:hover{filter:brightness(1.06)}
|
||||
.start-menu{width:34px;height:46px;background:#123126;border:1px solid #3f7b5c;border-left:0;
|
||||
color:#a9d9c0;cursor:pointer;font-size:11px}
|
||||
.account-login{height:46px;margin-left:12px;padding:0 16px;display:flex;align-items:center;gap:8px;
|
||||
border:1px solid rgba(255,255,255,.16);border-radius:9px;background:rgba(255,255,255,.05);
|
||||
color:#e4e6f5;cursor:pointer;text-align:left}
|
||||
.account-login:hover{background:rgba(124,92,255,.18);border-color:rgba(124,92,255,.5)}
|
||||
.account-login .account-ico{font-size:18px}
|
||||
.account-login b{display:block;font-size:13px;font-weight:500;line-height:1.2}
|
||||
.account-login small{display:block;font-size:10px;color:#9a9eb5;line-height:1.4;max-width:120px;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.account-picker{height:46px;margin-left:8px;min-width:160px;padding:4px 10px;
|
||||
border:1px solid rgba(255,255,255,.16);border-radius:9px;background:rgba(255,255,255,.05);
|
||||
display:flex;flex-direction:column;justify-content:center}
|
||||
.account-picker-label{font-size:10px;color:#8d91a8;line-height:1.2}
|
||||
.account-picker select{border:0;outline:0;background:transparent;color:#eef0fb;font-size:12px;
|
||||
width:100%;cursor:pointer;appearance:none;padding:0 12px 0 0;
|
||||
background-image:linear-gradient(45deg,transparent 50%,#a9a2d8 50%),linear-gradient(135deg,#a9a2d8 50%,transparent 50%);
|
||||
background-position:calc(100% - 5px) 55%,calc(100% - 1px) 55%;background-size:4px 4px;background-repeat:no-repeat}
|
||||
.account-picker select option{background:#141424;color:#eef0fb}
|
||||
.stop-server-btn{height:46px;margin-left:10px;padding:0 16px;border:1px solid rgba(255,93,108,.45);
|
||||
border-radius:9px;background:rgba(255,93,108,.08);color:#ffb8bf;cursor:pointer;font-size:13px}
|
||||
.stop-server-btn:hover{background:rgba(255,93,108,.2);color:#fff}
|
||||
.stop-server-btn span{margin-right:5px}
|
||||
.start-menu-pop{position:absolute;z-index:20;margin-top:50px;background:#10241c;border:1px solid #315f49;
|
||||
min-width:190px;box-shadow:0 8px 20px #0009}
|
||||
.start-menu-pop button{display:block;width:100%;text-align:left;background:none;border:0;color:#c9e3d5;
|
||||
padding:9px 12px;cursor:pointer;font-size:12px}
|
||||
.start-menu-pop button:hover{background:#1a3b2d;color:#fff}
|
||||
.hero-hint{font-size:11px;color:#759485}
|
||||
.hero-hint a{color:#a8d9c2;text-decoration:underline}
|
||||
.hero-clock{position:absolute;right:26px;bottom:18px;color:#70917f;font-size:11px}
|
||||
|
||||
/* stats */
|
||||
.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid #1b3529;
|
||||
border-left:1px solid #1b3529;margin-top:12px}
|
||||
.stat-card{border-right:1px solid #1b3529;border-bottom:1px solid #1b3529;padding:10px 16px 12px;background:#0b1712}
|
||||
.stat-label{font-size:11px;color:#789786;margin-bottom:6px}
|
||||
.stat-row{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:#88a696;line-height:1.9}
|
||||
.stat-row b{font-weight:400;color:#d1e6dc;max-width:64%;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.section-head{display:flex;align-items:baseline;justify-content:space-between;margin:18px 0 6px}
|
||||
.section-head h2{font-size:13px;font-weight:400;color:#a8c8b8;margin:0}
|
||||
.section-head span{font-size:11px;color:#607f70}
|
||||
.changelog{border-top:1px solid #1b3529}
|
||||
.change-row{display:flex;gap:14px;align-items:center;padding:8px 2px;border-bottom:1px solid #152a22;font-size:11px;color:#8eada0}
|
||||
.change-row span{color:#4c9c76;width:72px;flex:none;font-family:Consolas,monospace}
|
||||
.change-row b{font-weight:400;color:#bdd5c8;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.change-row em{font-style:normal;color:#5e7e6f;width:70px;text-align:right}
|
||||
|
||||
/* buttons/input */
|
||||
.shell-btn{background:#11271e;border:1px solid #2b5542;color:#b8d8c8;padding:6px 12px;cursor:pointer;border-radius:2px}
|
||||
.shell-btn:hover{background:#19392b;color:#fff}
|
||||
.shell-btn.primary{background:#1d5c43;border-color:#3d9970;color:#e3fff1}
|
||||
.shell-input{background:#0a1712;border:1px solid #274b3a;color:#d6e9df;padding:6px 8px;width:100%;border-radius:2px}
|
||||
.shell-input:focus{outline:1px solid #4d9f77;border-color:#4d9f77}
|
||||
|
||||
/* loadout */
|
||||
.loadout-layout{display:grid;grid-template-columns:300px 1fr;gap:14px}
|
||||
.loadout-side,.loadout-main{border:1px solid #1d3a2d;background:#0b1712;padding:10px}
|
||||
.lo-pages{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.lo-page{padding:4px 9px;border:1px solid #284c3b;color:#9bbdac;cursor:pointer;font-size:11px}
|
||||
.lo-page.active{background:#1b4936;color:#d5f6e4;border-color:#4e9e77}
|
||||
.lo-list{min-height:210px;max-height:330px;overflow:auto;border-top:1px solid #1b3328}
|
||||
.lo-item{padding:8px;border-bottom:1px solid #172c23;cursor:pointer;color:#a9c7b7;font-size:12px}
|
||||
.lo-item:hover,.lo-item.active{background:#143225;color:#e1f7ea}
|
||||
.lo-side-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
||||
.lo-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}
|
||||
.lo-form label{font-size:11px;color:#83a393;display:flex;flex-direction:column;gap:4px}
|
||||
.lo-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-top:1px solid #1b3328;padding-top:9px}
|
||||
.lo-slots{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin-top:10px;max-height:320px;overflow:auto}
|
||||
.lo-slot{border:1px solid #1d3a2d;background:#0e2019;padding:6px;font-size:11px;color:#9ebdac}
|
||||
.lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace}
|
||||
|
||||
/* server tool */
|
||||
.st-tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid #244435;margin-bottom:10px}
|
||||
.st-tab{background:none;border:1px solid transparent;border-bottom:0;color:#8dac9c;padding:8px 13px;cursor:pointer;font-size:13px}
|
||||
.st-tab:hover{color:#d8eee3}
|
||||
.st-tab.active{background:#10251c;border-color:#2a503d;color:#8fe5b8}
|
||||
.st-subtabs{display:flex;gap:4px;margin:-2px 0 10px;padding:6px 0;border-bottom:1px solid #1d3a2d}
|
||||
.st-subtab{background:transparent;border:1px solid transparent;color:#8dac9c;padding:3px 9px;cursor:pointer;font-size:11px;border-radius:5px}
|
||||
.st-subtab.active{background:rgba(124,92,255,.16);border-color:rgba(124,92,255,.35);color:#dcd4ff}
|
||||
.st-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.st-body{border:1px solid #1d3a2d;background:#0b1712;padding:14px;min-height:300px}
|
||||
.st-section{margin-bottom:16px}
|
||||
.st-section h3{margin:0 0 8px;color:#9cd8b8;font-size:15px;font-weight:400;border-left:2px solid #4d9f77;padding-left:8px}
|
||||
.st-form{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px 18px}
|
||||
.st-form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:#83a393}
|
||||
.st-hint{font-size:11.5px;color:#687f73;line-height:1.4;margin-bottom:1px}
|
||||
.st-form input,.st-form select{background:#08130f;border:1px solid #254636;color:#d2e7db;padding:8px 9px;border-radius:2px;font-size:13px;height:34px}
|
||||
.st-check{display:flex;align-items:center;gap:6px;font-size:13px;color:#b7d4c5;padding:5px 0}
|
||||
.st-check-row{display:flex;gap:28px;flex-wrap:wrap;border:1px solid #1d3a2d;background:#0b1712;padding:8px 12px;margin-bottom:10px;border-radius:3px}
|
||||
.st-check-row .st-check{color:#d5e8de;font-size:13px}
|
||||
.st-check input{width:15px;height:15px}
|
||||
.st-note{font-size:12.5px;color:#638373;margin:6px 0}
|
||||
.st-json{margin-top:14px;border-top:1px solid #1d3a2d;padding-top:10px;color:#8eae9d;font-size:12px}
|
||||
.st-raw{width:100%;height:300px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
|
||||
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:3px;margin:6px 0}
|
||||
.st-json summary{cursor:pointer;color:#a9d8bf;margin-bottom:8px}
|
||||
.st-json textarea{width:100%;height:220px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
|
||||
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:2px}
|
||||
|
||||
/* GM embedded */
|
||||
#page-gm{padding:0}
|
||||
#page-gm.active{height:100%}
|
||||
#page-gm #gmView{height:100%;display:flex;background:#0b0b14;border:0}
|
||||
#page-gm .gm-sidebar{width:180px}
|
||||
#page-gm .gm-main{flex:1;min-width:0}
|
||||
#page-gm .gm-content{height:calc(100% - 58px);overflow:auto}
|
||||
|
||||
/* statusbar */
|
||||
.shell-statusbar{height:30px;display:flex;align-items:center;gap:18px;padding:0 14px;
|
||||
background:#07110e;border-top:1px solid #183025;color:#739283;font-size:11px}
|
||||
.shell-statusbar b{color:#a9cbb9;font-weight:400}
|
||||
.shell-statusbar .grow{flex:1}
|
||||
.shell-statusbar .live-dot{width:6px;height:6px}
|
||||
|
||||
@media (max-width:1000px){
|
||||
.stat-grid{grid-template-columns:repeat(2,1fr)}
|
||||
.loadout-layout{grid-template-columns:1fr}
|
||||
.st-form{grid-template-columns:repeat(2,1fr)}
|
||||
}
|
||||
|
||||
/* ================= 沿用原启动器配色与图标 ================= */
|
||||
:root{
|
||||
--bg:#0b0b14; --bg2:#10101f; --panel:rgba(20,20,36,.82); --panel2:#141424;
|
||||
--line:rgba(255,255,255,.08); --text:#f2f3f8; --muted:#6d7188;
|
||||
--green:#35d07f; --green2:#7c5cff; --dim:#8d91a8;
|
||||
--danger:#ff5d6c; --gold:#ffc44d;
|
||||
--primary:#7c5cff; --primary2:#b45cff;
|
||||
}
|
||||
#appShell{background:radial-gradient(1200px 700px at 20% -10%,rgba(124,92,255,.22),transparent 55%),
|
||||
radial-gradient(1000px 650px at 100% 0%,rgba(56,217,240,.12),transparent 50%),var(--bg)}
|
||||
.shell-topnav{background:rgba(12,12,24,.92);border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.shell-brand{border-bottom:0}
|
||||
.shell-logo{border-color:rgba(124,92,255,.55);color:#c9beff;background:linear-gradient(135deg,rgba(124,92,255,.25),rgba(180,92,255,.12))}
|
||||
.shell-brand b{color:#f2f3f8}
|
||||
.shell-brand span,.shell-brand em{color:#8d91a8}
|
||||
.shell-nav-item{color:#b8bcd0;border-bottom-color:transparent;border-radius:4px 4px 0 0;margin:0 2px;padding:8px 13px}
|
||||
.shell-nav-item:hover{background:rgba(255,255,255,.07);color:#fff}
|
||||
.shell-nav-item.active{background:rgba(124,92,255,.18);color:#e8e2ff;border-bottom-color:var(--primary)}
|
||||
.nav-ico{display:inline-block;width:22px;color:#9c8cff;font-size:13px;text-align:center}
|
||||
.shell-nav-item.active .nav-ico{color:#c9beff}
|
||||
.shell-nav-gap{border-left-color:rgba(255,255,255,.08)}
|
||||
.shell-side-foot{color:#5d6178}
|
||||
.shell-main{background:radial-gradient(900px 400px at 70% -10%,rgba(124,92,255,.18),transparent 60%),var(--bg)}
|
||||
.shell-topbar{border-bottom-color:rgba(255,255,255,.08);color:#8d91a8}
|
||||
.shell-top-actions select{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.12);color:#dcdff0;border-radius:8px}
|
||||
.tb-btn{border:0;border-radius:8px;background:rgba(255,255,255,.07);color:#b8bcd0}
|
||||
.tb-btn:hover{background:rgba(255,255,255,.16);color:#fff}
|
||||
.tb-close:hover{background:var(--danger);color:#fff}
|
||||
.page-pad h2,.hero h1{color:#f2f3f8}
|
||||
.hero{border-color:rgba(255,255,255,.09);background:#11111f}
|
||||
.hero-bg{background:
|
||||
linear-gradient(90deg,#0b0b14 0%,rgba(11,11,20,.92) 32%,rgba(20,20,44,.45) 62%,rgba(11,11,20,.92)),
|
||||
radial-gradient(520px 320px at 82% 35%,rgba(124,92,255,.38),transparent 65%),
|
||||
radial-gradient(420px 260px at 65% 80%,rgba(56,217,240,.16),transparent 70%),
|
||||
repeating-linear-gradient(115deg,rgba(124,92,255,.05) 0 1px,transparent 1px 26px)}
|
||||
.hero-bg:after{background:radial-gradient(ellipse at 50% 40%,rgba(180,92,255,.18),transparent 65%)}
|
||||
.hero-kicker{color:#8d91a8}
|
||||
.hero-status{color:#c4c8dc}
|
||||
.hero-status .muted{color:#8d91a8}
|
||||
.live-dot{background:var(--green);box-shadow:0 0 10px var(--green)}
|
||||
.start-btn{background:linear-gradient(135deg,var(--primary),var(--primary2));border:1px solid rgba(180,160,255,.55);color:#fff;box-shadow:0 6px 20px rgba(124,92,255,.28);display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.start-btn:hover{filter:brightness(1.1)}
|
||||
.start-menu{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-left:0;color:#c9c4e8;border-radius:0 9px 9px 0}
|
||||
.hero-actions .start-btn{border-radius:9px 0 0 9px}
|
||||
.start-menu-pop{background:#141424;border-color:rgba(255,255,255,.14);border-radius:10px}
|
||||
.start-menu-pop button{color:#c4c8dc}
|
||||
.start-menu-pop button:hover{background:rgba(124,92,255,.2);color:#fff}
|
||||
.hero-hint{color:#8d91a8}.hero-hint a{color:#b9aaff}
|
||||
.hero-clock{color:#7d8198}
|
||||
.stat-grid{border-color:rgba(255,255,255,.08)}
|
||||
.stat-card{border-color:rgba(255,255,255,.08);background:rgba(20,20,36,.55)}
|
||||
.stat-label{color:#8d91a8}.stat-row{color:#9a9eb5}.stat-row b{color:#e2e4f2}
|
||||
.green{color:var(--green)!important}
|
||||
.section-head h2{color:#c9cbe0}.section-head span{color:#6d7188}
|
||||
.changelog{border-top-color:rgba(255,255,255,.08)}
|
||||
.change-row{border-bottom-color:rgba(255,255,255,.06);color:#9a9eb5}
|
||||
.change-row span{color:#a897ff}.change-row b{color:#d4d6e6}.change-row em{color:#6d7188}
|
||||
.shell-btn{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.13);color:#d0d2e4;border-radius:8px}
|
||||
.shell-btn:hover{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.22)}
|
||||
.shell-btn.primary{background:linear-gradient(135deg,var(--primary),var(--primary2));border-color:rgba(180,160,255,.45);color:#fff}
|
||||
.shell-input{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:8px}
|
||||
.shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)}
|
||||
.tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)}
|
||||
.empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)}
|
||||
.loadout-side,.loadout-main,.st-body{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
|
||||
.lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px}
|
||||
.lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)}
|
||||
.lo-list{border-top-color:rgba(255,255,255,.08)}
|
||||
.lo-item{border-bottom-color:rgba(255,255,255,.06);color:#b8bcd0}
|
||||
.lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8}
|
||||
.lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8}
|
||||
.lo-slot b{color:#eef0fb}
|
||||
.st-tabs{border-bottom-color:rgba(255,255,255,.1)}
|
||||
.st-tab{color:#9a9eb5}.st-tab:hover{color:#fff}
|
||||
.st-tab.active{background:rgba(124,92,255,.15);border-color:rgba(124,92,255,.35);color:#dcd4ff;border-radius:8px 8px 0 0}
|
||||
.st-section h3{color:#c9beff;border-left-color:var(--primary)}
|
||||
.st-form label,.st-json{color:#9a9eb5}
|
||||
.st-form input,.st-form select{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:6px}
|
||||
.st-form input:focus,.st-form select:focus{outline:1px solid var(--primary)}
|
||||
.st-check{color:#c4c8dc}
|
||||
.st-json summary{color:#c9beff}
|
||||
.st-json textarea{background:rgba(0,0,0,.25);border-color:rgba(255,255,255,.13);color:#e6e8f5}
|
||||
.shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198}
|
||||
.shell-statusbar b{color:#b8bcd0}
|
||||
#page-gm #gmView{background:#0b0b14}
|
||||
.settings-card{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
|
||||
.settings-label{color:#8d91a8}.settings-value{color:#e2e4f2}
|
||||
.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
|
||||
.cp-toolbar select{min-width:220px}
|
||||
.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px;
|
||||
padding:10px 16px;margin-bottom:18px;background:rgba(20,20,36,.45)}
|
||||
.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
|
||||
.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
|
||||
.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
|
||||
.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
|
||||
.cp-card b{font-size:14px;display:block;color:#eef0fb}
|
||||
.cp-card small{font-size:12px;color:#9a9eb5}
|
||||
.cp-card input{margin-right:7px}
|
||||
.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
|
||||
.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
|
||||
.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
|
||||
.cp-ai-head select{width:240px}
|
||||
.cp-ai .st-form{margin-bottom:10px}
|
||||
|
||||
/* ================= 字号整体 +2,间距放宽 ================= */
|
||||
body{font-size:16px}
|
||||
.shell-brand{height:100%;padding:0 4px;gap:12px}
|
||||
.shell-logo{width:36px;height:36px;font-size:14px}
|
||||
.shell-brand b{font-size:17px}
|
||||
.shell-brand span{font-size:12px}
|
||||
.shell-nav{padding:0;gap:4px}
|
||||
.shell-nav-item{font-size:15px;padding:12px 15px;margin:0 3px}
|
||||
.nav-ico{width:24px;font-size:15px}
|
||||
.shell-nav-gap{margin:0 6px}
|
||||
.shell-side-foot{font-size:13px;padding:0 4px}
|
||||
.shell-topbar{position:sticky;top:0;z-index:300;flex:none;height:50px;font-size:14px;padding:0 22px;
|
||||
background:rgba(10,10,24,.94);backdrop-filter:blur(12px);
|
||||
box-shadow:0 1px 0 rgba(255,255,255,.06),0 8px 18px rgba(0,0,0,.18)}
|
||||
.tb-btn{width:34px;height:30px;font-size:16px;border-radius:9px}
|
||||
.shell-top-actions{gap:10px}
|
||||
.hero{min-height:292px;margin:20px 0 16px}
|
||||
.hero-content{padding:30px 36px}
|
||||
.hero-kicker{font-size:13px}
|
||||
.hero h1{font-size:40px;margin:6px 0 4px}
|
||||
.hero-status{font-size:14px;gap:12px}
|
||||
.hero-actions{gap:0;margin:22px 0 12px}
|
||||
.start-btn{width:250px;height:52px;font-size:19px;border-radius:10px}
|
||||
.start-menu{width:40px;height:52px;font-size:14px}
|
||||
.account-login{height:52px;margin-left:14px;padding:0 18px;border-radius:10px}
|
||||
.account-login .account-ico{font-size:21px}
|
||||
.account-login b{font-size:15px}
|
||||
.account-login small{font-size:12px;max-width:140px}
|
||||
.account-picker{height:52px;margin-left:10px;min-width:180px;padding:6px 12px;border-radius:10px}
|
||||
.account-picker-label{font-size:12px}
|
||||
.account-picker select{font-size:14px}
|
||||
.stop-server-btn{height:52px;margin-left:12px;padding:0 18px;font-size:15px;border-radius:10px}
|
||||
.hero-hint{font-size:13px;margin-top:10px}
|
||||
.hero-clock{font-size:13px;right:32px;bottom:22px}
|
||||
.start-menu-pop{min-width:220px;margin-top:56px}
|
||||
.start-menu-pop button{font-size:14px;padding:11px 14px}
|
||||
.stat-grid{margin-top:16px}
|
||||
.stat-card{padding:14px 18px 16px}
|
||||
.stat-label{font-size:13px;margin-bottom:9px}
|
||||
.stat-row{font-size:13px;line-height:2.15;gap:12px}
|
||||
.stat-row b{font-size:13px}
|
||||
.section-head h2{font-size:15px}
|
||||
.section-head span{font-size:13px}
|
||||
.change-row{font-size:13px}
|
||||
.page-pad h2{font-size:22px}
|
||||
.page-heading{margin-bottom:16px}
|
||||
.shell-btn{font-size:14px;padding:8px 15px;border-radius:8px}
|
||||
.shell-input{font-size:14px;padding:8px 10px}
|
||||
.muted{font-size:13px}
|
||||
.loadout-layout{gap:18px}
|
||||
.loadout-side,.loadout-main{padding:14px}
|
||||
.lo-pages{gap:8px;margin-bottom:12px}
|
||||
.lo-page{font-size:13px;padding:6px 11px}
|
||||
.lo-list{min-height:240px}
|
||||
.lo-item{font-size:14px;padding:11px 9px}
|
||||
.lo-side-actions{gap:8px;margin-top:14px}
|
||||
.lo-form{gap:12px;margin-bottom:14px}
|
||||
.lo-form label{font-size:13px;gap:6px}
|
||||
.lo-toolbar{gap:10px;padding-top:12px}
|
||||
.lo-cooldown{font-size:12px;color:#f0c674;white-space:nowrap}
|
||||
.lo-cooldown.active{color:#ffcf7a}
|
||||
.lo-slot{font-size:13px;padding:9px}
|
||||
.lo-slot b{font-size:13px}
|
||||
.st-tabs{gap:4px;margin-bottom:14px}
|
||||
.st-tab{font-size:15px;padding:9px 14px}
|
||||
.st-subtabs{gap:6px;padding:8px 0;margin-bottom:12px}
|
||||
.st-subtab{font-size:13px;padding:5px 11px}
|
||||
.st-toolbar{gap:10px;margin-bottom:14px}
|
||||
.st-body{padding:18px;min-height:380px}
|
||||
.st-section{margin-bottom:20px}
|
||||
.st-section h3{font-size:17px;margin-bottom:12px;padding-left:10px}
|
||||
.st-form{grid-template-columns:repeat(2,minmax(280px,1fr));gap:16px 22px}
|
||||
.st-form label{font-size:15px;gap:6px}
|
||||
.st-form input,.st-form select{font-size:15px;padding:9px 10px;height:38px}
|
||||
.st-hint{font-size:13px;line-height:1.5}
|
||||
.st-note{font-size:14px;line-height:1.6}
|
||||
.st-check{font-size:15px;gap:9px;padding:8px 0}
|
||||
.st-check input{width:17px;height:17px}
|
||||
.st-check-row{gap:34px;padding:12px 16px;margin-bottom:14px}
|
||||
.st-check-row .st-check{font-size:15px}
|
||||
.st-json{font-size:14px;padding-top:14px;margin-top:18px}
|
||||
.st-json summary{font-size:14px;margin-bottom:10px}
|
||||
.st-json textarea,.st-raw{font-size:13px;height:260px}
|
||||
.shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px}
|
||||
.settings-grid{gap:14px;margin-top:18px}
|
||||
.settings-card{padding:16px 18px}
|
||||
.settings-label{font-size:13px}
|
||||
.settings-value{font-size:13px;line-height:1.7}
|
||||
@media (max-width:1100px){
|
||||
.stat-grid{grid-template-columns:repeat(2,1fr)}
|
||||
.st-form{grid-template-columns:1fr}
|
||||
.hero-actions{flex-wrap:wrap;gap:10px}
|
||||
}
|
||||
.win-resize{position:fixed;z-index:10000;display:none}
|
||||
.win-resize.n{left:8px;right:8px;top:0;height:5px;cursor:n-resize}
|
||||
.win-resize.s{left:8px;right:8px;bottom:0;height:5px;cursor:s-resize}
|
||||
.win-resize.w{top:8px;bottom:8px;left:0;width:5px;cursor:w-resize}
|
||||
.win-resize.e{top:8px;bottom:8px;right:0;width:5px;cursor:e-resize}
|
||||
.win-resize.nw{left:0;top:0;width:9px;height:9px;cursor:nw-resize}
|
||||
.win-resize.ne{right:0;top:0;width:9px;height:9px;cursor:ne-resize}
|
||||
.win-resize.sw{left:0;bottom:0;width:9px;height:9px;cursor:sw-resize}
|
||||
.win-resize.se{right:0;bottom:0;width:9px;height:9px;cursor:se-resize}
|
||||
|
||||
/* ================= GM 全屏模式(点击 GM 标签覆盖整个窗口) ================= */
|
||||
body.gm-fullscreen #appShell{height:100vh}
|
||||
body.gm-fullscreen .shell-topnav{display:none}
|
||||
body.gm-fullscreen .shell-topbar{display:none}
|
||||
body.gm-fullscreen .shell-statusbar{display:none}
|
||||
body.gm-fullscreen #page-gm #gmView{height:100%}
|
||||
body.gm-fullscreen .shell-main{overflow:hidden}
|
||||
/* GM 全屏下隐藏窗口缩放热区(铺满工作区时不可再拖边) */
|
||||
body.gm-fullscreen .win-resize{display:none}
|
||||
|
||||
/* GM 顶部栏内的窗口按钮:仅在全屏模式(顶部导航隐藏)时显示 */
|
||||
.gm-win-btn{display:none;width:28px;height:24px;border:1px solid var(--border,#25483a);
|
||||
background:transparent;color:#a8c7b6;cursor:pointer;font-size:14px;line-height:1;
|
||||
border-radius:3px;flex-shrink:0}
|
||||
.gm-win-btn:hover{background:#1a3a2c;color:#fff}
|
||||
.gm-win-close:hover{background:#7a2a33;border-color:#a33a44;color:#fff}
|
||||
body.gm-fullscreen .gm-win-btn{display:inline-block}
|
||||
|
||||
/* ================= 标题栏右侧窗口控制按钮(最小化/关闭,无边框线性图标) ================= */
|
||||
.shell-topnav{cursor:move;user-select:none;padding:0 6px 0 16px}
|
||||
.shell-topnav button,.shell-topnav .shell-side-foot{cursor:default}
|
||||
.shell-win-controls{flex:none;display:flex;align-items:center;gap:2px;margin-left:auto}
|
||||
.shell-win-controls .tb-btn{
|
||||
width:42px;height:100%;border:0;background:transparent;color:#d6d9ea;
|
||||
font-size:16px;line-height:1;border-radius:0;cursor:pointer;padding:0;
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff}
|
||||
.shell-win-controls .tb-close:hover{background:var(--danger);color:#fff}
|
||||
|
||||
@@ -0,0 +1,981 @@
|
||||
/* 90轮回启动器 · 单窗口页面与工具整合 */
|
||||
(function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog'];
|
||||
let shellGmInit = false;
|
||||
let loConfig = null, loPage = 0, loScheme = -1;
|
||||
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
|
||||
let stFeatures = {}, stActivity = {}, stTab = 'global';
|
||||
let stGameplay = {};
|
||||
let stBatchPickup = true;
|
||||
let hotkeyCapture = false;
|
||||
let stRaidTab = 'anton';
|
||||
let stFeatureTab = 'instance.json';
|
||||
let stRawText = '';
|
||||
|
||||
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',companion:'工具 / 支援兵',servertool:'工具 / 服务端工具',plugins:'插件',settings:'设置',changelog:'更新日志'};
|
||||
|
||||
function shellGo(page) {
|
||||
if (!pages.includes(page)) page = 'start';
|
||||
document.querySelectorAll('.shell-page').forEach(el => el.classList.toggle('active', el.id === 'page-' + page));
|
||||
document.querySelectorAll('.shell-nav-item').forEach(el => el.classList.toggle('active', el.dataset.shellPage === page));
|
||||
$('shellCrumb').textContent = crumb[page] || page;
|
||||
if (page === 'start') loadStart();
|
||||
if (page === 'loadout') loadLoadout();
|
||||
if (page === 'companion') loadCompanion();
|
||||
if (page === 'servertool') loadServerTool();
|
||||
if (page === 'settings') loadSettings();
|
||||
if (page === 'gm') {
|
||||
// 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区
|
||||
document.body.classList.add('gm-fullscreen');
|
||||
const w = win();
|
||||
if (w && w.enter_gm) w.enter_gm();
|
||||
if (!shellGmInit && typeof initGm === 'function') {
|
||||
shellGmInit = true;
|
||||
initGm();
|
||||
if (typeof renderGmPage === 'function') renderGmPage('dashboard');
|
||||
} else if (typeof renderGmPage === 'function') {
|
||||
renderGmPage(gmCurrentPage || 'dashboard');
|
||||
}
|
||||
} else {
|
||||
// 离开 GM:移除全屏状态,并恢复窗口几何(若正处于全屏)
|
||||
if (document.body.classList.contains('gm-fullscreen')) {
|
||||
document.body.classList.remove('gm-fullscreen');
|
||||
const w2 = win();
|
||||
if (w2 && w2.restore_gm) w2.restore_gm();
|
||||
}
|
||||
}
|
||||
}
|
||||
window.shellGo = shellGo;
|
||||
|
||||
// 从 GM 返回启动器:恢复窗口几何 + 回到开始页
|
||||
function shellExitGm() {
|
||||
document.body.classList.remove('gm-fullscreen');
|
||||
const w = win();
|
||||
if (w && w.restore_gm) w.restore_gm();
|
||||
shellGo('start');
|
||||
}
|
||||
window.shellExitGm = shellExitGm;
|
||||
|
||||
document.querySelectorAll('.shell-nav-item').forEach(el => {
|
||||
el.addEventListener('click', () => shellGo(el.dataset.shellPage));
|
||||
});
|
||||
|
||||
// 顶部窗口按钮
|
||||
const win = () => (window.pywebview && window.pywebview.api) || null;
|
||||
const bMin = $('shellWinMin'); if (bMin) bMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); });
|
||||
const bClose = $('shellWinClose'); if (bClose) bClose.addEventListener('click', () => { const w = win(); if (w && w.close) w.close(); });
|
||||
|
||||
// GM 全屏模式下的窗口按钮(最小化 / 关闭)
|
||||
const gmWinMin = $('gmWinMin');
|
||||
const gmWinClose = $('gmWinClose');
|
||||
if (gmWinMin) gmWinMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); });
|
||||
if (gmWinClose) gmWinClose.addEventListener('click', () => { const w = win(); if (w && w.close) w.close(); });
|
||||
|
||||
// 账号登录:打开原账号管理弹窗,可选择默认账号
|
||||
$('shellAccount').addEventListener('click', () => {
|
||||
if (typeof openAccounts === 'function') openAccounts();
|
||||
else alert('账号管理未加载');
|
||||
});
|
||||
|
||||
async function shellAddAccount() {
|
||||
const user = $('newAccUser').value.trim();
|
||||
const pass = $('newAccPass').value;
|
||||
const toast = (msg, type) => {
|
||||
if (typeof gmToast === 'function') gmToast(msg, type || 'info');
|
||||
else alert(msg);
|
||||
};
|
||||
if (!user || !pass) { toast('请填写用户名和密码', 'err'); return; }
|
||||
try {
|
||||
const r = await post('/api/accounts-config', {op:'add', username:user, password:pass});
|
||||
if (!r.ok) { toast('添加失败:' + (r.error || '未知错误'), 'err'); return; }
|
||||
$('newAccUser').value = '';
|
||||
$('newAccPass').value = '';
|
||||
$('accountsModal').classList.add('hidden');
|
||||
await loadAccountLabel();
|
||||
toast('账号添加成功:' + user, 'ok');
|
||||
} catch (e) { toast('添加失败:' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
// app.js 的 init 如果没有完成绑定,这里也保证添加按钮可用
|
||||
const addBtn = $('btnAddAccount');
|
||||
if (addBtn) {
|
||||
const fresh = addBtn.cloneNode(true);
|
||||
addBtn.replaceWith(fresh);
|
||||
fresh.addEventListener('click', shellAddAccount);
|
||||
}
|
||||
|
||||
// 开始页
|
||||
let startState = 'idle'; // idle | starting | started
|
||||
function setStartState(state) {
|
||||
startState = state;
|
||||
const btn = $('shellStart');
|
||||
if (!btn) return;
|
||||
btn.disabled = state === 'starting';
|
||||
if (state === 'starting') btn.innerHTML = '<span>⏳</span> 游戏启动中…';
|
||||
else if (state === 'started') btn.innerHTML = '<span>✔</span> 游戏已启动';
|
||||
else btn.innerHTML = '<span>▶</span> 开始游戏';
|
||||
}
|
||||
$('shellStart').addEventListener('click', () => {
|
||||
if (startState === 'starting') return;
|
||||
setStartState('starting');
|
||||
doAction('start');
|
||||
});
|
||||
$('shellStartMenu').addEventListener('click', () => $('shellStartPop').classList.toggle('hidden'));
|
||||
document.querySelectorAll('#shellStartPop button').forEach(b => b.addEventListener('click', () => {
|
||||
$('shellStartPop').classList.add('hidden');
|
||||
if (b.dataset.shellAction === 'stop') setStartState('idle');
|
||||
doAction(b.dataset.shellAction);
|
||||
}));
|
||||
$('shellStop').addEventListener('click', () => {
|
||||
setStartState('idle');
|
||||
doAction('stop');
|
||||
});
|
||||
|
||||
async function doAction(action) {
|
||||
const map = {start:'start', launch:'start', stop:'stop'};
|
||||
const payload = {action: map[action] || action};
|
||||
if (payload.action === 'start') {
|
||||
payload.account = $('shellAccountSelect').value || '';
|
||||
}
|
||||
try {
|
||||
const r = await post('/api/action', payload);
|
||||
if (!r.ok) {
|
||||
if (startState === 'starting') setStartState('idle');
|
||||
alert(r.error || '操作失败');
|
||||
}
|
||||
setTimeout(loadStart, 900);
|
||||
} catch (e) {
|
||||
if (startState === 'starting') setStartState('idle');
|
||||
alert('操作失败:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStart() {
|
||||
try {
|
||||
const s = await api('/api/status');
|
||||
const running = !!s.server_running;
|
||||
if (running && startState !== 'started') setStartState('started');
|
||||
else if (!running && startState === 'started') setStartState('idle');
|
||||
$('shellStatusDot').style.background = running ? '#3dd68b' : '#5a7568';
|
||||
$('shellStatusText').textContent = running ? '运行中' : '未运行';
|
||||
$('shellPid').textContent = running ? '服务端已启动' : 'PID —';
|
||||
$('shellBottomStatus').textContent = running ? '运行中' : '未运行';
|
||||
$('statClient').textContent = running ? '运行中' : '未运行';
|
||||
$('statPorts').textContent = running ? '18111 / 7001' : '—';
|
||||
$('statPvf').textContent = s.db_exists ? '已校验通过' : '未检测';
|
||||
$('statClientDir').textContent = s.client_dir || '—';
|
||||
$('statDb').textContent = s.db_exists ? 'SQLite 就绪' : '未创建';
|
||||
$('statGmPort').textContent = '18111';
|
||||
$('statApiPort').textContent = location.port || '19527';
|
||||
$('shellBottomPort').textContent = running ? '18111 / 7001' : '—';
|
||||
$('statState').textContent = s.control_exists && s.client_dir_exists ? '已就绪' : '不完整';
|
||||
$('statVersion').textContent = 'v2026.11.03';
|
||||
$('statRemote').textContent = '当前版本';
|
||||
$('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失';
|
||||
$('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
|
||||
$('statResource').textContent = s.admin ? '管理员' : '普通权限';
|
||||
loadAccountLabel();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadAccountLabel() {
|
||||
try {
|
||||
const r = await api('/api/accounts-config');
|
||||
const list = (r && r.data) || [];
|
||||
const selected = list.find(a => a.is_default) || list[0];
|
||||
$('shellAccountName').textContent = selected ? selected.username : '未配置账号';
|
||||
const sel = $('shellAccountSelect');
|
||||
if (sel && list.length) {
|
||||
const current = sel.value;
|
||||
sel.innerHTML = list.map(a => `<option value="${a.username}">${a.username}</option>`).join('');
|
||||
sel.value = selected ? selected.username : (current || list[0].username);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
$('shellAccountSelect').addEventListener('change', async () => {
|
||||
const username = $('shellAccountSelect').value;
|
||||
try {
|
||||
const r = await post('/api/accounts-config', {op:'select', username});
|
||||
if (r.ok) { $('shellAccountName').textContent = username; }
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
|
||||
// ---------------- 一键换装 ----------------
|
||||
function normalizeConfig(cfg) {
|
||||
cfg = cfg || {};
|
||||
if (!Array.isArray(cfg.Pages) || !cfg.Pages.length) cfg.Pages = [{Name:'默认页', Schemes:[]}];
|
||||
cfg.Pages.forEach(p => { if (!Array.isArray(p.Schemes)) p.Schemes = []; });
|
||||
cfg.ActivePage = Math.max(0, Math.min(Number(cfg.ActivePage) || 0, cfg.Pages.length - 1));
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function loadLoadout() {
|
||||
try {
|
||||
const r = await api('/api/loadout/config');
|
||||
loConfig = normalizeConfig(r.data);
|
||||
loPage = loConfig.ActivePage || 0;
|
||||
renderLoadout();
|
||||
} catch (e) { $('loStatus').textContent = '读取失败:' + e.message; }
|
||||
}
|
||||
|
||||
function currentPage() { return loConfig.Pages[loPage]; }
|
||||
function currentScheme() { return currentPage().Schemes[loScheme] || null; }
|
||||
|
||||
function loadoutCooldownRemaining() {
|
||||
return Math.max(0, Math.ceil((loCooldownUntil - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function updateLoadoutCooldown() {
|
||||
const s = currentScheme();
|
||||
const active = s && s.CharacterName === loCooldownCharacter;
|
||||
const remain = active ? loadoutCooldownRemaining() : 0;
|
||||
const el = $('loCooldown');
|
||||
if (remain > 0) {
|
||||
el.textContent = `换装冷却:${remain} 秒`;
|
||||
el.classList.add('active');
|
||||
$('loApply').disabled = true;
|
||||
} else {
|
||||
el.textContent = '';
|
||||
el.classList.remove('active');
|
||||
$('loApply').disabled = false;
|
||||
if (loCooldownTimer) { clearInterval(loCooldownTimer); loCooldownTimer = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function startLoadoutCooldown(character, seconds) {
|
||||
loCooldownCharacter = character || '';
|
||||
loCooldownUntil = Date.now() + Math.max(0, Number(seconds) || 0) * 1000;
|
||||
if (loCooldownTimer) clearInterval(loCooldownTimer);
|
||||
updateLoadoutCooldown();
|
||||
loCooldownTimer = setInterval(updateLoadoutCooldown, 250);
|
||||
}
|
||||
|
||||
|
||||
function renderLoadout() {
|
||||
const pages = $('loPages'); pages.innerHTML = '';
|
||||
loConfig.Pages.forEach((p, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'lo-page' + (i === loPage ? ' active' : '');
|
||||
b.textContent = p.Name || ('页 ' + (i + 1));
|
||||
b.onclick = async () => { loPage = i; loConfig.ActivePage = i; loScheme = -1; renderLoadout(); await saveLoadout(false); };
|
||||
pages.appendChild(b);
|
||||
});
|
||||
const list = $('loList'); list.innerHTML = '';
|
||||
currentPage().Schemes.forEach((s, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'lo-item' + (i === loScheme ? ' active' : '');
|
||||
d.textContent = `${s.Name || '方案'} [${s.CharacterName || '未绑定'}] ${hotkeyText(s.Modifiers, s.KeyCode)} (${(s.Slots||[]).length} 件)`;
|
||||
d.onclick = () => { loScheme = i; renderLoadout(); };
|
||||
list.appendChild(d);
|
||||
});
|
||||
const s = currentScheme();
|
||||
$('loName').value = s ? (s.Name || '') : '';
|
||||
$('loCharacter').value = s ? (s.CharacterName || '') : '';
|
||||
$('loHotkey').value = s ? hotkeyText(s.Modifiers, s.KeyCode) : '未设置';
|
||||
const slots = $('loSlots');
|
||||
slots.innerHTML = '';
|
||||
if (!s || !(s.Slots || []).length) {
|
||||
slots.innerHTML = '<div class="empty-box">选择方案后显示装备快照;点击「保存当前装备」从服务端读取。</div>';
|
||||
} else {
|
||||
s.Slots.forEach(it => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'lo-slot';
|
||||
d.innerHTML = `槽位 ${it.slot}<b>${it.item_id}</b>`;
|
||||
slots.appendChild(d);
|
||||
});
|
||||
}
|
||||
updateLoadoutCooldown();
|
||||
}
|
||||
|
||||
function hotkeyText(mods, key) {
|
||||
if (!key) return '未设置';
|
||||
const p = [];
|
||||
if (mods & 2) p.push('Ctrl');
|
||||
if (mods & 1) p.push('Alt');
|
||||
if (mods & 4) p.push('Shift');
|
||||
p.push(keyName(key));
|
||||
return p.join('+');
|
||||
}
|
||||
function keyName(code) {
|
||||
if (code >= 112 && code <= 123) return 'F' + (code - 111);
|
||||
if (code >= 65 && code <= 90) return String.fromCharCode(code);
|
||||
if (code >= 48 && code <= 57) return String.fromCharCode(code);
|
||||
return String(code);
|
||||
}
|
||||
|
||||
$('loAddPage').addEventListener('click', async () => {
|
||||
const name = prompt('换装页名称', '换装页' + (loConfig.Pages.length + 1));
|
||||
if (!name) return;
|
||||
loConfig.Pages.push({Name:name, Schemes:[]});
|
||||
loPage = loConfig.Pages.length - 1; loConfig.ActivePage = loPage; loScheme = -1;
|
||||
renderLoadout(); await saveLoadout();
|
||||
});
|
||||
|
||||
$('loAddScheme').addEventListener('click', async () => {
|
||||
const name = prompt('方案名称', '方案' + (currentPage().Schemes.length + 1));
|
||||
if (!name) return;
|
||||
const character = prompt('绑定角色游戏内名称(可稍后修改)', '') || '';
|
||||
currentPage().Schemes.push({Name:name, CharacterName:character, Modifiers:0, KeyCode:0, Slots:[]});
|
||||
loScheme = currentPage().Schemes.length - 1;
|
||||
renderLoadout(); await saveLoadout();
|
||||
});
|
||||
|
||||
$('loDeleteScheme').addEventListener('click', async () => {
|
||||
if (loScheme < 0) return;
|
||||
if (!confirm('删除当前方案?')) return;
|
||||
currentPage().Schemes.splice(loScheme, 1);
|
||||
loScheme = -1; renderLoadout(); await saveLoadout();
|
||||
});
|
||||
|
||||
$('loSaveConfig').addEventListener('click', () => saveLoadout());
|
||||
$('loName').addEventListener('change', () => { const s=currentScheme(); if(s){s.Name=$('loName').value; renderLoadout();} });
|
||||
$('loCharacter').addEventListener('change', () => { const s=currentScheme(); if(s){s.CharacterName=$('loCharacter').value.trim(); renderLoadout();} });
|
||||
|
||||
async function saveLoadout(show = true) {
|
||||
try {
|
||||
if (loConfig) { loConfig.ActivePage = loPage; const s=currentScheme(); if(s){s.Name=$('loName').value; s.CharacterName=$('loCharacter').value.trim();} }
|
||||
const r = await post('/api/loadout/config', {config: loConfig});
|
||||
if (r.ok) { if (show) $('loStatus').textContent = '配置已保存 ' + new Date().toLocaleTimeString(); }
|
||||
else $('loStatus').textContent = r.error || '保存失败';
|
||||
} catch (e) { $('loStatus').textContent = '保存失败:' + e.message; }
|
||||
}
|
||||
|
||||
$('loRefreshChars').addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await api('/api/loadout/characters');
|
||||
if (!r.ok) { $('loStatus').textContent = r.error || '读取失败'; return; }
|
||||
const list = r.data || [];
|
||||
$('loStatus').textContent = list.map(c => `${c.name}(${c.online?'在线':'离线'})`).join(' ') || '没有角色';
|
||||
} catch (e) { $('loStatus').textContent = e.message; }
|
||||
});
|
||||
|
||||
$('loSaveCurrent').addEventListener('click', async () => {
|
||||
const s = currentScheme();
|
||||
if (!s) { $('loStatus').textContent = '请先选择方案'; return; }
|
||||
if (!s.CharacterName) { $('loStatus').textContent = '请先填写绑定角色'; return; }
|
||||
try {
|
||||
const r = await api('/api/loadout/equipment?character=' + encodeURIComponent(s.CharacterName));
|
||||
if (!r.ok) { $('loStatus').textContent = r.error || '读取装备失败'; return; }
|
||||
s.Slots = r.data.slots || [];
|
||||
renderLoadout(); await saveLoadout();
|
||||
$('loStatus').textContent = `已保存 ${s.Slots.length} 个部位`;
|
||||
} catch (e) { $('loStatus').textContent = e.message; }
|
||||
});
|
||||
|
||||
$('loApply').addEventListener('click', async () => {
|
||||
const s = currentScheme();
|
||||
if (!s) { $('loStatus').textContent = '请先选择方案'; return; }
|
||||
if (s.CharacterName === loCooldownCharacter && loadoutCooldownRemaining() > 0) {
|
||||
$('loStatus').textContent = `换装冷却中,还需 ${loadoutCooldownRemaining()} 秒`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await post('/api/loadout/apply', {character:s.CharacterName, slots:s.Slots || []});
|
||||
if (r.ok) {
|
||||
const d = r.data || {};
|
||||
const applied = (d.applied || []).length;
|
||||
const skipped = (d.skipped || []).length;
|
||||
if (Number(d.cooldown_seconds) > 0) {
|
||||
startLoadoutCooldown(s.CharacterName, Number(d.cooldown_seconds));
|
||||
$('loStatus').textContent = `应用完成:成功 ${applied},跳过 ${skipped};换装冷却 ${d.cooldown_seconds} 秒`;
|
||||
} else {
|
||||
$('loStatus').textContent = `应用完成:成功 ${applied},跳过 ${skipped}`;
|
||||
}
|
||||
} else {
|
||||
const msg = r.error || '应用失败';
|
||||
const m = msg.match(/cooldown[^0-9]*(\d+)\s*seconds?/i);
|
||||
if (m) {
|
||||
startLoadoutCooldown(s.CharacterName, Number(m[1]));
|
||||
$('loStatus').textContent = `换装冷却中,还需 ${m[1]} 秒才能再次换装`;
|
||||
} else {
|
||||
$('loStatus').textContent = msg;
|
||||
}
|
||||
}
|
||||
} catch (e) { $('loStatus').textContent = e.message; }
|
||||
});
|
||||
|
||||
$('loCaptureHotkey').addEventListener('click', () => {
|
||||
if (!currentScheme()) { $('loStatus').textContent = '请先选择方案'; return; }
|
||||
hotkeyCapture = true; $('loHotkey').value = '请按下组合键…'; $('loHotkey').focus();
|
||||
});
|
||||
$('loHotkey').addEventListener('keydown', (e) => {
|
||||
if (!hotkeyCapture) return;
|
||||
e.preventDefault();
|
||||
let mods = 0;
|
||||
if (e.ctrlKey) mods |= 2;
|
||||
if (e.altKey) mods |= 1;
|
||||
if (e.shiftKey) mods |= 4;
|
||||
const key = e.keyCode;
|
||||
if (!key || key === 17 || key === 18 || key === 16) return;
|
||||
const s = currentScheme();
|
||||
if (!s) return;
|
||||
s.Modifiers = mods; s.KeyCode = key;
|
||||
hotkeyCapture = false;
|
||||
$('loHotkey').value = hotkeyText(mods, key);
|
||||
saveLoadout();
|
||||
});
|
||||
|
||||
// ---------------- 支援兵 ----------------
|
||||
let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set();
|
||||
let cpAIProfiles = {}, cpAISource = '';
|
||||
let cpLoading = false;
|
||||
const CP_AI_DEFAULTS = {
|
||||
behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto',
|
||||
combo_enabled:true, aoe_min_targets:3, protect_leader:true,
|
||||
tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90,
|
||||
chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85,
|
||||
heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700,
|
||||
detour_ms:900,give_up_ms:3500,follow_distance:75}
|
||||
};
|
||||
|
||||
async function loadCompanion() {
|
||||
if (cpLoading) return;
|
||||
cpLoading = true;
|
||||
$('cpStatus').textContent = '读取账号和角色…';
|
||||
try {
|
||||
const a = await api('/api/accounts');
|
||||
const ba = await api('/api/basic-attack');
|
||||
$('cpBasicAttack').checked = !ba || ba.enabled !== false;
|
||||
cpAccounts = (a && a.data) || [];
|
||||
const sel = $('cpAccount');
|
||||
sel.innerHTML = cpAccounts.map(x => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).join('');
|
||||
if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; }
|
||||
await loadCompanionCharacters();
|
||||
$('cpStatus').textContent = '';
|
||||
} catch (e) {
|
||||
$('cpStatus').textContent = '读取失败:' + e.message;
|
||||
} finally { cpLoading = false; }
|
||||
}
|
||||
|
||||
async function loadCompanionCharacters() {
|
||||
const account = $('cpAccount').value;
|
||||
const r = await api('/api/characters?account=' + encodeURIComponent(account));
|
||||
cpCharacters = (r && r.data) || [];
|
||||
const owner = $('cpOwner');
|
||||
owner.innerHTML = cpCharacters.map(c => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
|
||||
if (cpCharacters.length) await loadCompanionState();
|
||||
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
|
||||
}
|
||||
|
||||
async function loadCompanionState() {
|
||||
const owner = $('cpOwner').value;
|
||||
if (!owner) return;
|
||||
$('cpStatus').textContent = '读取支援兵配置…';
|
||||
try {
|
||||
const r = await api('/api/companion?character=' + encodeURIComponent(owner));
|
||||
if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; }
|
||||
cpState = r.data || {};
|
||||
const cfg = (cpState.state && cpState.state.config) || {};
|
||||
$('cpEnabled').checked = !!cfg.enabled;
|
||||
$('cpBalance').checked = !!cfg.balance_enabled;
|
||||
$('cpProgression').checked = !!cfg.progression_enabled;
|
||||
$('cpRandom').checked = !!cfg.random_equipment_enabled;
|
||||
cpSelected = new Set((cfg.source_character_ids || []).map(String));
|
||||
cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {}));
|
||||
cpAISource = [...cpSelected][0] || '';
|
||||
renderCompanionGrid();
|
||||
$('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取';
|
||||
} catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; }
|
||||
}
|
||||
|
||||
function renderCompanionGrid() {
|
||||
const owner = String($('cpOwner').value || '');
|
||||
const list = cpCharacters.filter(c => String(c.character_id) !== owner);
|
||||
const grid = $('cpGrid');
|
||||
if (!list.length) { grid.innerHTML = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
|
||||
grid.innerHTML = list.map(c => {
|
||||
const id = String(c.character_id), selected = cpSelected.has(id);
|
||||
return `<label class="cp-card ${selected?'selected':''}">
|
||||
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
|
||||
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
|
||||
</label>`;
|
||||
}).join('');
|
||||
grid.querySelectorAll('input[data-cp-source]').forEach(inp => {
|
||||
inp.onchange = () => {
|
||||
const id = inp.dataset.cpSource;
|
||||
if (inp.checked) cpSelected.add(id); else cpSelected.delete(id);
|
||||
inp.closest('.cp-card').classList.toggle('selected', inp.checked);
|
||||
renderCpAI();
|
||||
};
|
||||
});
|
||||
renderCpAI();
|
||||
}
|
||||
|
||||
function cpProfile(id) {
|
||||
const raw = cpAIProfiles[String(id)] || {};
|
||||
return {
|
||||
...CP_AI_DEFAULTS, ...raw,
|
||||
tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})}
|
||||
};
|
||||
}
|
||||
|
||||
function renderCpAI() {
|
||||
const select = $('cpAISource');
|
||||
const ids = [...cpSelected];
|
||||
if (!ids.includes(cpAISource)) cpAISource = ids[0] || '';
|
||||
select.innerHTML = ids.map(id => {
|
||||
const c = cpCharacters.find(x => String(x.character_id) === id);
|
||||
return `<option value="${id}">${c ? c.name : id}</option>`;
|
||||
}).join('');
|
||||
if (cpAISource) select.value = cpAISource;
|
||||
if (!cpAISource) {
|
||||
$('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first';
|
||||
$('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3;
|
||||
$('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true;
|
||||
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
|
||||
inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const p = cpProfile(cpAISource);
|
||||
$('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive';
|
||||
$('cpAIBuff').value = p.buff_policy || 'leader_first';
|
||||
$('cpAIComboPreset').value = p.combo_preset || 'auto';
|
||||
$('cpAIAoe').value = Number(p.aoe_min_targets) || 3;
|
||||
$('cpAIComboEnabled').checked = !!p.combo_enabled;
|
||||
$('cpAIProtect').checked = !!p.protect_leader;
|
||||
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
|
||||
inp.value = p.tuning[inp.dataset.cpTuning] ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
function readCpAI() {
|
||||
if (!cpAISource) return;
|
||||
const p = cpProfile(cpAISource);
|
||||
p.behavior = $('cpAIBehavior').value;
|
||||
p.buff_policy = $('cpAIBuff').value;
|
||||
p.combo_preset = $('cpAIComboPreset').value;
|
||||
p.combo_enabled = $('cpAIComboEnabled').checked;
|
||||
p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3));
|
||||
p.protect_leader = $('cpAIProtect').checked;
|
||||
p.tuning = {...p.tuning};
|
||||
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
|
||||
const n = Number(inp.value);
|
||||
if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n;
|
||||
});
|
||||
cpAIProfiles[String(cpAISource)] = p;
|
||||
}
|
||||
|
||||
|
||||
$('cpAccount').addEventListener('change', async () => {
|
||||
try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; }
|
||||
});
|
||||
$('cpOwner').addEventListener('change', loadCompanionState);
|
||||
$('cpReload').addEventListener('click', loadCompanion);
|
||||
$('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); });
|
||||
['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => {
|
||||
$(id).addEventListener('change', readCpAI);
|
||||
});
|
||||
document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI));
|
||||
$('cpBasicAttack').addEventListener('change', async () => {
|
||||
try {
|
||||
const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked});
|
||||
if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败';
|
||||
else $('cpStatus').textContent = $('cpBasicAttack').checked
|
||||
? '已允许支援兵普攻;重启客户端后生效。'
|
||||
: '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。';
|
||||
} catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; }
|
||||
});
|
||||
|
||||
$('cpSave').addEventListener('click', async () => {
|
||||
const owner = $('cpOwner').value;
|
||||
if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; }
|
||||
if ($('cpEnabled').checked && cpSelected.size === 0) {
|
||||
$('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return;
|
||||
}
|
||||
const state = (cpState && cpState.state) || {};
|
||||
const cfg = state.config || {};
|
||||
const sources = [...cpSelected];
|
||||
readCpAI();
|
||||
const payload = {
|
||||
characterId: String(owner),
|
||||
expectedRevision: Number(cfg.revision || 0),
|
||||
enabled: $('cpEnabled').checked,
|
||||
balanceEnabled: $('cpBalance').checked,
|
||||
progressionEnabled: $('cpProgression').checked,
|
||||
randomEquipmentEnabled: $('cpRandom').checked,
|
||||
sourceCharacterId: sources[0] || '',
|
||||
sourceCharacterIds: sources,
|
||||
ai: cfg.ai || {},
|
||||
aiProfiles: cpAIProfiles
|
||||
};
|
||||
$('cpStatus').textContent = '保存中…';
|
||||
try {
|
||||
const r = await post('/api/companion', payload);
|
||||
if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; }
|
||||
cpState = r.data || cpState;
|
||||
$('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。';
|
||||
} catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; }
|
||||
});
|
||||
|
||||
// ---------------- 服务端工具 ----------------
|
||||
async function loadServerTool() {
|
||||
try {
|
||||
$('stBody').innerHTML = '<div class="empty-box">正在读取服务端配置…</div>';
|
||||
const [f, a, s, g, b] = await Promise.all([api('/api/servertool/features'), api('/api/servertool/activity'), api('/api/status'), api('/api/servertool/gameplay'), api('/api/servertool/batch-pickup')]);
|
||||
stFeatures = f.data || {}; stActivity = a.data || {}; stGameplay = g.data || {};
|
||||
stBatchPickup = !!(b.data || {}).enabled;
|
||||
window.__stStatusInfo = s || {};
|
||||
$('stSource').textContent = f.source === 'native' ? 'Native API' : '文件配置';
|
||||
renderServerTool();
|
||||
} catch (e) {
|
||||
$('stBody').innerHTML = '<div class="empty-box">读取失败:' + (e && e.message ? e.message : e) + '</div>';
|
||||
$('stStatus').textContent = '读取失败';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.st-tab').forEach(b => b.addEventListener('click', () => {
|
||||
stTab = b.dataset.st;
|
||||
document.querySelectorAll('.st-tab').forEach(x => x.classList.toggle('active', x === b));
|
||||
renderServerTool();
|
||||
}));
|
||||
|
||||
const LABEL = {
|
||||
raidReviveSeconds:'副本复活秒数', allDeadReturnSeconds:'全员死亡返回秒数', allowFreeRevive:'允许免费复活',
|
||||
hellOrbChance:'深渊宝珠概率', hellDropChanceDenominator:'掉落分母', equipmentRollMultiplier:'装备倍率',
|
||||
epicPieceRollMultiplier:'史诗碎片倍率', lukeEnergyRecoveryPercent:'卢克能量恢复%', fiendWarSquadCount:'超时空队伍数',
|
||||
roomFatigueCost:'每房间疲劳', dailyFatigueResetHour:'每日疲劳重置点', adventureGroupBonusPercent:'冒险团加成%',
|
||||
rentalMaxReinforce:'租赁最大强化', rentalMaxForge:'租赁最大锻造', soloTeleportCooldownSeconds:'单人传送冷却秒',
|
||||
soloTeleportCooldownIndices:'单人传送索引', auctionStandardListingLimit:'普通拍卖上架数',
|
||||
auctionPremiumListingLimit:'高级拍卖上架数', auctionStandardDurationHours:'普通拍卖时长',
|
||||
auctionPremiumDurationHours:'高级拍卖时长', auctionStandardFeePercent:'普通手续费%', auctionPremiumFeePercent:'高级手续费%',
|
||||
antonBlackFogRecoverySeconds:'安徒恩黑雾恢复秒', antonBlackFogActiveSeconds:'安徒恩黑雾持续秒',
|
||||
antonQuakeRecoverySeconds:'安徒恩震颤恢复秒', antonQuakeActiveSeconds:'安徒恩震颤持续秒',
|
||||
antonNavalOpenDelaySeconds:'安徒恩舰炮延迟秒', antonNavalRecoverySeconds:'安徒恩舰炮恢复秒',
|
||||
antonNavalActiveSeconds:'安徒恩舰炮持续秒', lukeEnergyRecovery0Seconds:'卢克1阶段恢复秒',
|
||||
lukeEnergyRecovery1Seconds:'卢克2阶段恢复秒', lukeEnergyRecovery2Seconds:'卢克3阶段恢复秒',
|
||||
lukePhaseTimeoutPercent:'卢克阶段时限%', fiendWarAreaLimitPercent:'超时空区域限制%',
|
||||
fiendWarDieImprisonPercent:'超时空死亡禁锢%', antonMemberMax:'安徒恩人数上限',
|
||||
antonStartMinimumMembers:'安徒恩最少开局人数', antonSingleStartMinimumMembers:'安徒恩单人开局人数',
|
||||
antonStartDelaySeconds:'安徒恩开始延迟', antonPhaseBreakSeconds:'安徒恩阶段间隔',
|
||||
antonEntryCostItem:'安徒恩入场物品', antonEntryCostCount:'安徒恩入场数量',
|
||||
antonWeeklyEnterCountA:'安徒恩A每周次数', antonWeeklyEnterCountB:'安徒恩B每周次数',
|
||||
antonPhaseTimeoutSeconds:'安徒恩阶段时限', lukeDailyAccountEnterLimit:'卢克账号每日次数',
|
||||
lukeSanctumTimerCount:'卢克圣所计时', lukeEntryCostItem:'卢克入场物品', lukeEntryCostCount:'卢克入场数量',
|
||||
fiendWarDailyEnterLimit:'超时空每日次数', fiendWarMemberMax:'超时空人数上限',
|
||||
fiendWarStartMinimumMembers:'超时空最少开局人数', fiendWarDisconnectWaitSeconds:'超时空掉线等待',
|
||||
fiendWarEntryCostItem:'超时空入场物品', fiendWarEntryCostCount:'超时空入场数量', fiendWarRetry:'超时空重试',
|
||||
reviveByDungeon:'按副本复活配置'
|
||||
};
|
||||
|
||||
const ST_DESC = {
|
||||
soloTeleportCooldownSeconds:'单人传送冷却时间,单位秒;服务端读取该值限制重复传送。',
|
||||
soloTeleportCooldownIndices:'允许触发单人传送的技能索引,多个用逗号分隔。',
|
||||
auctionStandardListingLimit:'普通拍卖行同时可上架数量。',
|
||||
auctionStandardDurationHours:'普通拍卖行上架时长,单位小时。',
|
||||
auctionPremiumListingLimit:'高级拍卖行同时可上架数量。',
|
||||
auctionPremiumDurationHours:'高级拍卖行上架时长,单位小时。',
|
||||
auctionStandardFeePercent:'普通拍卖成交手续费百分比。',
|
||||
auctionPremiumFeePercent:'高级拍卖成交手续费百分比。',
|
||||
raidReviveSeconds:'团本死亡后等待复活的秒数,有效范围 1-60;不是无限复活选项。',
|
||||
allDeadReturnSeconds:'队伍全员死亡后返回城镇的等待秒数。',
|
||||
hellOrbChance:'深渊宝珠掉落判定值。',
|
||||
hellDropChanceDenominator:'深渊基础掉率分母,数值越大掉率越低。',
|
||||
equipmentRollMultiplier:'史诗装备掉落 roll 次数倍率。',
|
||||
epicPieceRollMultiplier:'史诗碎片掉落 roll 次数倍率。',
|
||||
allowFreeRevive:'允许不使用复活币直接复活。',
|
||||
roomFatigueCost:'进入每个房间消耗的疲劳值。',
|
||||
dailyFatigueResetHour:'每日疲劳重置小时,0-23。',
|
||||
adventureGroupBonusPercent:'冒险团经验加成百分比。',
|
||||
rentalMaxReinforce:'租赁装备允许的最大强化等级。',
|
||||
rentalMaxForge:'租赁装备允许的最大锻造等级。',
|
||||
antonBlackFogRecoverySeconds:'安徒恩黑雾之源恢复等待秒数。',
|
||||
antonBlackFogActiveSeconds:'安徒恩黑雾之源持续时间秒数。',
|
||||
antonQuakeRecoverySeconds:'安徒恩震颤之地恢复等待秒数。',
|
||||
antonQuakeActiveSeconds:'安徒恩震颤之地持续时间秒数。',
|
||||
antonNavalOpenDelaySeconds:'安徒恩舰炮防御战开启延迟秒数。',
|
||||
antonNavalRecoverySeconds:'安徒恩舰炮防御战恢复等待秒数。',
|
||||
antonNavalActiveSeconds:'安徒恩舰炮防御战持续时间秒数。',
|
||||
lukeEnergyRecovery0Seconds:'卢克能量控制一阶段恢复秒数。',
|
||||
lukeEnergyRecovery1Seconds:'卢克能量控制二阶段恢复秒数。',
|
||||
lukeEnergyRecovery2Seconds:'卢克能量控制三阶段恢复秒数。',
|
||||
lukePhaseTimeoutPercent:'卢克各阶段时限百分比。',
|
||||
lukeEnergyRecoveryPercent:'卢克能量恢复速度百分比。',
|
||||
fiendWarSquadCount:'超时空之战小队编制数量。',
|
||||
fiendWarAreaLimitPercent:'超时空区域进入限制百分比。',
|
||||
fiendWarDieImprisonPercent:'超时空死亡禁锢触发百分比。'
|
||||
};
|
||||
|
||||
const ST_FIELDS = {
|
||||
global: [
|
||||
'soloTeleportCooldownSeconds','soloTeleportCooldownIndices',
|
||||
'auctionStandardListingLimit','auctionStandardDurationHours',
|
||||
'auctionPremiumListingLimit','auctionPremiumDurationHours',
|
||||
'auctionStandardFeePercent','auctionPremiumFeePercent'
|
||||
],
|
||||
gameplay: [
|
||||
'allDeadReturnSeconds','hellOrbChance',
|
||||
'hellDropChanceDenominator','equipmentRollMultiplier',
|
||||
'epicPieceRollMultiplier','allowFreeRevive'
|
||||
],
|
||||
growth: [
|
||||
'roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent',
|
||||
'rentalMaxReinforce','rentalMaxForge'
|
||||
],
|
||||
raid: {
|
||||
anton: [
|
||||
'antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds',
|
||||
'antonQuakeRecoverySeconds','antonQuakeActiveSeconds',
|
||||
'antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds'
|
||||
],
|
||||
luke: [
|
||||
'lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds',
|
||||
'lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent',
|
||||
'lukeEnergyRecoveryPercent'
|
||||
],
|
||||
fiend: [
|
||||
'fiendWarSquadCount','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
function allAssignedKeys() {
|
||||
return new Set([...ST_FIELDS.global, ...ST_FIELDS.gameplay, ...ST_FIELDS.growth,
|
||||
...ST_FIELDS.raid.anton, ...ST_FIELDS.raid.luke, ...ST_FIELDS.raid.fiend]);
|
||||
}
|
||||
|
||||
function renderServerTool() {
|
||||
const body = $('stBody');
|
||||
if (stTab === 'activity') { renderActivity(body); return; }
|
||||
if (stTab === 'launch') {
|
||||
body.innerHTML = `<div class="st-section"><h3>启动控制</h3><p class="st-note">配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。</p><div class="lo-toolbar"><button class="shell-btn primary" id="stStart">开始游戏</button><button class="shell-btn" id="stStop">关闭服务端</button></div><div class="st-note" id="stLaunchInfo"></div></div>`;
|
||||
$('stStart').onclick = () => doAction('start');
|
||||
$('stStop').onclick = () => doAction('stop');
|
||||
api('/api/status').then(s => {
|
||||
$('stLaunchInfo').textContent = `服务端:${s.server_running ? '运行中' : '未运行'} · 目录:${s.server_root || '未绑定'}`;
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
let sub = '';
|
||||
let keys;
|
||||
if (stTab === 'features') {
|
||||
keys = Object.keys(stFeatures).filter(k => !allAssignedKeys().has(k));
|
||||
} else if (stTab === 'raid') {
|
||||
const names = {anton:'安徒恩机制', luke:'卢克', fiend:'超时空'};
|
||||
$('stSubTabs').innerHTML = Object.keys(names).map(k =>
|
||||
`<button class="st-subtab ${stRaidTab===k?'active':''}" data-raid="${k}">${names[k]}</button>`).join('');
|
||||
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
|
||||
stRaidTab = b.dataset.raid; renderServerTool();
|
||||
});
|
||||
keys = ST_FIELDS.raid[stRaidTab] || [];
|
||||
} else {
|
||||
$('stSubTabs').innerHTML = '';
|
||||
keys = ST_FIELDS[stTab] || [];
|
||||
}
|
||||
let html = `<div class="st-section"><h3>${tabTitle(stTab)}</h3>`;
|
||||
if (stTab === 'features') {
|
||||
const info = window.__stStatusInfo || {};
|
||||
const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json'];
|
||||
$('stSubTabs').innerHTML = rawNames.map(n =>
|
||||
`<button class="st-subtab ${stFeatureTab===n?'active':''}" data-raw="${n}">${n}</button>`).join('');
|
||||
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
|
||||
stFeatureTab = b.dataset.raw; renderServerTool();
|
||||
});
|
||||
html += `<div class="st-form">
|
||||
<label>服务端目录<input value="${(info.server_root || '').replace(/"/g,'"')}" readonly></label>
|
||||
<label>Admin 端口<input value="18111" readonly></label>
|
||||
<label>数据库<input value="${(info.db || '').replace(/"/g,'"')}" readonly></label>
|
||||
<label>客户端目录<input value="${(info.client_dir || '').replace(/"/g,'"')}" readonly></label>
|
||||
</div>`;
|
||||
html += `<div class="st-note">当前配置文件:${stFeatureTab}</div>`;
|
||||
html += `<textarea id="stRaw" class="st-raw" spellcheck="false">${stRawText.replace(/</g,'<')}</textarea>`;
|
||||
html += `<div class="st-note">服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。</div>`;
|
||||
}
|
||||
html += `<div class="st-form">${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}</div>`;
|
||||
if (stTab === 'gameplay') {
|
||||
html += `<div class="st-form">
|
||||
<label>深渊史诗掉率(%)<small class="st-hint">100 = 原版,250 = 2.5 倍</small><input data-game-key="hellEpicDropRatePercent" type="number" value="${stGameplay.hell_epic_drop_rate_percent ?? 0}"></label>
|
||||
<label>史诗药剂测试倍率(%)<small class="st-hint">0 = 关闭测试倍率</small><input data-game-key="epicPotionMultiplierPercent" type="number" value="${stGameplay.epic_drop_rate_potion_test_multiplier_percent ?? 0}"></label>
|
||||
</div>`;
|
||||
html += `<div class="st-check-row">
|
||||
<label class="st-check"><input type="checkbox" data-st-key="allowFreeRevive" ${stFeatures.allowFreeRevive === true ? 'checked' : ''}>允许无复活币免费复活</label>
|
||||
<label class="st-check"><input type="checkbox" data-batch-pickup ${stBatchPickup ? 'checked' : ''}>服务端允许一键拾取</label>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<details class="st-json"><summary>高级:完整 JSON 配置</summary><textarea id="stJson" spellcheck="false"></textarea><div class="lo-toolbar"><button class="shell-btn" id="stJsonApply">应用 JSON</button><span class="muted">可直接编辑全部服务端字段</span></div></details>`;
|
||||
body.innerHTML = html;
|
||||
if (stTab === 'features') loadRawConfig(stFeatureTab);
|
||||
const jsonBox = $('stJson');
|
||||
if (jsonBox) jsonBox.value = JSON.stringify(stFeatures, null, 2);
|
||||
$('stJsonApply').onclick = () => {
|
||||
try {
|
||||
const obj = JSON.parse($('stJson').value);
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('需要 JSON 对象');
|
||||
stFeatures = obj; renderServerTool();
|
||||
$('stStatus').textContent = 'JSON 已载入,点击“保存当前页”写入';
|
||||
} catch (e) { $('stStatus').textContent = 'JSON 错误:' + e.message; }
|
||||
};
|
||||
bindFeatureInputs(body);
|
||||
}
|
||||
|
||||
async function loadRawConfig(name) {
|
||||
try {
|
||||
const r = await api('/api/servertool/raw?name=' + encodeURIComponent(name));
|
||||
stRawText = r.text || '';
|
||||
const box = $('stRaw');
|
||||
if (box) box.value = stRawText;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function bindFeatureInputs(body) {
|
||||
body.querySelectorAll('input[data-st-key]').forEach(inp => {
|
||||
inp.addEventListener('change', () => {
|
||||
const key = inp.dataset.stKey;
|
||||
let v = inp.value;
|
||||
if (inp.type === 'checkbox') v = inp.checked;
|
||||
else if (inp.dataset.type === 'number') { const n = Number(v); v = Number.isFinite(n) ? n : 0; }
|
||||
stFeatures[key] = v;
|
||||
});
|
||||
});
|
||||
body.querySelectorAll('input[data-game-key]').forEach(inp => {
|
||||
inp.addEventListener('change', () => {
|
||||
stGameplay[inp.dataset.gameKey] = Number(inp.value) || 0;
|
||||
});
|
||||
});
|
||||
body.querySelectorAll('input[data-batch-pickup]').forEach(inp => {
|
||||
inp.addEventListener('change', () => { stBatchPickup = inp.checked; });
|
||||
});
|
||||
}
|
||||
|
||||
function groupFields(obj, keys) {
|
||||
return keys.map(k => {
|
||||
const val = obj[k];
|
||||
const label = LABEL[k] || k;
|
||||
if (typeof val === 'boolean' || /Retry|Revive|Pickup/.test(k)) {
|
||||
return `<label class="st-check"><input type="checkbox" data-st-key="${k}" ${val===true?'checked':''}>${label}</label>`;
|
||||
}
|
||||
const numeric = (typeof val === 'number') || /Seconds|Percent|Count|Hour|Limit|Cost|Max|Min|Delay|Item/.test(k);
|
||||
const type = numeric ? 'number' : 'text';
|
||||
const shown = val === undefined || val === null ? '' : String(val);
|
||||
const hint = ST_DESC[k] ? `<small class="st-hint">${ST_DESC[k]}</small>` : '';
|
||||
return `<label>${label}${hint}<input data-st-key="${k}" data-type="${type}" type="${type}" value="${shown.replace(/"/g,'"')}"></label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function tabTitle(tab) {
|
||||
return ({features:'服务端参数',global:'全局游戏参数',gameplay:'游戏参数',growth:'疲劳/成长',raid:'团本配置'})[tab] || tab;
|
||||
}
|
||||
function tabKeys(tab) {
|
||||
const all = Object.keys(stFeatures);
|
||||
const match = {
|
||||
features: ['raidReviveSeconds','allDeadReturnSeconds','allowFreeRevive','hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','lukeEnergyRecoveryPercent','fiendWarSquadCount'],
|
||||
global: [],
|
||||
gameplay: ['hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','roomFatigueCost','rentalMaxReinforce','rentalMaxForge'],
|
||||
growth: ['roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent','rentalMaxReinforce','rentalMaxForge'],
|
||||
raid: ['antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds','antonQuakeRecoverySeconds','antonQuakeActiveSeconds','antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds','lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds','lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent','antonMemberMax','antonStartMinimumMembers','lukeDailyAccountEnterLimit','fiendWarDailyEnterLimit'],
|
||||
};
|
||||
if (tab === 'global') return all.filter(k => /soloTeleport|auction|allDead|revive/i.test(k));
|
||||
return (match[tab] || []).filter(k => all.includes(k));
|
||||
}
|
||||
|
||||
function renderActivity(body) {
|
||||
const keys = Object.keys(stActivity);
|
||||
body.innerHTML = `<div class="st-section"><h3>在线活动开关</h3><div class="st-form">${keys.map(k => {
|
||||
const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k;
|
||||
return `<label class="st-check"><input type="checkbox" data-act-key="${k}" ${v?'checked':''}>${label}</label>`;
|
||||
}).join('')}</div></div>`;
|
||||
body.querySelectorAll('input[data-act-key]').forEach(inp => {
|
||||
inp.addEventListener('change', () => { stActivity[inp.dataset.actKey] = inp.checked; });
|
||||
});
|
||||
}
|
||||
|
||||
$('stReload').addEventListener('click', loadServerTool);
|
||||
$('stSave').addEventListener('click', async () => {
|
||||
try {
|
||||
let r;
|
||||
if (stTab === 'activity') {
|
||||
r = await post('/api/servertool/activity', stActivity);
|
||||
} else if (stTab === 'features' && $('stRaw')) {
|
||||
r = await post('/api/servertool/raw', {name: stFeatureTab, text: $('stRaw').value});
|
||||
} else if (stTab === 'gameplay') {
|
||||
r = await post('/api/servertool/features', stFeatures);
|
||||
const g = await post('/api/servertool/gameplay', stGameplay);
|
||||
const bp = await post('/api/servertool/batch-pickup', {enabled: stBatchPickup});
|
||||
if (!g.ok) r = g;
|
||||
if (!bp.ok) r = bp;
|
||||
} else {
|
||||
r = await post('/api/servertool/features', stFeatures);
|
||||
}
|
||||
$('stStatus').textContent = r.ok ? ('已保存' + (r.warning ? '(' + r.warning + ')' : '')) : (r.error || '保存失败');
|
||||
} catch (e) { $('stStatus').textContent = '保存失败:' + e.message; }
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const s = await api('/api/status');
|
||||
$('setServerRoot').textContent = s.server_root || '未绑定';
|
||||
$('setControl').textContent = s.control_exists ? '已找到 DNF90Control.exe' : '缺失';
|
||||
$('setDb').textContent = s.db || '未找到';
|
||||
$('setClient').textContent = s.client_dir || '未找到';
|
||||
$('setRun').textContent = s.server_running ? '运行中' : '未运行';
|
||||
$('setNative').textContent = s.native_available ? '可用' : '不可用';
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
$('setRefresh').addEventListener('click', loadSettings);
|
||||
|
||||
// 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium)
|
||||
// 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar)
|
||||
['shell-topnav', 'shell-topbar'].forEach((sel) => {
|
||||
const topbar = document.querySelector('.' + sel);
|
||||
if (!topbar) return;
|
||||
let dragState = null;
|
||||
const onMove = (e) => {
|
||||
if (!dragState) return;
|
||||
const w = win();
|
||||
if (w && w.move) w.move(dragState.x + e.screenX - dragState.sx, dragState.y + e.screenY - dragState.sy);
|
||||
};
|
||||
const onUp = () => {
|
||||
dragState = null;
|
||||
document.removeEventListener('mousemove', onMove, true);
|
||||
document.removeEventListener('mouseup', onUp, true);
|
||||
};
|
||||
topbar.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('button,select,input,a')) return;
|
||||
const w = win();
|
||||
if (!w || !w.getPosition) return;
|
||||
e.preventDefault();
|
||||
const sx = e.screenX, sy = e.screenY;
|
||||
w.getPosition().then((pos) => {
|
||||
dragState = {x: Number(pos[0]) || 0, y: Number(pos[1]) || 0, sx, sy};
|
||||
document.addEventListener('mousemove', onMove, true);
|
||||
document.addEventListener('mouseup', onUp, true);
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.win-resize').forEach(el => {
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
const edge = el.dataset.edge;
|
||||
const w = win();
|
||||
if (!w) return;
|
||||
// 优先系统边缘缩放;右侧/底部再用 JS 尺寸兜底
|
||||
if (['left','top','topleft','topright','bottomleft'].includes(edge) && w.start_resize) w.start_resize(edge);
|
||||
if (['right','bottom','bottomright'].includes(edge) && w.resize_window) {
|
||||
const sx = e.screenX, sy = e.screenY;
|
||||
const sw = window.innerWidth, sh = window.innerHeight;
|
||||
let rz = {edge, sx, sy, sw, sh};
|
||||
const move = (ev) => {
|
||||
let nw = sw, nh = sh;
|
||||
if (edge.includes('right') || edge === 'right') nw = sw + ev.screenX - sx;
|
||||
if (edge.includes('bottom') || edge === 'bottom') nh = sh + ev.screenY - sy;
|
||||
w.resize_window(nw, nh);
|
||||
};
|
||||
const up = () => {
|
||||
rz = null;
|
||||
document.removeEventListener('mousemove', move, true);
|
||||
document.removeEventListener('mouseup', up, true);
|
||||
};
|
||||
document.addEventListener('mousemove', move, true);
|
||||
document.addEventListener('mouseup', up, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 兼容旧 GM 页面里的返回
|
||||
window.shellGo = shellGo;
|
||||
loadStart();
|
||||
setInterval(loadStart, 4000);
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,838 @@
|
||||
/* DNF90 单机模拟器 启动器 · 精美深色主题 */
|
||||
:root {
|
||||
--bg: #0b0b14;
|
||||
--panel: rgba(20, 20, 36, 0.82);
|
||||
--panel-solid: #141424;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.16);
|
||||
--text: #f2f3f8;
|
||||
--text-dim: #b8bcd0;
|
||||
--text-muted: #6d7188;
|
||||
--primary: #7c5cff;
|
||||
--primary-2: #b45cff;
|
||||
--gold: #ffc44d;
|
||||
--danger: #ff5d6c;
|
||||
--green: #35d07f;
|
||||
--radius: 18px;
|
||||
--font: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============ 无边框窗口标题栏(透明悬浮,与页面背景融为一体) ============ */
|
||||
#titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 14px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
.tb-title { flex: 1; }
|
||||
.tb-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.tb-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.tb-min:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: var(--text);
|
||||
}
|
||||
.tb-close:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 40px);
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 20% -10%, rgba(124, 92, 255, 0.22), transparent 55%),
|
||||
radial-gradient(1000px 650px at 100% 0%, rgba(56, 217, 240, 0.12), transparent 50%),
|
||||
radial-gradient(900px 900px at 50% 120%, rgba(180, 92, 255, 0.18), transparent 55%);
|
||||
}
|
||||
|
||||
/* 背景光斑 */
|
||||
.bg-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
animation: float 12s ease-in-out infinite;
|
||||
}
|
||||
.bg-1 { width: 420px; height: 420px; background: rgba(124,92,255,0.35); top: -120px; left: -80px; }
|
||||
.bg-2 { width: 360px; height: 360px; background: rgba(255,196,77,0.22); bottom: -100px; right: -60px; animation-delay: 3s; }
|
||||
.bg-3 { width: 300px; height: 300px; background: rgba(56,217,240,0.25); bottom: 20%; left: -100px; animation-delay: 6s; }
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(20px, -20px) scale(1.08); }
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 760px;
|
||||
max-width: 96vw;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 30px 36px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: rise 0.5s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from { transform: translateY(24px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.logo-ring {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 28px rgba(124, 92, 255, 0.45);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-inner {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.title-box { flex: 1; }
|
||||
.title-box h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(90deg, #fff, #cfc3ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.subtitle { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; letter-spacing: 0.5px; }
|
||||
|
||||
.status-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.account-setting-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 20px;
|
||||
background: rgba(124, 92, 255, 0.14);
|
||||
border: 1px solid rgba(124, 92, 255, 0.35);
|
||||
font-size: 12.5px;
|
||||
color: #cfc3ff;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.account-setting-btn:hover {
|
||||
background: rgba(124, 92, 255, 0.28);
|
||||
border-color: rgba(124, 92, 255, 0.6);
|
||||
color: #fff;
|
||||
}
|
||||
.loadout-btn {
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
color: #bae6fd;
|
||||
}
|
||||
.loadout-btn:hover {
|
||||
background: rgba(56, 189, 248, 0.28);
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
color: #fff;
|
||||
}
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-badge .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.status-badge.on .dot { background: var(--green); box-shadow: 0 0 10px var(--green); }
|
||||
.status-badge.off .dot { background: var(--danger); box-shadow: 0 0 10px var(--danger); }
|
||||
|
||||
/* 按钮区 */
|
||||
.btn-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.18s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.action-btn:active { transform: translateY(0); }
|
||||
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.action-btn .btn-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 11px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.action-btn .btn-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.btn-label { font-size: 16px; font-weight: 700; display: block; white-space: nowrap; }
|
||||
.btn-desc { font-size: 12px; color: var(--text-muted); display: block; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.action-btn.primary .btn-icon {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
box-shadow: 0 4px 16px rgba(124, 92, 255, 0.4);
|
||||
}
|
||||
.action-btn.primary { border-color: rgba(124, 92, 255, 0.4); }
|
||||
.action-btn.gold .btn-icon {
|
||||
background: linear-gradient(135deg, #ffb84d, #ff8a4d);
|
||||
box-shadow: 0 4px 16px rgba(255, 196, 77, 0.35);
|
||||
}
|
||||
.action-btn.gold { border-color: rgba(255, 196, 77, 0.4); }
|
||||
.action-btn.danger .btn-icon {
|
||||
background: linear-gradient(135deg, #ff5d6c, #ff3d5a);
|
||||
box-shadow: 0 4px 16px rgba(255, 93, 108, 0.35);
|
||||
}
|
||||
.action-btn.danger { border-color: rgba(255, 93, 108, 0.35); }
|
||||
.action-btn:not(.primary):not(.gold):not(.danger) .btn-icon {
|
||||
background: linear-gradient(135deg, #38d9f0, #4d9fff);
|
||||
box-shadow: 0 4px 16px rgba(56, 217, 240, 0.3);
|
||||
}
|
||||
|
||||
/* 日志区 */
|
||||
.log-panel {
|
||||
margin-top: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-clear:hover { color: var(--text); }
|
||||
.log-body {
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font-family: "Cascadia Code", "Consolas", "Microsoft YaHei", monospace;
|
||||
}
|
||||
/* 日志区自定义滚动条 */
|
||||
.log-body::-webkit-scrollbar { width: 8px; }
|
||||
.log-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.log-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.log-body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.26); }
|
||||
.log-line { color: var(--text-dim); word-break: break-all; }
|
||||
.log-line.muted { color: var(--text-muted); }
|
||||
.log-line.error { color: var(--danger); }
|
||||
.log-line.ok { color: var(--green); }
|
||||
|
||||
/* 底部 */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.footer .sep { opacity: 0.5; }
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
.modal-mask.hidden { display: none; }
|
||||
.modal {
|
||||
width: 400px;
|
||||
max-width: 92vw;
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
animation: popIn 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
.modal-body {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
.sponsor-tip {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.8;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.qrcode-box {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.qrcode-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.qrcode-placeholder {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 20px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes popIn { from { transform: scale(0.94); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 8px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.btn-grid { grid-template-columns: 1fr; }
|
||||
.header { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
GM 工具视图样式(内置管理工具)
|
||||
============================================================ */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.gm-app {
|
||||
display: flex;
|
||||
height: calc(100vh - 40px);
|
||||
margin-top: 40px;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 15% -10%, rgba(124, 92, 255, 0.16), transparent 55%),
|
||||
radial-gradient(1000px 600px at 100% 0%, rgba(56, 217, 240, 0.10), transparent 50%),
|
||||
radial-gradient(900px 900px at 50% 120%, rgba(180, 92, 255, 0.12), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.gm-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, rgba(22, 22, 42, 0.92), rgba(13, 13, 26, 0.96));
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gm-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 20px;
|
||||
}
|
||||
.gm-logo-mark {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 18px; color: #fff;
|
||||
box-shadow: 0 0 22px rgba(124, 92, 255, 0.35);
|
||||
}
|
||||
.gm-logo-title { font-size: 17px; font-weight: 700; letter-spacing: 0.5px; }
|
||||
.gm-logo-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.gm-nav-group {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
padding: 16px 12px 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gm-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
.gm-nav-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
|
||||
.gm-nav-item.active {
|
||||
background: linear-gradient(135deg, rgba(124, 92, 255, 0.22), rgba(180, 92, 255, 0.12));
|
||||
color: #fff;
|
||||
border-color: rgba(124, 92, 255, 0.4);
|
||||
box-shadow: 0 0 18px rgba(124, 92, 255, 0.15);
|
||||
}
|
||||
.gm-nav-item .ico { width: 20px; text-align: center; font-size: 16px; }
|
||||
|
||||
.gm-sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.conn-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.conn-dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.conn-dot.off { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
|
||||
|
||||
.gm-back {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
color: var(--text-dim);
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.16s;
|
||||
}
|
||||
.gm-back:hover { background: rgba(255, 255, 255, 0.1); color: var(--text); }
|
||||
|
||||
/* 主内容 */
|
||||
.gm-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gm-topbar {
|
||||
height: 58px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(16, 16, 28, 0.6);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.gm-topbar h1 { font-size: 17px; font-weight: 700; }
|
||||
.gm-crumb { color: var(--text-muted); font-size: 12px; }
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
.gm-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
/* GM 通用组件 */
|
||||
.gm-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.gm-card + .gm-card { margin-top: 16px; }
|
||||
.gm-card-title {
|
||||
font-size: 15px; font-weight: 700; margin-bottom: 14px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.gm-card-title .dot { width: 4px; height: 16px; border-radius: 2px; background: linear-gradient(180deg, var(--primary), var(--primary-2)); }
|
||||
|
||||
.gm-grid { display: grid; gap: 16px; }
|
||||
.gm-grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
.gm-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.gm-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
@media (max-width: 1100px) { .gm-grid-3, .gm-grid-4 { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 720px) { .gm-grid-2, .gm-grid-3, .gm-grid-4 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 统计卡片 */
|
||||
.gm-stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gm-stat .label { font-size: 12.5px; color: var(--text-muted); }
|
||||
.gm-stat .value { font-size: 28px; font-weight: 800; margin-top: 6px; letter-spacing: -0.5px; }
|
||||
.gm-stat .unit { font-size: 13px; color: var(--text-muted); margin-left: 3px; font-weight: 500; }
|
||||
.gm-stat .ico-bg { position: absolute; right: -10px; bottom: -14px; font-size: 70px; opacity: 0.08; }
|
||||
.gm-stat.accent { border-color: rgba(124, 92, 255, 0.4); }
|
||||
.gm-stat.accent .value { color: #a98aff; }
|
||||
|
||||
/* GM 按钮 */
|
||||
.gm-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #1d1d36;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
transition: all 0.16s;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.gm-btn:hover { background: #24243f; border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
|
||||
.gm-btn:active { transform: translateY(0); }
|
||||
.gm-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
|
||||
.gm-btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 18px rgba(124, 92, 255, 0.35);
|
||||
}
|
||||
.gm-btn-primary:hover { filter: brightness(1.12); background: linear-gradient(135deg, var(--primary), var(--primary-2)); }
|
||||
.gm-btn-danger { background: linear-gradient(135deg, #ff5d6c, #ff3d5a); border-color: transparent; }
|
||||
.gm-btn-danger:hover { filter: brightness(1.1); background: linear-gradient(135deg, #ff5d6c, #ff3d5a); }
|
||||
.gm-btn-ghost { background: transparent; }
|
||||
.gm-btn-sm { padding: 6px 12px; font-size: 12.5px; }
|
||||
.gm-btn-xs { padding: 4px 9px; font-size: 11.5px; border-radius: 7px; }
|
||||
|
||||
/* 物品分区 Tab(装备 / 道具) */
|
||||
.gm-item-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
}
|
||||
.gm-item-tab {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.gm-item-tab:hover { color: var(--text); }
|
||||
.gm-item-tab.active {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(124, 92, 255, 0.35);
|
||||
}
|
||||
|
||||
/* GM 输入 */
|
||||
.gm-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.gm-field label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
.gm-input, .gm-select, .gm-textarea {
|
||||
background: #10101c;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: all 0.16s;
|
||||
width: 100%;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.gm-input:focus, .gm-select:focus, .gm-textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.15);
|
||||
}
|
||||
.gm-input::placeholder, .gm-textarea::placeholder { color: var(--text-muted); }
|
||||
.gm-select option { background: #16162a; color: var(--text); }
|
||||
.gm-textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
||||
|
||||
/* GM 表格 */
|
||||
.gm-table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); }
|
||||
.gm-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.gm-table thead th {
|
||||
text-align: left; padding: 11px 14px;
|
||||
background: #16162a;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600; font-size: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
position: sticky; top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gm-table tbody td { padding: 11px 14px; border-top: 1px solid var(--border); color: var(--text-dim); }
|
||||
.gm-table tbody tr { transition: background 0.12s; }
|
||||
.gm-table tbody tr:hover { background: rgba(255, 255, 255, 0.035); }
|
||||
|
||||
/* GM 标签 */
|
||||
.gm-tag {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 3px 9px; border-radius: 20px;
|
||||
font-size: 11.5px; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gm-tag-green { background: rgba(53, 208, 127, 0.15); color: var(--green); }
|
||||
.gm-tag-red { background: rgba(255, 93, 108, 0.15); color: var(--danger); }
|
||||
.gm-tag-gray { background: rgba(255, 255, 255, 0.08); color: var(--text-muted); }
|
||||
.gm-tag-gold { background: rgba(255, 196, 77, 0.15); color: var(--gold); }
|
||||
.gm-tag-purple { background: rgba(124, 92, 255, 0.16); color: #b79bff; }
|
||||
|
||||
/* GM Toast */
|
||||
.gm-toast-wrap {
|
||||
position: fixed; top: 20px; right: 20px; z-index: 999;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.gm-toast {
|
||||
min-width: 240px; max-width: 360px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 11px;
|
||||
background: #16162a;
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
font-size: 13.5px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
animation: slideIn 0.28s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
.gm-toast.ok { border-left: 3px solid var(--green); }
|
||||
.gm-toast.err { border-left: 3px solid var(--danger); }
|
||||
.gm-toast.info { border-left: 3px solid #4d9fff; }
|
||||
@keyframes slideIn { from { transform: translateX(30px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* GM 加载 / 空态 */
|
||||
.gm-loading {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 60px 0; gap: 16px; color: var(--text-muted);
|
||||
}
|
||||
.gm-spinner {
|
||||
width: 34px; height: 34px;
|
||||
border: 3px solid rgba(255,255,255,0.12);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.gm-empty {
|
||||
text-align: center; padding: 50px 20px; color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* GM 其他 */
|
||||
.gm-search-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.gm-search-row .gm-input { flex: 1; min-width: 160px; }
|
||||
.gm-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.gm-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||||
.gm-num-gold { color: var(--gold); font-weight: 700; }
|
||||
.gm-num-cyan { color: #38d9f0; font-weight: 700; }
|
||||
|
||||
.gm-avatar {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gm-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.gm-form-row .full { grid-column: 1 / -1; }
|
||||
@media (max-width: 620px) { .gm-form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 危险区 */
|
||||
.gm-danger-zone {
|
||||
border: 1px solid rgba(255, 93, 108, 0.35);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 93, 108, 0.05);
|
||||
}
|
||||
|
||||
/* ===== 账号设置面板 ===== */
|
||||
.account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.account-row.default {
|
||||
border-color: rgba(124, 92, 255, 0.55);
|
||||
background: rgba(124, 92, 255, 0.08);
|
||||
}
|
||||
.account-info { min-width: 0; }
|
||||
.account-user {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.account-pass {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.account-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.accounts-add {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.accounts-add-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace DNFLoadout;
|
||||
|
||||
internal sealed class HotkeyManager : IDisposable
|
||||
{
|
||||
private const int AltBit = 0x1;
|
||||
private const int CtrlBit = 0x2;
|
||||
private const int ShiftBit = 0x4;
|
||||
private readonly System.Windows.Forms.Timer _timer = new() { Interval = 50 };
|
||||
private readonly HashSet<int> _down = new();
|
||||
private Config _config;
|
||||
|
||||
public event Action<Scheme>? Triggered;
|
||||
|
||||
public HotkeyManager(Config config)
|
||||
{
|
||||
_config = config;
|
||||
_timer.Tick += (_, _) => Poll();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void UpdateConfig(Config config) => _config = config;
|
||||
|
||||
private static bool Down(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0;
|
||||
private static bool ModCtrl => Down((int)Keys.ControlKey);
|
||||
private static bool ModAlt => Down((int)Keys.Menu);
|
||||
private static bool ModShift => Down((int)Keys.ShiftKey);
|
||||
|
||||
private void Poll()
|
||||
{
|
||||
foreach (var scheme in _config.CurrentPage.Schemes)
|
||||
{
|
||||
if (scheme.KeyCode == 0) continue;
|
||||
bool mods = ((scheme.Modifiers & CtrlBit) == 0 || ModCtrl)
|
||||
&& ((scheme.Modifiers & AltBit) == 0 || ModAlt)
|
||||
&& ((scheme.Modifiers & ShiftBit) == 0 || ModShift);
|
||||
// Reject extra modifiers so Ctrl+F1 cannot fire plain F1.
|
||||
if ((scheme.Modifiers & CtrlBit) == 0 && ModCtrl) mods = false;
|
||||
if ((scheme.Modifiers & AltBit) == 0 && ModAlt) mods = false;
|
||||
if ((scheme.Modifiers & ShiftBit) == 0 && ModShift) mods = false;
|
||||
bool key = Down(scheme.KeyCode);
|
||||
int id = scheme.KeyCode | (scheme.Modifiers << 16);
|
||||
bool was = _down.Contains(id);
|
||||
if (key && mods && !was)
|
||||
{
|
||||
_down.Add(id);
|
||||
Triggered?.Invoke(scheme);
|
||||
}
|
||||
else if (!key || !mods)
|
||||
{
|
||||
_down.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static (int Modifiers, int KeyCode) Capture(Keys keyData)
|
||||
{
|
||||
var modifiers = 0;
|
||||
if ((keyData & Keys.Control) != 0) modifiers |= CtrlBit;
|
||||
if ((keyData & Keys.Alt) != 0) modifiers |= AltBit;
|
||||
if ((keyData & Keys.Shift) != 0) modifiers |= ShiftBit;
|
||||
var keyCode = (int)(keyData & Keys.KeyCode);
|
||||
if (keyCode is (int)Keys.ControlKey or (int)Keys.Menu or (int)Keys.ShiftKey
|
||||
or (int)Keys.LControlKey or (int)Keys.RControlKey
|
||||
or (int)Keys.LMenu or (int)Keys.RMenu
|
||||
or (int)Keys.LShiftKey or (int)Keys.RShiftKey)
|
||||
return (modifiers, 0);
|
||||
return (modifiers, keyCode);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Dispose();
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern short GetAsyncKeyState(int vKey);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>DNFLoadout</AssemblyName>
|
||||
<RootNamespace>DNFLoadout</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,389 @@
|
||||
namespace DNFLoadout;
|
||||
|
||||
internal sealed class TextPrompt : Form
|
||||
{
|
||||
private readonly TextBox _text = new() { Dock = DockStyle.Fill };
|
||||
public string Value => _text.Text.Trim();
|
||||
|
||||
public TextPrompt(string title, string label, string initial = "")
|
||||
{
|
||||
Text = title;
|
||||
Width = 360;
|
||||
Height = 150;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(10), RowCount = 3 };
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 36));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
|
||||
panel.Controls.Add(new Label { Text = label + "(按回车确认)", Dock = DockStyle.Fill }, 0, 0);
|
||||
_text.Text = initial;
|
||||
panel.Controls.Add(_text, 0, 1);
|
||||
var ok = new Button { Text = "确定", DialogResult = DialogResult.OK, Dock = DockStyle.Right, Width = 90 };
|
||||
var cancel = new Button { Text = "取消", DialogResult = DialogResult.Cancel, Dock = DockStyle.Right, Width = 90 };
|
||||
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft };
|
||||
buttons.Controls.Add(ok);
|
||||
buttons.Controls.Add(cancel);
|
||||
panel.Controls.Add(buttons, 0, 2);
|
||||
Controls.Add(panel);
|
||||
AcceptButton = ok;
|
||||
CancelButton = cancel;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HotkeyDialog : Form
|
||||
{
|
||||
private readonly TextBox _display = new() { ReadOnly = true, Dock = DockStyle.Top, Height = 28 };
|
||||
private int _modifiers;
|
||||
private int _keyCode;
|
||||
|
||||
public int Modifiers => _modifiers;
|
||||
public int KeyCode => _keyCode;
|
||||
|
||||
public HotkeyDialog(int modifiers, int keyCode)
|
||||
{
|
||||
Text = "设置快捷键";
|
||||
Width = 360;
|
||||
Height = 190;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
_modifiers = modifiers;
|
||||
_keyCode = keyCode;
|
||||
_display.Text = Scheme.HotkeyTextOf(_modifiers, _keyCode);
|
||||
_display.KeyDown += (_, e) =>
|
||||
{
|
||||
var (mods, code) = HotkeyManager.Capture(e.KeyData);
|
||||
if (code == 0) return;
|
||||
_modifiers = mods;
|
||||
_keyCode = code;
|
||||
_display.Text = Scheme.HotkeyTextOf(_modifiers, _keyCode);
|
||||
e.SuppressKeyPress = true;
|
||||
};
|
||||
var hint = new Label
|
||||
{
|
||||
Text = "点击下面的输入框,然后按下想要的组合键。\n支持 F1~F12、字母、数字,可组合 Ctrl/Alt/Shift。",
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(8),
|
||||
};
|
||||
var ok = new Button { Text = "确定", DialogResult = DialogResult.OK, Dock = DockStyle.Right, Width = 90 };
|
||||
var cancel = new Button { Text = "取消", DialogResult = DialogResult.Cancel, Dock = DockStyle.Right, Width = 90 };
|
||||
var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 40, FlowDirection = FlowDirection.RightToLeft };
|
||||
buttons.Controls.Add(ok);
|
||||
buttons.Controls.Add(cancel);
|
||||
Controls.Add(hint);
|
||||
Controls.Add(_display);
|
||||
Controls.Add(buttons);
|
||||
AcceptButton = ok;
|
||||
CancelButton = cancel;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
private readonly TextBox _server = new() { Width = 260 };
|
||||
private readonly TextBox _token = new() { Width = 260, UseSystemPasswordChar = true };
|
||||
private readonly ListBox _schemes = new() { Dock = DockStyle.Fill, IntegralHeight = false };
|
||||
private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 64, TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(6) };
|
||||
private readonly Button _add = new() { Text = "新增方案", Width = 100 };
|
||||
private readonly Button _hotkey = new() { Text = "设置快捷键", Width = 100 };
|
||||
private readonly Button _saveCurrent = new() { Text = "保存当前配装", Width = 120 };
|
||||
private readonly Button _apply = new() { Text = "应用方案", Width = 100 };
|
||||
private readonly Button _delete = new() { Text = "删除方案", Width = 100 };
|
||||
private readonly Button _saveConfig = new() { Text = "保存配置", Width = 100 };
|
||||
private readonly Button _addPage = new() { Text = "新增换装页", Width = 100 };
|
||||
private readonly Button _renamePage = new() { Text = "重命名换装页", Width = 100 };
|
||||
private readonly Button _deletePage = new() { Text = "删除换装页", Width = 100 };
|
||||
private readonly TabControl _pageTabs = new()
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 34,
|
||||
Appearance = TabAppearance.Buttons,
|
||||
SizeMode = TabSizeMode.Fixed,
|
||||
ItemSize = new Size(120, 28),
|
||||
Multiline = false,
|
||||
};
|
||||
private bool _syncingPageTabs;
|
||||
private readonly Config _config;
|
||||
private readonly HotkeyManager _hotkeys;
|
||||
private readonly Dictionary<string, DateTime> _cooldownUntil = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "DNF 一键换装";
|
||||
Width = 720;
|
||||
Height = 520;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(680, 460);
|
||||
_config = ConfigStore.Load();
|
||||
_server.Text = _config.ServerUrl;
|
||||
_token.Text = _config.AdminToken;
|
||||
|
||||
var top = new TableLayoutPanel { Dock = DockStyle.Top, Height = 46, ColumnCount = 4, Padding = new Padding(8) };
|
||||
for (var i = 0; i < 4; i++) top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
top.Controls.Add(new Label { Text = "服务端", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
|
||||
top.Controls.Add(_server, 1, 0);
|
||||
top.Controls.Add(new Label { Text = "Admin Token", AutoSize = true, Anchor = AnchorStyles.Left }, 2, 0);
|
||||
top.Controls.Add(_token, 3, 0);
|
||||
|
||||
var right = new FlowLayoutPanel { Dock = DockStyle.Right, Width = 130, FlowDirection = FlowDirection.TopDown, Padding = new Padding(6) };
|
||||
foreach (var button in new[] { _addPage, _renamePage, _deletePage, _add, _hotkey, _saveCurrent, _apply, _delete, _saveConfig })
|
||||
{
|
||||
button.Width = 112;
|
||||
right.Controls.Add(button);
|
||||
}
|
||||
|
||||
Controls.Add(_status);
|
||||
Controls.Add(_schemes);
|
||||
Controls.Add(_pageTabs);
|
||||
Controls.Add(right);
|
||||
Controls.Add(top);
|
||||
|
||||
_hotkeys = new HotkeyManager(_config);
|
||||
_hotkeys.Triggered += scheme => BeginInvoke(async () => await ApplySchemeAsync(scheme));
|
||||
|
||||
_pageTabs.SelectedIndexChanged += (_, _) => OnPageChanged();
|
||||
_addPage.Click += (_, _) => AddPage();
|
||||
_renamePage.Click += (_, _) => RenamePage();
|
||||
_deletePage.Click += (_, _) => DeletePage();
|
||||
_add.Click += (_, _) => AddScheme();
|
||||
_hotkey.Click += (_, _) => SetHotkey();
|
||||
_saveCurrent.Click += async (_, _) => await SaveCurrentAsync();
|
||||
_apply.Click += async (_, _) => await ApplySelectedAsync();
|
||||
_delete.Click += (_, _) => DeleteScheme();
|
||||
_saveConfig.Click += (_, _) => SaveConfig(true);
|
||||
_schemes.DoubleClick += async (_, _) => await ApplySelectedAsync();
|
||||
FormClosing += (_, _) => SaveConfig(false);
|
||||
RebuildPageTabs();
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
private Scheme? Selected => _schemes.SelectedIndex >= 0 && _schemes.SelectedIndex < _config.CurrentPage.Schemes.Count
|
||||
? _config.CurrentPage.Schemes[_schemes.SelectedIndex] : null;
|
||||
|
||||
private void RebuildPageTabs()
|
||||
{
|
||||
_syncingPageTabs = true;
|
||||
_pageTabs.TabPages.Clear();
|
||||
foreach (var page in _config.Pages)
|
||||
{
|
||||
_pageTabs.TabPages.Add(new TabPage(page.Name));
|
||||
}
|
||||
if (_pageTabs.TabPages.Count > 0)
|
||||
_pageTabs.SelectedIndex = Math.Clamp(_config.ActivePage, 0, _pageTabs.TabPages.Count - 1);
|
||||
_syncingPageTabs = false;
|
||||
}
|
||||
|
||||
private void OnPageChanged()
|
||||
{
|
||||
if (_syncingPageTabs || _pageTabs.SelectedIndex < 0) return;
|
||||
_config.ActivePage = _pageTabs.SelectedIndex;
|
||||
_hotkeys.UpdateConfig(_config);
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
}
|
||||
|
||||
private void AddPage()
|
||||
{
|
||||
using var prompt = new TextPrompt("新增换装页", "换装页名称", $"换装页{_config.Pages.Count + 1}");
|
||||
if (prompt.ShowDialog(this) != DialogResult.OK || prompt.Value.Length == 0) return;
|
||||
_config.Pages.Add(new LoadoutPage { Name = prompt.Value });
|
||||
_config.ActivePage = _config.Pages.Count - 1;
|
||||
RebuildPageTabs();
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
}
|
||||
|
||||
private void RenamePage()
|
||||
{
|
||||
var page = _config.CurrentPage;
|
||||
using var prompt = new TextPrompt("重命名换装页", "换装页名称", page.Name);
|
||||
if (prompt.ShowDialog(this) != DialogResult.OK || prompt.Value.Length == 0) return;
|
||||
page.Name = prompt.Value;
|
||||
RebuildPageTabs();
|
||||
SaveConfig(false);
|
||||
}
|
||||
|
||||
private void DeletePage()
|
||||
{
|
||||
if (_config.Pages.Count <= 1)
|
||||
{
|
||||
_status.Text = "至少保留一个换装页。";
|
||||
return;
|
||||
}
|
||||
var page = _config.CurrentPage;
|
||||
if (MessageBox.Show(this, $"删除换装页“{page.Name}”及其中的全部方案?", "确认",
|
||||
MessageBoxButtons.YesNo) != DialogResult.Yes) return;
|
||||
_config.Pages.Remove(page);
|
||||
_config.ActivePage = Math.Clamp(_config.ActivePage, 0, _config.Pages.Count - 1);
|
||||
RebuildPageTabs();
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
}
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
var selected = _schemes.SelectedIndex;
|
||||
_schemes.Items.Clear();
|
||||
foreach (var scheme in _config.CurrentPage.Schemes) _schemes.Items.Add(scheme.Display);
|
||||
if (_schemes.Items.Count > 0) _schemes.SelectedIndex = Math.Clamp(selected, 0, _schemes.Items.Count - 1);
|
||||
}
|
||||
|
||||
private void AddScheme()
|
||||
{
|
||||
using var namePrompt = new TextPrompt("新增方案", "方案名称", $"方案{_config.CurrentPage.Schemes.Count + 1}");
|
||||
if (namePrompt.ShowDialog(this) != DialogResult.OK || namePrompt.Value.Length == 0) return;
|
||||
using var characterPrompt = new TextPrompt("绑定角色", "角色游戏内名称", "");
|
||||
if (characterPrompt.ShowDialog(this) != DialogResult.OK || characterPrompt.Value.Length == 0) return;
|
||||
var scheme = new Scheme
|
||||
{
|
||||
Name = namePrompt.Value,
|
||||
CharacterName = characterPrompt.Value,
|
||||
};
|
||||
_config.CurrentPage.Schemes.Add(scheme);
|
||||
RefreshList();
|
||||
_schemes.SelectedIndex = _config.CurrentPage.Schemes.Count - 1;
|
||||
SetHotkey();
|
||||
}
|
||||
|
||||
private void SetHotkey()
|
||||
{
|
||||
var scheme = Selected;
|
||||
if (scheme == null) return;
|
||||
using var dialog = new HotkeyDialog(scheme.Modifiers, scheme.KeyCode);
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK || dialog.KeyCode == 0) return;
|
||||
scheme.Modifiers = dialog.Modifiers;
|
||||
scheme.KeyCode = dialog.KeyCode;
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
}
|
||||
|
||||
private async Task SaveCurrentAsync()
|
||||
{
|
||||
var scheme = Selected;
|
||||
if (scheme == null) { MessageBox.Show(this, "先选择一个方案"); return; }
|
||||
if (!ValidateConnection()) return;
|
||||
if (!EnsureCharacter(scheme)) return;
|
||||
try
|
||||
{
|
||||
SaveConfig(false);
|
||||
var client = new ServerClient(_config);
|
||||
var slots = await client.GetEquipmentAsync(scheme.CharacterName);
|
||||
scheme.Slots = slots;
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
_status.Text = $"已保存方案“{scheme.Name}”:角色“{scheme.CharacterName}”,{slots.Count} 个部位。";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_status.Text = "保存失败:" + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySelectedAsync()
|
||||
{
|
||||
var scheme = Selected;
|
||||
if (scheme == null) { MessageBox.Show(this, "先选择一个方案"); return; }
|
||||
await ApplySchemeAsync(scheme);
|
||||
}
|
||||
|
||||
private async Task ApplySchemeAsync(Scheme scheme)
|
||||
{
|
||||
if (!ValidateConnection()) return;
|
||||
if (!EnsureCharacter(scheme)) return;
|
||||
if (!CheckCooldown(scheme)) return;
|
||||
if (scheme.Slots.Count == 0)
|
||||
{
|
||||
_status.Text = $"方案“{scheme.Name}”还没有保存配装。";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
SaveConfig(false);
|
||||
_status.Text = $"正在应用“{scheme.Name}”到角色“{scheme.CharacterName}”...";
|
||||
var client = new ServerClient(_config);
|
||||
var result = await client.ApplyAsync(scheme.CharacterName, scheme.Slots);
|
||||
var applied = result.Applied ?? new List<SlotItem>();
|
||||
var skippedList = result.Skipped ?? new List<ServerClient.SkipData>();
|
||||
var skipped = skippedList.Count == 0 ? "无跳过"
|
||||
: "跳过:" + string.Join("; ", skippedList.Select(s => $"槽{s.Slot}={s.ItemId} {s.Reason}"));
|
||||
if (result.CooldownSeconds > 0)
|
||||
{
|
||||
_cooldownUntil[scheme.CharacterName] = DateTime.UtcNow.AddSeconds(result.CooldownSeconds);
|
||||
_status.Text = $"应用“{scheme.Name}”完成:成功 {applied.Count} 件,{skipped};换装冷却 {result.CooldownSeconds} 秒"
|
||||
+ (result.RefreshError is { Length: > 0 } ? " 刷新异常:" + result.RefreshError : "");
|
||||
}
|
||||
else
|
||||
{
|
||||
_status.Text = $"应用“{scheme.Name}”完成:成功 {applied.Count} 件,{skipped}"
|
||||
+ (result.RefreshError is { Length: > 0 } ? " 刷新异常:" + result.RefreshError : "");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_status.Text = "应用失败:" + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CheckCooldown(Scheme scheme)
|
||||
{
|
||||
if (_cooldownUntil.TryGetValue(scheme.CharacterName, out var until))
|
||||
{
|
||||
var remaining = (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds);
|
||||
if (remaining > 0)
|
||||
{
|
||||
_status.Text = $"角色“{scheme.CharacterName}”换装冷却中,还需 {remaining} 秒。";
|
||||
return false;
|
||||
}
|
||||
_cooldownUntil.Remove(scheme.CharacterName);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DeleteScheme()
|
||||
{
|
||||
var scheme = Selected;
|
||||
if (scheme == null) return;
|
||||
if (MessageBox.Show(this, $"删除方案“{scheme.Name}”?", "确认", MessageBoxButtons.YesNo) != DialogResult.Yes) return;
|
||||
_config.CurrentPage.Schemes.Remove(scheme);
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
}
|
||||
|
||||
private bool EnsureCharacter(Scheme scheme)
|
||||
{
|
||||
if (scheme.CharacterName.Trim().Length > 0) return true;
|
||||
using var prompt = new TextPrompt("绑定角色", "角色游戏内名称", "");
|
||||
if (prompt.ShowDialog(this) != DialogResult.OK || prompt.Value.Length == 0) return false;
|
||||
scheme.CharacterName = prompt.Value;
|
||||
RefreshList();
|
||||
SaveConfig(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ValidateConnection()
|
||||
{
|
||||
_config.ServerUrl = _server.Text.Trim();
|
||||
_config.AdminToken = _token.Text.Trim();
|
||||
if (_config.ServerUrl.Length == 0 || _config.AdminToken.Length == 0)
|
||||
{
|
||||
_status.Text = "请填写服务端地址和 Admin Token。";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SaveConfig(bool show)
|
||||
{
|
||||
_config.ServerUrl = _server.Text.Trim();
|
||||
_config.AdminToken = _token.Text.Trim();
|
||||
if (!_syncingPageTabs && _pageTabs.SelectedIndex >= 0)
|
||||
_config.ActivePage = _pageTabs.SelectedIndex;
|
||||
ConfigStore.Save(_config);
|
||||
_hotkeys.UpdateConfig(_config);
|
||||
if (show) _status.Text = "配置已保存。";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DNFLoadout;
|
||||
|
||||
internal sealed class Config
|
||||
{
|
||||
public string ServerUrl { get; set; } = "http://127.0.0.1:18111";
|
||||
public string AdminToken { get; set; } = "";
|
||||
public List<LoadoutPage> Pages { get; set; } = new();
|
||||
public int ActivePage { get; set; }
|
||||
|
||||
// Legacy pre-page format: keep the old JSON field readable, but never write it.
|
||||
[JsonPropertyName("Schemes")]
|
||||
public List<Scheme>? LegacySchemes { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public LoadoutPage CurrentPage
|
||||
{
|
||||
get
|
||||
{
|
||||
Normalize();
|
||||
return Pages[ActivePage];
|
||||
}
|
||||
}
|
||||
|
||||
public void Normalize()
|
||||
{
|
||||
if (Pages == null) Pages = new();
|
||||
if (Pages.Count == 0 && LegacySchemes is { Count: > 0 })
|
||||
Pages.Add(new LoadoutPage { Name = "默认页", Schemes = LegacySchemes });
|
||||
LegacySchemes = null;
|
||||
if (Pages.Count == 0)
|
||||
Pages.Add(new LoadoutPage { Name = "默认页" });
|
||||
ActivePage = Math.Clamp(ActivePage, 0, Pages.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LoadoutPage
|
||||
{
|
||||
public string Name { get; set; } = "默认页";
|
||||
public List<Scheme> Schemes { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class Scheme
|
||||
{
|
||||
public string Name { get; set; } = "方案";
|
||||
public string CharacterName { get; set; } = "";
|
||||
public int Modifiers { get; set; }
|
||||
public int KeyCode { get; set; }
|
||||
public List<SlotItem> Slots { get; set; } = new();
|
||||
|
||||
[JsonIgnore]
|
||||
public string HotkeyText => HotkeyTextOf(Modifiers, KeyCode);
|
||||
|
||||
[JsonIgnore]
|
||||
public string Display => $"{Name} [{CharacterName}] [{HotkeyText}] ({Slots.Count} 件)";
|
||||
|
||||
public static string HotkeyTextOf(int modifiers, int keyCode)
|
||||
{
|
||||
if (keyCode == 0) return "未设置";
|
||||
var parts = new List<string>();
|
||||
if ((modifiers & 0x2) != 0) parts.Add("Ctrl");
|
||||
if ((modifiers & 0x1) != 0) parts.Add("Alt");
|
||||
if ((modifiers & 0x4) != 0) parts.Add("Shift");
|
||||
var key = (Keys)keyCode;
|
||||
parts.Add(key.ToString());
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SlotItem
|
||||
{
|
||||
[JsonPropertyName("slot")]
|
||||
public int Slot { get; set; }
|
||||
[JsonPropertyName("item_id")]
|
||||
public long ItemId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ConfigStore
|
||||
{
|
||||
private static string PathFile
|
||||
{
|
||||
get
|
||||
{
|
||||
var portable = Path.Combine(AppContext.BaseDirectory, "config.json");
|
||||
if (File.Exists(portable)) return portable;
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"DNFLoadout", "config.json");
|
||||
}
|
||||
}
|
||||
|
||||
public static Config Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(PathFile))
|
||||
{
|
||||
var json = File.ReadAllText(PathFile);
|
||||
var config = JsonSerializer.Deserialize<Config>(json) ?? new Config();
|
||||
config.Normalize();
|
||||
return config;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
var fresh = new Config();
|
||||
fresh.Normalize();
|
||||
return fresh;
|
||||
}
|
||||
|
||||
public static void Save(Config config)
|
||||
{
|
||||
config.Normalize();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(PathFile)!);
|
||||
File.WriteAllText(PathFile, JsonSerializer.Serialize(config, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace DNFLoadout;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly string LogPath = Path.Combine(AppContext.BaseDirectory, "startup.log");
|
||||
|
||||
private static void Log(string text)
|
||||
{
|
||||
try { File.AppendAllText(LogPath, $"{DateTime.Now:O} {text}\r\n"); } catch { }
|
||||
}
|
||||
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) => Log("UNHANDLED " + e.ExceptionObject);
|
||||
Application.ThreadException += (_, e) => Log("THREAD " + e.Exception);
|
||||
Log("start");
|
||||
try
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
Log("exit");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log("FATAL " + ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DNFLoadout;
|
||||
|
||||
internal sealed class ServerClient
|
||||
{
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||
private readonly Config _config;
|
||||
|
||||
public ServerClient(Config config) => _config = config;
|
||||
|
||||
private HttpRequestMessage Request(HttpMethod method, string path)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, _config.ServerUrl.TrimEnd('/') + path);
|
||||
if (!string.IsNullOrWhiteSpace(_config.AdminToken))
|
||||
req.Headers.TryAddWithoutValidation("X-Admin-Token", _config.AdminToken.Trim());
|
||||
return req;
|
||||
}
|
||||
|
||||
public async Task<List<SlotItem>> GetEquipmentAsync(string characterName)
|
||||
{
|
||||
using var req = Request(HttpMethod.Get,
|
||||
"/local/dnf-gm/loadout/equipment?character_id=" + Uri.EscapeDataString(characterName.Trim()));
|
||||
using var resp = await _http.SendAsync(req);
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode) throw new Exception(text);
|
||||
var envelope = JsonSerializer.Deserialize<Envelope<SnapshotData>>(text)
|
||||
?? throw new Exception("empty response");
|
||||
if (!envelope.Ok) throw new Exception(envelope.Error ?? "server returned failure");
|
||||
return envelope.Data?.Slots ?? new List<SlotItem>();
|
||||
}
|
||||
|
||||
public async Task<List<CharacterInfo>> GetCharactersAsync()
|
||||
{
|
||||
using var req = Request(HttpMethod.Get, "/local/dnf-gm/characters");
|
||||
using var resp = await _http.SendAsync(req);
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode) throw new Exception(text);
|
||||
var envelope = JsonSerializer.Deserialize<Envelope<List<CharacterInfo>>>(text)
|
||||
?? throw new Exception("empty response");
|
||||
if (!envelope.Ok) throw new Exception(envelope.Error ?? "server returned failure");
|
||||
return envelope.Data ?? new List<CharacterInfo>();
|
||||
}
|
||||
|
||||
internal sealed class CharacterInfo
|
||||
{
|
||||
[JsonPropertyName("character_id")] public string CharacterId { get; set; } = "";
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("online")] public bool Online { get; set; }
|
||||
}
|
||||
|
||||
public async Task<ApplyData> ApplyAsync(string characterName, IEnumerable<SlotItem> slots)
|
||||
{
|
||||
var body = new ApplyRequest
|
||||
{
|
||||
CharacterId = characterName.Trim(),
|
||||
Slots = slots.ToList(),
|
||||
};
|
||||
using var req = Request(HttpMethod.Post, "/local/dnf-gm/loadout/apply");
|
||||
req.Content = JsonContent.Create(body);
|
||||
using var resp = await _http.SendAsync(req);
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode) throw new Exception(text);
|
||||
var envelope = JsonSerializer.Deserialize<Envelope<ApplyData>>(text)
|
||||
?? throw new Exception("empty response");
|
||||
if (!envelope.Ok) throw new Exception(envelope.Error ?? "server returned failure");
|
||||
return envelope.Data ?? new ApplyData();
|
||||
}
|
||||
|
||||
private sealed class Envelope<T>
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok { get; set; }
|
||||
[JsonPropertyName("error")] public string? Error { get; set; }
|
||||
[JsonPropertyName("data")] public T? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SnapshotData
|
||||
{
|
||||
[JsonPropertyName("character_id")] public string CharacterId { get; set; } = "";
|
||||
[JsonPropertyName("online")] public bool Online { get; set; }
|
||||
[JsonPropertyName("slots")] public List<SlotItem> Slots { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class ApplyRequest
|
||||
{
|
||||
[JsonPropertyName("character_id")] public string CharacterId { get; set; } = "";
|
||||
[JsonPropertyName("slots")] public List<SlotItem> Slots { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class ApplyData
|
||||
{
|
||||
[JsonPropertyName("applied")] public List<SlotItem>? Applied { get; set; } = new();
|
||||
[JsonPropertyName("skipped")] public List<SkipData>? Skipped { get; set; } = new();
|
||||
[JsonPropertyName("refresh_sent")] public bool RefreshSent { get; set; }
|
||||
[JsonPropertyName("refresh_error")] public string? RefreshError { get; set; }
|
||||
[JsonPropertyName("cooldown_seconds")] public int CooldownSeconds { get; set; }
|
||||
[JsonPropertyName("cooldown_remaining")] public int CooldownRemaining { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SkipData
|
||||
{
|
||||
[JsonPropertyName("slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("item_id")] public long ItemId { get; set; }
|
||||
[JsonPropertyName("reason")] public string Reason { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="DNFLoadout.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security><requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges></security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
Vendored
+292
@@ -0,0 +1,292 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
WAILS_VERSION: v3.0.0
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Apply release version metadata
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: node scripts/set-build-version.js "${RELEASE_TAG#v}"
|
||||
|
||||
- name: Install Wails CLI
|
||||
shell: bash
|
||||
run: |
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@${WAILS_VERSION}
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Ensure NSIS is available
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Get-Command makensis -ErrorAction SilentlyContinue)) {
|
||||
choco install nsis --yes --no-progress
|
||||
"${env:ProgramFiles(x86)}\NSIS" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
}
|
||||
|
||||
if (-not (Get-Command makensis -ErrorAction SilentlyContinue) -and
|
||||
-not (Test-Path "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) {
|
||||
throw "makensis was not found"
|
||||
}
|
||||
|
||||
- name: Build Windows installer
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
wails3 task windows:package \
|
||||
ARCH=amd64 \
|
||||
VERSION="$VERSION" \
|
||||
REPOSITORY="$GITHUB_REPOSITORY"
|
||||
|
||||
- name: Stage release asset
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
$binarySource = "bin/pvfine.exe"
|
||||
$binaryTarget = "pvfine-$env:RELEASE_TAG-windows-amd64.exe"
|
||||
$installerSource = "bin/pvfine-amd64-installer.exe"
|
||||
$installerTarget = "pvfine-$env:RELEASE_TAG-windows-amd64-installer.exe"
|
||||
|
||||
if (-not (Test-Path $binarySource)) {
|
||||
throw "Expected Windows executable was not generated: $binarySource"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $installerSource)) {
|
||||
throw "Expected Windows installer was not generated: $installerSource"
|
||||
}
|
||||
|
||||
Copy-Item $binarySource $binaryTarget
|
||||
Copy-Item $installerSource $installerTarget
|
||||
|
||||
- name: Upload Windows asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-release
|
||||
path: |
|
||||
pvfine-${{ github.ref_name }}-windows-amd64.exe
|
||||
pvfine-${{ github.ref_name }}-windows-amd64-installer.exe
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS Universal
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Apply release version metadata
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: node scripts/set-build-version.js "${RELEASE_TAG#v}"
|
||||
|
||||
- name: Install Wails CLI
|
||||
shell: bash
|
||||
run: |
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@${WAILS_VERSION}
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build macOS DMG
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
wails3 task darwin:package:universal \
|
||||
VERSION="$VERSION" \
|
||||
REPOSITORY="$GITHUB_REPOSITORY"
|
||||
wails3 task darwin:create:dmg
|
||||
|
||||
mkdir -p release
|
||||
dmg="$(find bin -type f -name '*.dmg' -print -quit)"
|
||||
if [ -z "$dmg" ]; then
|
||||
echo "Expected macOS DMG was not generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$dmg" "release/pvfine-${RELEASE_TAG}-macos-universal.dmg"
|
||||
ditto -c -k --sequesterRsrc --keepParent \
|
||||
bin/pvfine.app \
|
||||
"release/pvfine-${RELEASE_TAG}-darwin-universal-arm64-amd64.zip"
|
||||
|
||||
- name: Upload macOS assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-release
|
||||
path: release/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
name: Build Linux x64
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
CGO_ENABLED: '1'
|
||||
GOARCH: amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Apply release version metadata
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: node scripts/set-build-version.js "${RELEASE_TAG#v}"
|
||||
|
||||
- name: Install Linux desktop dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
libgtk-4-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Install Wails CLI
|
||||
shell: bash
|
||||
run: |
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@${WAILS_VERSION}
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build frontend before Go tests
|
||||
shell: bash
|
||||
run: wails3 task common:build:frontend
|
||||
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
||||
- name: Build Linux AppImage
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
wails3 task linux:create:appimage \
|
||||
ARCH=amd64 \
|
||||
VERSION="$VERSION" \
|
||||
REPOSITORY="$GITHUB_REPOSITORY"
|
||||
|
||||
mkdir -p release
|
||||
appimage="$(find bin -type f -iname '*.AppImage' -print -quit)"
|
||||
if [ -z "$appimage" ]; then
|
||||
echo "Expected Linux AppImage was not generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$appimage" "release/pvfine-${RELEASE_TAG}-linux-amd64.AppImage"
|
||||
chmod +x "release/pvfine-${RELEASE_TAG}-linux-amd64.AppImage"
|
||||
|
||||
- name: Upload Linux asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-release
|
||||
path: release/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-windows
|
||||
- build-macos
|
||||
- build-linux
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: '*-release'
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify release assets
|
||||
shell: bash
|
||||
run: |
|
||||
find release-assets -type f -print
|
||||
test "$(find release-assets -type f | wc -l)" -ge 5
|
||||
|
||||
- name: Generate checksums
|
||||
shell: bash
|
||||
run: |
|
||||
(cd release-assets && sha256sum -- * > SHA256SUMS)
|
||||
|
||||
- name: Create or update GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release upload "$RELEASE_TAG" release-assets/* \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "$RELEASE_TAG" \
|
||||
--draft=false
|
||||
else
|
||||
gh release create "$RELEASE_TAG" release-assets/* \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "$RELEASE_TAG" \
|
||||
--generate-notes
|
||||
fi
|
||||
@@ -0,0 +1,23 @@
|
||||
.task
|
||||
bin
|
||||
.idea
|
||||
.zcode
|
||||
frontend/dist
|
||||
frontend/node_modules
|
||||
*.pvf
|
||||
*.pvf.bak
|
||||
*.pvf.pvfine
|
||||
internal/annotations/default.json.bak
|
||||
build/linux/appimage/build
|
||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||
# Mobile generated (per-machine; overlays hold absolute paths)
|
||||
build/ios/xcode/overlay.json
|
||||
build/ios/xcode/gen/
|
||||
build/android/overlay.json
|
||||
build/android/gen/
|
||||
|
||||
ImagePacks2
|
||||
|
||||
# Local PVF export fixtures and unpacked game data
|
||||
/export/
|
||||
/testdata/
|
||||
@@ -0,0 +1,34 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## 项目结构与模块组织
|
||||
|
||||
- `main.go` 是 Wails 入口,负责窗口配置和服务注册。
|
||||
- `internal/pvf/` 是独立可测试的 Go 解析与打包内核,包含加密、zlib 分块、字符串池和编解码逻辑;测试与实现同目录放置。
|
||||
- `services/` 提供 `ArchiveService`、`EditorService` 及共享并发状态;服务测试位于 `services/services_test.go`。
|
||||
- `frontend/src/` 是 Vue 3 + TypeScript 界面,按 `components/` 和 `stores/` 组织;`frontend/bindings/` 是 Wails 生成的绑定,不要手工修改。
|
||||
- `build/` 保存 Wails 各平台构建配置,`docs/` 保存格式说明。不要提交新的构建产物或临时归档文件。
|
||||
|
||||
## 构建、测试与开发命令
|
||||
|
||||
- `wails3 task dev`:启动 Wails 热重载开发环境。
|
||||
- `go test ./...`:运行全部 Go 单元测试。
|
||||
- `PVF_TESTFILE=/path/to/Script.pvf go test ./...`:启用真实 PVF 归档的集成、编辑和字节级回归测试;未设置时相关测试会跳过。
|
||||
- `cd frontend && npm ci`:按锁文件安装前端依赖。
|
||||
- `cd frontend && npm run build`:执行 `vue-tsc` 类型检查并生成生产前端资源。
|
||||
- `wails3 task build`:构建完整桌面应用;`go build ./...` 仅验证 Go 编译。
|
||||
|
||||
## 编码风格与命名约定
|
||||
|
||||
Go 代码使用 `gofmt`(可运行 `go fmt ./...`),包名使用小写,导出标识符使用 PascalCase。Vue 单文件组件使用 `<script setup lang="ts">`,组件采用 PascalCase,Pinia store 使用 `useXxxStore`。保持现有 2 空格前端缩进和严格 TypeScript 配置;生成的 bindings 通过 Wails 命令更新。
|
||||
|
||||
## 测试约定
|
||||
|
||||
测试文件命名为 `*_test.go`,测试函数使用 `TestXxx`。解析内核优先覆盖合成归档往返、编辑保存和编码边界;涉及真实数据时使用 `PVF_TESTFILE`,不要把个人路径硬编码。新增行为应同时补充针对性测试,并在改动范围允许时运行 `go test ./...` 和前端构建。
|
||||
|
||||
## 提交与 Pull Request
|
||||
|
||||
现有提交使用简短、动词开头的中文说明(如 `优化解析`、`按 section 名称配置格式规则`),初始化提交使用 `chore:` 前缀。提交应聚焦单一变更。PR 描述需说明用户可见影响、主要实现、验证命令及是否需要 `PVF_TESTFILE`;涉及界面时附截图或录屏,并说明兼容性影响。不要提交 `frontend/dist`、`bin`、依赖目录或 secrets。
|
||||
|
||||
## 安全与配置
|
||||
|
||||
不要读取、打印或提交 `.env`、密钥、token、凭据或密码。真实 `Script.pvf` 仅用于本地验证;分享日志和 PR 前检查路径、归档内容及构建输出中没有敏感信息。
|
||||
@@ -0,0 +1,515 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录 pvfine 的版本更新。
|
||||
|
||||
## [v1.0.0] - 2026-11-03
|
||||
|
||||
### ✨ 正式版里程碑
|
||||
|
||||
- 发布首个稳定正式版,汇集多版本 PVF 无损读写、结构化 GUI 编辑、归档对比合并、本地版本控制、MOD 导入导出及 MCP 服务等核心能力。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 全面改版官网,完善核心能力展示、使用指南与跨平台下载入口。
|
||||
- 更新商城、抽奖机与娃娃机工具图标,便于区分不同编辑工具。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 同步 macOS、Windows 与 Linux 构建配置版本号至 `1.0.0`。
|
||||
|
||||
## [v0.20.0] - 2026-11-02
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增商城、抽奖机和娃娃机图形界面,支持可视化查看和编辑相关数据。
|
||||
- MOD 导出支持合并同格式的已有 MOD,并自动递增版本号。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复商城图形界面对不同数据结构的兼容性问题。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充商城、抽奖机、娃娃机及 MOD 合并导出的测试与文档。
|
||||
- 更新 README 功能说明,并同步各平台构建配置版本号至 `0.20.0`。
|
||||
|
||||
## [v0.19.0] - 2026-10-31
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 115CN 分页归档内置解析支持,并适配对应标注、渲染与 GUI 编辑。
|
||||
- 新增 `115CN-mod` ZIP 压缩包导入导出,支持组件选择与依赖补齐,并扩展 MCP MOD 导出能力。
|
||||
- 自动更新增加下载前确认,统一启动检查、菜单与设置页的更新交互。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 115CN 商店复杂结构编辑、锻造六列布局与任务嵌套字段的兼容性问题,保留不支持字段并防止误改。
|
||||
- 修复跨平台兼容回归,并保留 macOS 主窗口与脚本窗口的透明背景。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 升级至 Wails v3 正式版,更新 Go 版本要求并统一 Wails 运行时依赖。
|
||||
- 补充 115CN 解析、GUI 编辑、MOD 导入导出与更新确认的回归测试及设计文档。
|
||||
- 补齐 README 的 115CN 支持说明,并同步各平台构建配置版本号至 `0.19.0`。
|
||||
|
||||
## [v0.18.0] - 2026-10-22
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增独立掉落编辑器,支持查看和编辑独立掉落数据。
|
||||
- 文本导入支持编码转换,便于导入不同编码的文本文件。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 TW 类型 3 token 导致归档对比失败的问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化日志面板展开性能,减少展开时的卡顿。
|
||||
|
||||
## [v0.17.0] - 2026-10-21
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增技能树可视化编辑,支持技能关联、根级数值区块(@flobit)与模式数据继承。
|
||||
- 新增物品 GUI,支持创建物品、编辑礼包内容,以及配置装备技能效果与触发器。移植自贴吧「向日葵武士」的编辑器
|
||||
- 新增装备对象粒子效果预览,并完善装备技能编辑。移植自贴吧「向日葵武士」的编辑器
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 IMG v5 DDS 文件头解析导致的图片错位。
|
||||
- 修复装备触发器编辑 DSL 的缩进问题。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 调整 GUI 工具入口,优化工具的查找与打开方式。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 更新 README 核心能力说明,并同步各平台构建配置版本号至 `0.17.0`。
|
||||
|
||||
## [v0.16.0] - 2026-10-19
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增本机 MCP 服务,支持 AI 客户端搜索、编辑、导入、导出与管理当前 PVF,并提供标注文档工具。
|
||||
- 新增 MOD 文件导入,支持 `110USextend` 格式及依赖补齐。
|
||||
- 新增掉率、技能数值、强化/增幅/锻造、NPC 商店与任务五类内置 skill。
|
||||
- @flobit 支持 86 版本强化与增幅表格的 19 列规格,并适配深渊掉率数据格式。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 重构设置页布局,集中配置快捷键、MCP 服务与内置 skill。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充 MCP、MOD 导入、内置 skill、掉率及 86 版本适配相关测试与设计文档。
|
||||
- 更新 README 的 MCP 服务与 skill 使用说明,并同步各平台构建配置版本号至 `0.16.0`。
|
||||
|
||||
## [v0.15.0] - 2026-10-16
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- @flobit 将归档差异对比升级为嵌入式对比与合并工作台,支持双栏实时编辑、段落合入、文件多选与批量合并。
|
||||
- @flobit 新增差异文件与段落导航,支持 IDEA 风格快捷键、撤销重做及快捷保存。
|
||||
- 支持切换归档文本显示编码并记忆选择,提供自动、UTF-8、GBK、Big5、CP949 与 UTF-16LE 选项。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复反向对比编辑底稿选择、归档重载后对比状态及配置栏显示异常。
|
||||
- 修复合并、局部保存与文件切换时的草稿丢失及编辑器状态联动问题,加强未保存修改保护。
|
||||
- 修复关键词筛选后勾选目录会合入隐藏文件,以及局部保存后错误移除差异文件的问题。
|
||||
- 修复索引完成后错误读取特殊标签页的问题,并防止导出覆盖已有目标。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化差异保存后的树刷新,减少闪烁抖动,保留滚动位置与光标状态。
|
||||
- 完善归档对比撤销重做体验,并支持 Linux 全局回退。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充归档合并、草稿保护、编码切换、撤销重做及导出防覆盖回归测试。
|
||||
- 更新 README 工作台能力与快捷键说明,并同步各平台构建配置版本号至 `0.15.0`。
|
||||
|
||||
## [v0.14.0] - 2026-10-15
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 60ACT 格式归档的识别、读取、编辑与保存支持。
|
||||
- 新增归档差异对比,可比较两个归档的文件变化并导出差异结果。
|
||||
- 新增通用二进制 ANI 解码与预览,并兼容 ANI 公共图片路径与角色通配符。
|
||||
- 新增保存进度反馈。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 统一管理缓存空间预算并自动清理,优化大型归档内存占用。
|
||||
- 优化 SQLite 增量索引、元数据提取、图标批量加载及索引读取性能。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 增加 Squirrel 脚本基础语法高亮,并补充归档、ANI 与索引相关测试和格式文档。
|
||||
- 同步各平台构建配置中的应用版本号至 `0.14.0`。
|
||||
|
||||
## [v0.13.0] - 2026-10-13
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 TW PVF 变体识别与无损读写支持。
|
||||
- 支持在编辑器中右键查看并编辑标注与渲染规则。
|
||||
- 基础掉率工具扩展至所有支持的 PVF 版本。
|
||||
- 内置 115US 配套资源,支持对应分页归档的保存。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复分页归档保存时的容量检查问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化 SQLite 全量索引重建,并支持增量更新,减少索引维护开销。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充 TW 变体、规则编辑、分页容量与索引更新的回归测试及设计文档。
|
||||
- 更新 README 核心能力与架构说明,并同步各平台构建配置版本号至 `0.13.0`。
|
||||
|
||||
## [v0.12.0] - 2026-10-13
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增任务 GUI 编辑器,支持编辑任务属性、完成条件、奖励、剧情对话及目标引用。
|
||||
- 新增强化/增幅与安全强化/安全增幅编辑器,支持按等级批量调整概率、失败处理和材料消耗。
|
||||
- 新增锻造配置 GUI,支持编辑锻造成功率、材料倍率与材料配置,并提供范围批量设置。
|
||||
- 新增 100CN 分页归档识别与内置解封资源,统一管理分页客户端资源;保留双字符串池及 ASCII indexhash 引用,支持离线编辑与无损写回。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 扩充强化、增幅与锻造相关的标注和渲染规则。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充任务、强化/增幅、安全强化及锻造编辑与规则回归测试。
|
||||
- 更新 README 功能说明,并同步各平台构建配置中的应用版本号至 `0.12.0`。
|
||||
|
||||
## [v0.11.0] - 2026-10-10
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增技能参数 GUI 编辑器:工具菜单「技能参数」扫描当前归档 `skill/` 及子目录下的 `.skl` 文件,支持按职业、名称与路径筛选,可视化查看 `[dungeon]` / `[pvp]` 的静态数据、等级数据与描述绑定。
|
||||
- 技能参数支持对描述数值、静态数据与表格单元格执行赋值、加固定值、乘固定值编辑,默认修改全部等级,也可仅修改当前等级;编辑只替换目标 token 的源位置,保留其余字段、注释与换行。
|
||||
- 技能描述模板支持字符串引用解析、字面与 `\n` / `\r\n` 转义换行,以及 `<int>` / `<floatN>` 混用占位符;预览按模板语义渲染,不依赖 PVF 变体编号,并适配 110US 的引用模板与参数绑定。
|
||||
- 技能草稿独立于编辑器标签:切换技能或关闭窗口保留草稿,参与单技能保存、主工具栏保存、自动保存与归档关闭保护,不占用编辑器标签上限。
|
||||
- 高级搜索新增快速搜索模式,并作为默认选项。
|
||||
- 欢迎页显示当前归档的 PVF 变体类型,状态栏新增文件类型显示。
|
||||
- 大幅扩充内置标注规则,覆盖更多脚本字段与结构。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 Linux 下 `go test ./...` 失败:将导出发布逻辑按平台拆分到独立文件。
|
||||
- 修复字符串表占位符译文溢出截断的问题,超长译文可完整换行显示。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化海量标注规则下的编辑器渲染与日志处理,降低打开与编辑大量标注文件时的卡顿。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 恢复 Linux(AppImage)发布构建流程,发布任务重新包含 Linux x64 产物。
|
||||
- 补充技能扫描、参数修改编解码往返与 110US 真实样例回归测试。
|
||||
- 修正稀有度单元测试,覆盖神话与太初的边界取值。
|
||||
- 同步各平台构建配置中的应用版本号至 `0.11.0`。
|
||||
|
||||
## [v0.10.0] - 2026-10-08
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增装备辅助职业效果与名望展示,支持神话、太初稀有度识别。
|
||||
- Lua 脚本编辑器新增语法高亮。
|
||||
- 新增大文件阈值设置;超长行文件可关闭自动换行,提升打开速度。
|
||||
- 关于页面新增应用信息与更新检查入口。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复装备预览中的字符串显示问题。
|
||||
- 删除不再适用的 `equ.rarity` 标注。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 默认隐藏资源树复选框,减少初始界面干扰。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充装备预览、大文件加载、设置、更新检查与编辑器行为的回归测试及脚本 API 文档。
|
||||
- 移除 Linux 版本的自动构建任务。
|
||||
- 同步各平台构建配置中的应用版本号至 `0.10.0`。
|
||||
|
||||
## [v0.9.1] - 2026-10-07
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 真实 110US PVF 探测测试统一通过 `PVF_TESTFILE` 显式启用;未设置时跳过,移除对本地 `testdata/110US.pvf` 的硬编码依赖,修复 Linux CI 常规测试失败。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 同步各平台构建配置中的应用版本号至 `0.9.1`。
|
||||
|
||||
## [v0.9.0] - 2026-10-07
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增统一导出预览,支持从选中文件、文件集、工作区改动和历史提交导出,按输出树选择文件,并导出为 `110USextend` MOD;可选补齐字符串引用与列表登记依赖。
|
||||
- 新增日志面板,汇集前后端日志,支持按等级筛选、展开查看与清空。
|
||||
- 扩展脚本工作区的 110US 字符串引用 API,支持解析原始译文、编辑字符串表引用及自动注册字符串条目。
|
||||
- 新增 110US 字符串表索引进度与耗时统计。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 90US 全局掉落解析与 GUI 适用范围问题。
|
||||
- 修复索引锁定失败及异常锁占用引起的性能问题。
|
||||
- 商店分类改为读取 `itemshopcategory`,保持分类名称与归档配置一致。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 文件打开分为正文加载与异步元数据补充,降低等待时间;元数据失败不影响正文编辑,延迟结果不会覆盖草稿。
|
||||
- 优化导出快照复用、依赖扫描与字符串表索引,减少重复解码和不必要的锁占用。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 新增独立 `internal/mod` 格式注册与写入内核、`ExportService` 及 MOD 格式扩展文档。
|
||||
- 补充导出、日志、索引与锁诊断测试,修复分阶段文件加载的测试 mock,新增异步元数据回归测试;前端共 133 项测试通过。
|
||||
- 同步各平台构建配置中的应用版本号至 `0.9.0`。
|
||||
|
||||
## [v0.8.0] - 2026-09-24
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 `etc/worlddrop.etc` 全局掉落 GUI 编辑器,可按等级管理道具与权重,保留文本模式切换,并在提交前校验文件内容与归档版本。
|
||||
- 新增统一快捷键系统,可在偏好设置中查看、修改和清除工作区快捷键,并处理冲突及保留组合键。
|
||||
- 装备预览新增套装效果展示;标注支持路径定位与跳转、临时关闭、隐藏时悬停提示及稀有度着色;编辑器支持按 section 折叠代码。
|
||||
- 扩展标注与渲染规则引擎,支持嵌套掉落数据的渲染和标注。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 110US 高级搜索及 ID 关联的相关问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化工具栏布局、书签操作及归档关闭时的内存释放。
|
||||
- 改进高级搜索索引与名称解析流程,降低大型归档的查询与缓存开销。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 新增项目官网静态页面及设计文档。
|
||||
- 补充全局掉落、快捷键、标注、预览和搜索相关回归测试。
|
||||
- 同步各平台构建配置中的应用版本号至 `0.8.0`。
|
||||
|
||||
## [v0.7.0] - 2026-09-22
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增「文件 GUI 模式」框架:按文件类型注册独立 GUI provider,可在同一窗格内与文本编辑器自由切换,隐藏时保留组件状态、关闭文件时才销毁;文本编辑器采用隐藏而非卸载,保留光标、滚动与撤销历史。首批注册 `.shp` 商店文件。
|
||||
- 新增商店查看与编辑器:完整保留 Tab、分类区块与商品出现顺序;支持商品增删改(含成本与职业引用)、分页新增/重命名/删除、按分页批量重设金币与兑换材料,界面明确提示价格属于物品文件的共享属性。
|
||||
- 商店编辑在独立 staging 中构造全部受影响文件,校验归档 revision、路径与定位后一次性提交 overlay,并记录单次版本撤销操作;原 PVF 不会自动写盘,过期表单会被拒绝。
|
||||
- 新增通用装备/道具选择器 `ItemPicker`:支持图标、ID/名称/路径检索、延迟搜索与分页,并隔离过期请求;归档检索在内存与 SQLite 索引中均先限定物品范围再分页。
|
||||
- 新增基础掉率编辑器:可视化编辑 `basis of rarity dicision` 各分组的掉率数值(仅 90US/90CN),保存前校验数据代次避免覆盖过期结果。
|
||||
- 新增定时缓存与异常退出恢复(可选,默认关闭):按设置间隔把当前工作区(含未保存修改)从隔离克隆另存为缓存 PVF,不改变工作区的“未保存”状态、也不写入原文件;崩溃或被强制结束后重新启动会提示恢复。
|
||||
- 新增缓存占用统计与一键清理:统计搜索索引、归档索引、高级搜索索引、NPK 图标索引与定时缓存的总占用,并可一键清理这些可重建的缓存;设置、书签与文件集等配置数据不在缓存目录内,不受影响。
|
||||
- 标注配置支持声明适用 PVF 版本(90US / 90CN / 110US),并在切换归档时按当前版本过滤规则;未声明版本时保持全量启用,兼容既有配置。
|
||||
- 未启用版本控制时,版本面板可直接查看当前内存改动清单。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复资源管理器路径通配符搜索匹配不正确的问题。
|
||||
- 修复非闭合标签在各渲染规则下的排版适配问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化大文件打开与编辑性能,降低大脚本加载、标注计算与编辑器状态同步的开销。
|
||||
- 搜索索引改为按实际内容增量刷新:按变更影响区分「仅路径」与「语义」刷新,归档编辑后只更新受影响条目,避免全量重建。
|
||||
- 优化商店 GUI 的视觉与交互细节。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 新增前端 Vitest 测试基建(`vitest.config.ts`)与编辑器加载/标注、文件 GUI、商店表单、掉率、自动保存共 6 组测试,34 条用例。
|
||||
- 新增商店编辑、掉率、文件 GUI、缓存、自动保存与搜索变更等服务层测试,并补充标注版本过滤、PVF 变更与内存改动的回归测试。
|
||||
- 新增 `docs/文件GUI模式.md`,说明 GUI provider 扩展入口、商店数据结构与编辑检索流程。
|
||||
|
||||
## [v0.6.0] - 2026-09-21
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 高级字符串搜索迁移至 SQLite 磁盘反向索引,支持大型归档的游标分页查询、查询结果缓存与按需重建。
|
||||
- 新增字符串引用扫描与索引构建进度、取消搜索和结果失效处理,编辑归档后可自动重新建立索引。
|
||||
- 抽象归档格式探测、内容规则与写入能力,统一支持标准格式、90CN 变体与 110US(Paged110)分页归档。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复 110US 字符串池写入与 `indexhash` 处理导致的编码和列表注册问题。
|
||||
- 修复 Windows 下 SQLite 只读文件 URI 将本地盘符误解析为 authority,导致高级搜索无法打开索引的问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化高级搜索索引缓存、查询结果磁盘预算和会话清理,降低大归档搜索的内存占用。
|
||||
- 优化开发构建参数,保持 SQLite 依赖的编译优化,改善索引构建性能。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 新增 SQLite 文件索引与语义索引测试,补充字符串引用、归档格式组合及编码边界回归测试。
|
||||
- 更新 PVF 变体架构、索引性能与新格式分析文档。
|
||||
|
||||
## [v0.5.0] - 2026-09-20
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 110US(Paged110)容器解析与写回支持,兼容分页归档及其相关密钥文件。
|
||||
- 新增非标准种子 PVF 变体识别 (90CN)、种子恢复与保存支持,变体归档可正常编辑并重建 HASH 段。
|
||||
- 新增 `list/*_indexhash.etc` 的解析、读写工具,以及 110US `hashindex` 的自动生成与注册界面。
|
||||
- 新增 110US 字符串表、列表布局与占位符编辑支持;新建文件时可创建字符串表条目并插入引用。
|
||||
- 编辑器支持显示并就地修改占位符译文。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 仅对分页归档显示文件预览入口,避免在不支持的归档类型中展示无效操作。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复写入中文脚本时字符串池选择不当导致的游戏内乱码。
|
||||
- 修复变体种子搜索中的 goroutine 泄漏。
|
||||
- 修复变体归档保存时固定种子与 HASH 段处理不正确的问题,确保新增文件可被索引。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 新增索引缓存,减少重复建立与查询索引的开销。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充 Paged110、90CN 变体、种子恢复、HASH/indexhash、字符串表及字符串编码的回归测试。
|
||||
- 更新 PVF 格式说明与 110US 容器分析文档。
|
||||
|
||||
## [v0.4.0] - 2026-09-13
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 ANI 动画预览:解析 `.ani` 帧序列与贴图引用,在编辑器内直接播放角色、特效等动画资源。
|
||||
- 新增装备预览:解析装备脚本字段并渲染属性面板,可查看名称、等级、职业限定、物理/魔法攻击等关键数值。
|
||||
- 新增预览宿主机制,按文件类型自动分派预览组件,为后续资源预览扩展提供统一接入点。
|
||||
- 脚本控制台新增 `.lst` 列表操作 API,支持按文件顺序遍历(`forEach`)、查询与改写列表条目。
|
||||
- 脚本新增文件集(FileSet)读写 API,可在脚本中直接读取与创建文件集。
|
||||
- 脚本新增创建、复制、删除文件 API,支持批量增删归档条目。
|
||||
- 新增路径中的目录自动创建:通过脚本或新建文件时,父目录不存在会自动补建。
|
||||
- 脚本工作区支持以独立窗口打开,便于与主编辑窗口并排使用。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 编辑器改为手动保存:编辑内容经工具栏保存按钮或 <kbd>Cmd</kbd>/<kbd>Ctrl</kbd>+<kbd>S</kbd> 写入 Overlay,关闭未保存标签前提示确认。
|
||||
- 文件编辑工具栏显示当前文件路径,支持点击复制。
|
||||
- 资源管理器右键菜单新增复制二级菜单,可复制相对路径或绝对路径。
|
||||
- 鼠标中键点击标签页可直接关闭该标签。
|
||||
- 脚本控制台支持文本选中,脚本预览 Diff 新增按文件路径筛选。
|
||||
- 代码编辑器 <kbd>Tab</kbd> 键改为插入制表符,不再触发整行自动缩进;优化悬浮提示展示。
|
||||
- 修复文件树滚动异常,并完善磁盘保存确认弹窗的加载中状态反馈。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复较大文件 Diff 退化为整文件删除的比对错误。
|
||||
- 修复标注名字索引跨越二级 section 的问题,名字索引改为只取一级 section。
|
||||
- 修复 Windows 上平台相关的测试断言。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 补充 ANI 预览、装备预览、脚本文件集、窗口服务与批量 Diff 的单元测试。
|
||||
- 更新标注配置与渲染规则配置,补充脚本工作区、渲染规则与标注设计文档。
|
||||
- 将应用版本元数据统一同步至 `0.4.0`,覆盖 macOS、Windows 与 Linux 构建配置。
|
||||
- 真实归档集成测试改为断言归档结构而非固定文件数,避免因本地游戏版本差异误报失败。
|
||||
|
||||
## [v0.3.0] - 2026-09-10
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 JavaScript 脚本工作区,使用 Goja 沙箱在进程内运行 `.pvf.js`,提供 `pvf.files/find/glob`、文本接口与结构化 Token 文档 API。
|
||||
- 脚本运行阶段写入隔离事务,成功后生成可审阅预览,确认后点击「应用选中」才写入工作区 Overlay,归档仍需手动保存。
|
||||
- 新增脚本渲染规则,可按扩展名与 glob 匹配定制编辑器及导出的展示格式,并支持在设置中热重载配置。
|
||||
- 编辑器新增 JavaScript 语法模式、脚本 API 类型声明与语言服务补全,`.pvf.js` 与 PVF 文本共用同一编辑面板。
|
||||
- 注册 `.pvf` 文件关联,支持双击或拖拽 `.pvf` 文件直接启动并打开归档。
|
||||
- 标注规则新增 `offset` 跳过 section 开头 token,以及 `tokensPerLineIndex` 按行内数值动态覆盖 `recordTokens`。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 重构设置界面,按功能分区重排配置项。
|
||||
- 优化脚本工作区、版本控制面板、批处理与高级搜索界面。
|
||||
- 优化侧边栏、工具栏与状态栏布局,精简冗余操作入口。
|
||||
- 优化资源管理器体验,支持一键收起所有目录。
|
||||
- 优化首页界面,打开文件时自动切回「归档编辑」视图。
|
||||
- 编辑器标签文件名过长时自动省略显示。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 版本控制改用规范化渲染文本计算内容快照,避免脚本 token 字符串池引用导致的差异误判。
|
||||
- 修复标注重复目标在记录宽度越界时读取 token 的行为,并在下标非法时安全跳过。
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 新增 `internal/script` 模块与 `ScriptService`、`RenderingService` 服务层接入。
|
||||
- 拆分 PVF 脚本文档树与规范化文本渲染逻辑,补充脚本、渲染规则、标注与版本控制测试。
|
||||
- 将应用版本元数据统一同步至 `0.3.0`,覆盖 macOS、Windows 与 Linux 构建配置。
|
||||
|
||||
## [v0.2.1] - 2026-09-09
|
||||
|
||||
### 🔨 重构与维护
|
||||
|
||||
- 清理 macOS、Windows 和 Linux 构建模板中的示例占位元数据。
|
||||
- 统一应用名称、发布者、产品标识、版本、描述、版权及项目主页配置。
|
||||
- 更新项目文档,补充当前版本控制、NPK、LST、书签簿和批处理能力说明。
|
||||
|
||||
## [v0.2.0] - 2026-09-09
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增 NPK 资源读取与 DXT 图像解析支持。
|
||||
- 新增资源版本控制功能,支持版本仓库、对象存储及版本面板。
|
||||
- 新增书签簿管理,支持嵌套书签簿、书签编辑与导航。
|
||||
- 新增 LST 列表、标注与跳转能力。
|
||||
- 新增资源导入、标签页操作及深浅色主题切换。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 优化资源管理器、编辑器信息栏、工具栏及文件操作界面。
|
||||
- 支持在资源管理器中标记已修改文件,并改善目录选中与加载反馈。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复浅色模式下背景颜色不正确的问题。
|
||||
- 修复资源管理器选中尚未加载目录时的状态处理问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 优化书签、标签页及版本控制相关操作体验。
|
||||
- 增加 NPK、PVF、服务层和版本控制的测试与基准测试覆盖。
|
||||
|
||||
## [v0.1.0] - 2026-09-08
|
||||
|
||||
### ✨ 新特性
|
||||
|
||||
- 新增标注系统及可配置标注索引、装备/道具联合关联与独立标注编辑器。
|
||||
- 新增文件集持久化、文件导出、最近打开文件和精确匹配能力。
|
||||
- 新增批处理功能,支持批量处理 PVF 内容。
|
||||
- 新增 Vim 编辑模式。
|
||||
- 新增资源管理器复制文件路径和选中当前文件的能力。
|
||||
|
||||
### 🎨 界面与交互
|
||||
|
||||
- 新增分屏编辑与编辑面板拖拽调整。
|
||||
- 新增资源管理器单击/双击打开方式设置。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 修复删除文件集后状态未正确更新的问题。
|
||||
- 修复标注换行计算导致的行号偏移。
|
||||
- 修复发布版本脚本在重复使用相同版本号时误报 macOS 版本字段缺失的问题。
|
||||
|
||||
### ⚡ 优化
|
||||
|
||||
- 增加未保存修改退出确认。
|
||||
- 增加保存确认与源文件自动备份。
|
||||
- 完善编辑器缩进处理。
|
||||
- 移除不再使用的 iOS 与 Android 构建模板及任务配置。
|
||||
- 补充 PVF、标注、文件集、设置、批处理及编辑器相关测试和设计文档。
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
<div align="center">
|
||||
|
||||
# ⚡️ pvfine
|
||||
|
||||
**现代化、高性能的某横版动作游戏 `Script.pvf` 跨平台桌面编辑器与解析套件**
|
||||
|
||||
[](https://github.com)
|
||||
[](#-支持的-pvf-版本)
|
||||
[](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<a href="#-支持的-pvf-版本">支持版本</a> •
|
||||
<a href="#-核心特性">核心特性</a> •
|
||||
<a href="#-快捷键操作">快捷键</a> •
|
||||
<a href="#-系统架构">系统架构</a> •
|
||||
<a href="#-go-sdk-独立调用">Go SDK</a> •
|
||||
<a href="#-开发与构建">开发与构建</a> •
|
||||
<a href="#-相关文档">相关文档</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
`pvfine` 是一款面向某横版动作游戏归档文件的现代化跨平台桌面编辑器与解析套件。当前稳定正式版为 **v1.0.0**,安装包见 [GitHub Releases](https://github.com/dof-dev/pvfine/releases/latest)。
|
||||
|
||||
采用 **Go + Wails v3 + Vue 3 + CodeMirror 6** 技术栈从零构建,将纯 Go 高性能解析内核与现代 Web 交互界面深度融合。具备流式按需解密、百万索引秒开、双向无损反编译、动画与资产预览、结构化数据 GUI 编辑及本地版本控制等特性。未修改保存保证原文件 **100% 逐字节完全一致**。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 🚀 **百万索引秒开与低内存开销**:基于流式按需解密、zlib 分块(Chunk-based)机制与持久化索引缓存,百万条目秒级载入;内置基于 SQLite 磁盘反向索引的高级字符串搜索。
|
||||
- 📝 **结构化反编译与专业级编辑**:无损反编译 5 字节 Token 流,支持层级感知缩进、渲染排版规则定制、110US 占位符就地改写、Vim 模式、分屏与主题自适应;兼容 60ACT 格式归档编辑与保存。
|
||||
- **归档文本编码切换**:支持自动识别及 UTF-8、GBK、Big5、CP949、UTF-16LE 显示编码选择,并记忆归档编码偏好。
|
||||
- **115CN 分页归档**:内置解析所需配套资源,支持对应标注、渲染与 GUI 编辑,以及 `115CN-mod` 压缩包导入导出。
|
||||
- 🏷️ **智能标注系统与 LST 跳转**:规则驱动关联装备/道具/技能 `.lst` 列表映射,内嵌交互标注 Tag,一键跨脚本跳转;原生支持 110US `indexhash` 计算与自动登记。
|
||||
- 🛠️ **标注与渲染规则编辑**:可从编辑器上下文直接查看和调整标注、渲染规则,支持按文件与 Section 匹配并即时应用。
|
||||
- 🖼️ **丰富的多媒体与资产预览**:内置 ANI 动作关键帧动画播放器与通用二进制 ANI 解码、装备高保真属性面板、纯 Go 高性能 NPK / IMG 解码器(支持 DXT1/3/5 纹理与行内缩略图预览)。
|
||||
- 🗂️ **结构化数据 GUI 可视化编辑**:为商店(`.shp`)、商城、抽奖机、娃娃机、基础掉率、全局掉落、独立掉落、任务(`.qst`)、强化/增幅、锻造、物品与礼包(`.equ` / `.stk`)及技能参数(`.skl`)提供专用图形化表单,与代码随时切换,支持原子校验与安全提交;技能树支持可视化浏览、技能关联编辑与布局调整。
|
||||
- 🗃️ **轻量本地版本控制(VCS)**:内置基于 SQLite 元数据与 SHA-256 CAS 对象存储的旁路版本库,支持工作区修改检测、快照对比与安全回退。
|
||||
- 🔍 **归档对比与合并工作台**:在编辑区标签页中比较两个归档的新增、删除与修改文件,支持双栏实时编辑、段落合入、批量合并、草稿保护及差异导出;提供差异文件与段落导航、撤销重做及快捷保存。
|
||||
- 🧩 **JavaScript 脚本工作区**:集成轻量 Goja 沙箱,支持通过 JS 脚本批量改写归档、列表及文件集,提供事务隔离与 Diff 差异预览。
|
||||
- 🛡️ **安全增量打包与原子落盘**:Dirty-chunk 差异重打包,非 ASCII 字符串智能路由进入 `sTrW` 防乱码,未修改保存 **100% 逐字节一致**,支持原子覆盖与自动备份。
|
||||
- 📦 **资产导出与 MOD 发布**:支持单文件/批量导出、全量流式解包、`110USextend` 目录包与 `115CN-mod` ZIP 的 PVF 导入导出及依赖补齐;支持合并同格式的已有 MOD 并自动递增版本号;115CN 支持组件选择。
|
||||
- **MCP 服务**:AI 可通过本机服务搜索和编辑当前 PVF、打开/关闭/保存归档、生成副本、导入文件或目录,以及导出普通文件和 MOD。
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ 快捷键操作
|
||||
|
||||
以下快捷键在归档对比与合并工作台中生效。
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
| --- | --- |
|
||||
| `Alt+Left` / `Alt+Right` | 上一个 / 下一个差异文件 |
|
||||
| `Shift+F7` / `F7` | 上一个 / 下一个差异段落 |
|
||||
| `Alt+Up` / `Alt+Down` | 上一个 / 下一个差异段落 |
|
||||
| `Ctrl+S`(macOS:`Cmd+S`) | 保存当前对比编辑 |
|
||||
| `Ctrl+Z`(macOS:`Cmd+Z`) | 撤销 |
|
||||
| `Ctrl+Y` 或 `Ctrl+Shift+Z`(macOS:`Cmd+Y` 或 `Cmd+Shift+Z`) | 重做 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
采用严格关注点分离的分层架构:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Vue 3 + Naive UI) │
|
||||
│ - ToolBar / Explorer(虚拟树懒加载)/ CodeEditor(CM6 / Vim / 分屏) │
|
||||
│ - Previews(ANI 动画 / 装备面板 / 纹理)/ FileGUI(商店/任务/掉落/技能) │
|
||||
│ - ScriptWorkbench / VersionPanel / BatchModal / AdvancedSearch ... │
|
||||
│ - CompareWorkbench / SideBySideDiff(归档对比与实时合并) │
|
||||
└───────────────────────────────────┬────────────────────────────────────┘
|
||||
│ Wails v3 IPC
|
||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||
│ Services Layer (pvfine/services) │
|
||||
│ - ArchiveService / EditorService / ExportService / PreviewService │
|
||||
│ - AnnotationService / SearchSQLite / FileGUIService / ScriptService │
|
||||
│ - VersionService / BatchService / ImageService / AutosaveService ... │
|
||||
│ - ArchiveDiffService(归档对比、草稿保存与批量合并) │
|
||||
└───────────────────────────────────┬────────────────────────────────────┘
|
||||
│ Go API
|
||||
┌───────────────────────────────────▼────────────────────────────────────┐
|
||||
│ Core Engines │
|
||||
│ - internal/pvf: 90US/100CN/110US/115US/115CN/TW 容器与 Token 字符串池 │
|
||||
│ - internal/preview: ANI 关键帧序列与图层调度模型 │
|
||||
│ - internal/script: Goja 沙箱与事务化宿主 API │
|
||||
│ - internal/npk: NPK 只读解密、IMG 解析与 DXT 纹理解码 │
|
||||
│ - internal/annotations: 规则引擎、LST 双布局映射与联合索引 │
|
||||
│ - internal/version: SQLite 元数据与 SHA-256 CAS 对象版本库 │
|
||||
│ - internal/mod: MOD 模型与 110USextend / 115CN-mod 导入导出 │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 Go SDK 独立调用
|
||||
|
||||
`internal/pvf` 是一个完全解耦、不依赖 GUI 组件的纯 Go 核心包,可直接在命令行工具或脚本中独立使用:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 打开归档(自动识别 90US / 100CN / 110US / TW)
|
||||
archive, err := pvf.Open("Script.pvf")
|
||||
if err != nil {
|
||||
log.Fatalf("打开失败: %v", err)
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
// 2. 根据路径查找文件索引并反编译
|
||||
fileIndex, found := archive.Find("equipment/character/common/amulet/100300001.equ")
|
||||
if !found {
|
||||
log.Fatal("未找到指定文件")
|
||||
}
|
||||
text, _ := archive.Text(fileIndex)
|
||||
fmt.Println("脚本内容:\n", text)
|
||||
|
||||
// 3. 修改内容并原子保存
|
||||
archive.SetText(fileIndex, text+"\n# pvfine customized\n")
|
||||
if err := archive.SaveAs("Script_edited.pvf"); err != nil {
|
||||
log.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
### 环境要求
|
||||
- **Go**: `1.26.0` 或更高版本
|
||||
- **Node.js**: `18.0.0` 或更高版本及 `npm`
|
||||
- **Wails v3 CLI**: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0`
|
||||
|
||||
macOS 构建默认启用 `private_mac_apis`,用于保留主窗口和脚本窗口的 Liquid Glass 透明背景。Wails Go 依赖、CLI 与前端 runtime 应保持同一版本。
|
||||
|
||||
### 开发与测试
|
||||
```bash
|
||||
# 启动热重载开发环境
|
||||
wails3 task dev
|
||||
|
||||
# 运行 Go 内核与服务层测试
|
||||
go test ./...
|
||||
|
||||
# 运行前端测试
|
||||
cd frontend && npm test
|
||||
|
||||
# 真实 PVF 往返重打包回归测试(可选)
|
||||
PVF_TESTFILE=/path/to/Script.pvf go test -v ./...
|
||||
```
|
||||
|
||||
### 生产打包
|
||||
```bash
|
||||
wails3 task build # 构建桌面应用程序
|
||||
wails3 task package # 打包平台发布安装包
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/ # 辅助工具(如标注规则独立编辑器)
|
||||
├── internal/ # 核心内核(纯 Go 独立可测:PVF/ANI/NPK/脚本/规则/版本库)
|
||||
├── services/ # 应用服务层(桥接 Go 内核与前端 Wails IPC 状态)
|
||||
├── frontend/ # 前端工程(Vue 3 + TypeScript + Naive UI + Pinia)
|
||||
├── docs/ # 架构与格式规格设计文档
|
||||
├── scripts/ # 基准测试与工程脚本
|
||||
└── Taskfile.yml # 跨平台构建与开发任务编排
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP 连接
|
||||
|
||||
在“设置 → MCP 服务”启用本机服务,默认端口为 `19245`,点击“复制连接配置”,将配置粘贴到支持 Streamable HTTP 的 AI 客户端。连接无需鉴权,端口不变时重启后可继续使用原配置;服务默认关闭,仅监听本机并校验 Host/Origin。
|
||||
|
||||
在该设置页下方的“内置 skill”中,可多选安装掉率、技能数值、强化/增幅/锻造、NPC 商店与任务五类指南。支持选择项目根目录(安装至 `.agents/skills`)、通用目录 `~/.agents/skills`、Claude Code 用户目录 `~/.claude/skills`,以及自定义 skill 根目录。每个指南独立包含 `SKILL.md` 和 MCP 操作流程;默认跳过已有同名内容;可勾选“覆盖已有 skill”更新内置文件(会替换这些文件的本地修改),用户额外添加的文件保留。符号链接或文件类型冲突会报错。安装完成后让 AI 客户端重新加载 skill,并单独配置 MCP 连接。
|
||||
|
||||
AI 可通过 `pvf_annotation_search` 搜索标注说明,使用 `pvf_annotation_get` 查看字段布局和分页枚举,再用 `pvf_annotation_explain` 解释实际文件的值与关联对象。详见 [标注文档协议](docs/MCP标注文档协议.md)。
|
||||
|
||||
AI 与界面共享当前归档。脚本和导入都先生成预览,再应用到工作区,最后按需保存 PVF;另存为仅生成副本,后续保存仍写原文件。界面有未提交文本或 GUI 草稿时,先在编辑器处理草稿再重试。执行 AI 任务时暂时锁定编辑,AI 脚本可通过底部按钮取消。
|
||||
|
||||
普通导出写入指定父目录下的新目录;MOD 导出支持 `110USextend` 目录和 `115CN-mod` ZIP,包名为 `名称-版本号`,均不覆盖已有目标。可选择同格式的已有 MOD 合并 PVF 内容,自动读取名称并递增版本号末段。115CN 导出需提供唯一 `modId`。MCP 导入支持普通文件/目录,MOD 包导入使用 GUI 的“导入 → Mod”。操作较长时,AI 通过任务 ID 查询进度与结果。详细工具约定见 [MCP 服务设计](docs/MCP服务设计.md)。
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [S4A21 PVF 格式逆向分析规格与数学算法](docs/FORMAT.md)
|
||||
- [110US (Paged110) 容器结构与密钥分析](docs/PVF新格式分析.md)
|
||||
- [PVF 变体架构与 100CN / TW 接入设计](docs/PVF变体架构.md)
|
||||
- [115CN 内置支持与 GUI 兼容性检查](docs/115CN兼容性.md)
|
||||
- [MOD 格式注册与 110USextend 导出约定](docs/MOD.md)
|
||||
- [JavaScript 脚本工作区设计与 API](docs/脚本工作区设计.md)
|
||||
- [标注与渲染规则设计说明](docs/rule-editor.md)
|
||||
- [MCP 服务设计与工具约定](docs/MCP服务设计.md)
|
||||
|
||||
---
|
||||
|
||||
## 📋 免责声明
|
||||
|
||||
1. 本项目(`pvfine`)旨在用于**文件格式研究、逆向工程学习、单机交流与算法探索**。
|
||||
2. 本项目不提供、不分发任何受版权保护的游戏客户端原始资源包。
|
||||
3. 使用本项目对任何游戏资源进行的分析、提取、修改等操作,所有法律风险与责任均由使用者自行承担,作者及贡献者概不负责。
|
||||
4. 请勿将本项目用于任何破坏计算机信息系统、侵犯知识产权或商业盈利行为。
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源协议
|
||||
|
||||
本项目基于 [GNU General Public License v3.0 (GPL-3.0)](LICENSE) 协议开源。
|
||||
@@ -0,0 +1,76 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
APP_NAME: "pvfine"
|
||||
BIN_DIR: "bin"
|
||||
VERSION: '{{.VERSION | default "0.0.0"}}'
|
||||
REPOSITORY: '{{.REPOSITORY | default "dof-dev/pvfine"}}'
|
||||
PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}'
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
# Target OS for build/package/run. Defaults to the host OS, and is overridden
|
||||
# by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The
|
||||
# tasks below dispatch to the matching platform Taskfile via this variable.
|
||||
GOOS: '{{.GOOS | default OS}}'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:build"
|
||||
vars:
|
||||
VERSION: '{{.VERSION}}'
|
||||
REPOSITORY: '{{.REPOSITORY}}'
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:package"
|
||||
vars:
|
||||
VERSION: '{{.VERSION}}'
|
||||
REPOSITORY: '{{.REPOSITORY}}'
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:run"
|
||||
|
||||
dev:
|
||||
summary: Runs the application in development mode
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
annotation:editor:
|
||||
summary: Starts the built-in annotation rule editor
|
||||
cmds:
|
||||
- go run ./cmd/annotation-editor
|
||||
|
||||
setup:docker:
|
||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||
cmds:
|
||||
- task: common:setup:docker
|
||||
|
||||
build:server:
|
||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||
cmds:
|
||||
- task: common:build:server
|
||||
|
||||
run:server:
|
||||
summary: Runs the application in server mode
|
||||
cmds:
|
||||
- task: common:run:server
|
||||
|
||||
build:docker:
|
||||
summary: Builds a Docker image for server mode deployment
|
||||
cmds:
|
||||
- task: common:build:docker
|
||||
|
||||
run:docker:
|
||||
summary: Builds and runs the Docker image
|
||||
cmds:
|
||||
- task: common:run:docker
|
||||
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
annotationrules "pvfine/internal/annotations"
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
const maxRequestBytes = 4 << 20
|
||||
|
||||
type editorServer struct {
|
||||
mu sync.Mutex
|
||||
rulesPath string
|
||||
runtimePath string
|
||||
listsPath string
|
||||
runtimeListsPath string
|
||||
htmlPath string
|
||||
token string
|
||||
}
|
||||
|
||||
type previewRequest struct {
|
||||
PVFVersion string `json:"pvfVersion,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Text string `json:"text"`
|
||||
Document *annotationrules.Document `json:"document,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server, err := newEditorServer(root)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Handler: server.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
url := "http://" + listener.Addr().String() + "/"
|
||||
fmt.Printf("标注规则编辑器已启动: %s\n", url)
|
||||
if err := openBrowser(url); err != nil {
|
||||
fmt.Printf("无法自动打开浏览器,请手动访问上面的地址: %v\n", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("规则编辑器服务异常: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func newEditorServer(root string) (*editorServer, error) {
|
||||
tokenBytes := make([]byte, 24)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimePath, err := annotationrules.RuntimePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeListsPath, err := annotationrules.RuntimeListsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &editorServer{
|
||||
rulesPath: filepath.Join(root, "config", "annotations.json"),
|
||||
runtimePath: runtimePath,
|
||||
listsPath: filepath.Join(root, "config", "lists.json"),
|
||||
runtimeListsPath: runtimeListsPath,
|
||||
htmlPath: filepath.Join(root, "tools", "annotation-editor.html"),
|
||||
token: hex.EncodeToString(tokenBytes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *editorServer) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.serveHTML)
|
||||
mux.Handle("/api/rules", s.authorized(http.HandlerFunc(s.handleRules)))
|
||||
mux.Handle("/api/preview", s.authorized(http.HandlerFunc(s.handlePreview)))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *editorServer) authorized(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Annotation-Editor-Token") != s.token {
|
||||
writeAPIError(w, http.StatusForbidden, "无效的编辑器会话")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *editorServer) serveHTML(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(s.htmlPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page := strings.ReplaceAll(string(data), "__EDITOR_TOKEN__", s.token)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = io.WriteString(w, page)
|
||||
}
|
||||
|
||||
func (s *editorServer) handleRules(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// The editor owns the hand-maintained rules only. config/paths.json is
|
||||
// generated bulk data that must never round-trip into annotations.json.
|
||||
document, err := annotationrules.LoadRulesFile(s.rulesPath)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
data, err := annotationrules.Marshal(document)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
case http.MethodPut:
|
||||
data, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes+1))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(data) > maxRequestBytes {
|
||||
writeAPIError(w, http.StatusRequestEntityTooLarge, "规则文件超过 4MB")
|
||||
return
|
||||
}
|
||||
document, err := annotationrules.ParseRules(data)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
current, err := annotationrules.LoadRulesFile(s.rulesPath)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
document.Relations = current.Relations
|
||||
if err := annotationrules.Validate(document); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
formatted, err := annotationrules.MarshalRules(document)
|
||||
if s.listsPath == "" {
|
||||
formatted, err = annotationrules.Marshal(document)
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := writeRulesWithBackup(s.rulesPath, formatted); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if s.runtimePath != "" {
|
||||
if err := atomicWrite(s.runtimePath, formatted, 0o600); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "同步运行时规则失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.runtimeListsPath != "" && s.listsPath != "" {
|
||||
listsData, listsErr := os.ReadFile(s.listsPath)
|
||||
if listsErr != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "读取列表配置失败: "+listsErr.Error())
|
||||
return
|
||||
}
|
||||
if err := atomicWrite(s.runtimeListsPath, listsData, 0o600); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "同步运行时列表配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": len(formatted)})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
writeAPIError(w, http.StatusMethodNotAllowed, "不支持的请求方法")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *editorServer) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
writeAPIError(w, http.StatusMethodNotAllowed, "不支持的请求方法")
|
||||
return
|
||||
}
|
||||
var request previewRequest
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, maxRequestBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "预览参数无效: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if request.PVFVersion != "" && !annotationrules.ValidPVFVersion(request.PVFVersion) {
|
||||
writeAPIError(w, http.StatusBadRequest, "pvfVersion 只允许 90US、90CN、110US、115US、100CN、TW 或空值")
|
||||
return
|
||||
}
|
||||
|
||||
var document annotationrules.Document
|
||||
if request.Document != nil {
|
||||
document = *request.Document
|
||||
if len(document.Relations) == 0 {
|
||||
current, err := annotationrules.LoadRulesFile(s.rulesPath)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
document.Relations = current.Relations
|
||||
}
|
||||
if err := annotationrules.Validate(document); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Preview the rules the editor owns, not the generated bulk rule sets.
|
||||
current, loadErr := annotationrules.LoadRulesFile(s.rulesPath)
|
||||
if loadErr != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, loadErr.Error())
|
||||
return
|
||||
}
|
||||
document = current
|
||||
}
|
||||
engine, err := annotationrules.Compile(document)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
engine = engine.ForVersion(request.PVFVersion)
|
||||
results := engine.Annotate(request.Path, pvf.ParseScriptView(request.Text), nil)
|
||||
pathResults := engine.AnnotatePath(request.Path, false)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"annotations": results,
|
||||
"pathAnnotations": pathResults,
|
||||
})
|
||||
}
|
||||
|
||||
func writeRulesWithBackup(path string, data []byte) error {
|
||||
previous, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
if err := atomicWrite(path+".bak", previous, 0o644); err != nil {
|
||||
return fmt.Errorf("写入规则备份失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := atomicWrite(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("写入规则失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".annotation-rules-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer os.Remove(tempPath)
|
||||
if err := temp.Chmod(mode); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempPath, path)
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("未找到包含 go.mod 的项目根目录")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
var command *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
command = exec.Command("open", url)
|
||||
case "windows":
|
||||
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default:
|
||||
command = exec.Command("xdg-open", url)
|
||||
}
|
||||
return command.Start()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAnnotationEditorGroupingFields verifies the standalone editor exposes
|
||||
// both grouping controls on rules and shared fields.
|
||||
func TestAnnotationEditorGroupingFields(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "tools", "annotation-editor.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{
|
||||
"target-group-offset", "target-standalone-values",
|
||||
"field-target-group-offset", "field-target-standalone-values",
|
||||
"target-offset", "target-tokens-per-line",
|
||||
} {
|
||||
if !bytes.Contains(data, []byte(`"`+id+`"`)) {
|
||||
t.Errorf("missing editor input %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotationEditorGroupingSaveAndPreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
|
||||
document := []byte(`{
|
||||
"version":1,
|
||||
"rules":[{
|
||||
"id":"world.drop",
|
||||
"target":{"kind":"token","section":"world drop","index":0,"offset":1,"groupOffset":1,"recordTokens":2,"standaloneValues":[-1]},
|
||||
"annotation":{"title":"掉落","type":"text"}
|
||||
}],
|
||||
"fields":[{
|
||||
"id":"shared.drop",
|
||||
"target":{"kind":"token","section":"shared drop","index":0,"groupOffset":1,"recordTokens":2,"standaloneValues":[-1]},
|
||||
"annotation":{"title":"共享掉落","type":"text"}
|
||||
}]
|
||||
}`)
|
||||
call := func(method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/rules", document); response.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
saved := call(http.MethodGet, "/api/rules", nil)
|
||||
if saved.Code != http.StatusOK {
|
||||
t.Fatalf("read = %d: %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
var decoded struct {
|
||||
Rules []struct {
|
||||
Target struct {
|
||||
GroupOffset int `json:"groupOffset"`
|
||||
StandaloneValues []int32 `json:"standaloneValues"`
|
||||
} `json:"target"`
|
||||
} `json:"rules"`
|
||||
Fields []struct {
|
||||
Target struct {
|
||||
GroupOffset int `json:"groupOffset"`
|
||||
StandaloneValues []int32 `json:"standaloneValues"`
|
||||
} `json:"target"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(saved.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Rules) != 1 || len(decoded.Fields) != 1 ||
|
||||
decoded.Rules[0].Target.GroupOffset != 1 || decoded.Fields[0].Target.GroupOffset != 1 ||
|
||||
len(decoded.Rules[0].Target.StandaloneValues) != 1 || decoded.Rules[0].Target.StandaloneValues[0] != -1 ||
|
||||
len(decoded.Fields[0].Target.StandaloneValues) != 1 || decoded.Fields[0].Target.StandaloneValues[0] != -1 {
|
||||
t.Fatalf("grouping fields not preserved: %s", saved.Body.String())
|
||||
}
|
||||
text := "[world drop]\n999 10 100 200 -1 20 300 400"
|
||||
payload, err := json.Marshal(map[string]any{"path": "item/a.etc", "text": text})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := call(http.MethodPost, "/api/preview", payload)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("preview = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var preview struct {
|
||||
Annotations []struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
} `json:"annotations"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(preview.Annotations) != 2 {
|
||||
t.Fatalf("preview annotation count = %d: %s", len(preview.Annotations), response.Body.String())
|
||||
}
|
||||
for i, want := range []string{"100", "300"} {
|
||||
got := text[preview.Annotations[i].Start:preview.Annotations[i].End]
|
||||
if got != want {
|
||||
t.Errorf("annotation[%d] = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestRulesAPIWritesBackupAndRejectsInvalidRules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "default.json")
|
||||
runtimePath := filepath.Join(dir, "config", "annotations.json")
|
||||
original := []byte("{\"version\":1,\"relations\":{},\"rules\":[]}\n")
|
||||
if err := os.WriteFile(rulesPath, original, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: runtimePath, token: "test-token"}
|
||||
|
||||
updated := []byte(`{"version":1,"description":"updated","relations":{},"fields":[{"id":"equ.rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"稀有度","type":"enum","values":{"4":"史诗"}},"preview":{"provider":"equ","role":"rarity","group":"summary","order":10,"format":"enum"}}],"rules":[]}`)
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/rules", bytes.NewReader(updated))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
backup, err := os.ReadFile(rulesPath + ".bak")
|
||||
if err != nil || !bytes.Equal(backup, original) {
|
||||
t.Fatalf("backup = %q, err = %v", backup, err)
|
||||
}
|
||||
written, err := os.ReadFile(rulesPath)
|
||||
if err != nil || !bytes.Contains(written, []byte("updated")) || !bytes.Contains(written, []byte("equ.rarity")) {
|
||||
t.Fatalf("written = %q, err = %v", written, err)
|
||||
}
|
||||
runtimeRules, err := os.ReadFile(runtimePath)
|
||||
if err != nil || !bytes.Equal(runtimeRules, written) {
|
||||
t.Fatalf("runtime rules = %q, err = %v", runtimeRules, err)
|
||||
}
|
||||
|
||||
invalid := httptest.NewRequest(http.MethodPut, "/api/rules", bytes.NewReader([]byte("{}")))
|
||||
invalid.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(invalidResponse, invalid)
|
||||
if invalidResponse.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid status = %d", invalidResponse.Code)
|
||||
}
|
||||
afterInvalid, err := os.ReadFile(rulesPath)
|
||||
if err != nil || !bytes.Equal(afterInvalid, written) {
|
||||
t.Fatalf("invalid request changed rules: %q, err = %v", afterInvalid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewAPIUsesSubmittedRulesAndIncludesPathAnnotations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "default.json")
|
||||
if err := os.WriteFile(rulesPath, []byte("{\"version\":1,\"relations\":{},\"rules\":[]}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, token: "test-token"}
|
||||
body := []byte(`{
|
||||
"path":"equipment/example.equ",
|
||||
"text":"[rarity]\n4",
|
||||
"document":{
|
||||
"version":1,
|
||||
"relations":{},
|
||||
"rules":[
|
||||
{"id":"rarity","match":{"extensions":[".equ"]},"target":{"kind":"token","section":"rarity","index":0},"annotation":{"title":"装备品级","type":"enum","values":{"4":"史诗"}}},
|
||||
{"id":"path","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备配置","type":"text"}}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/preview", bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var result struct {
|
||||
Annotations []map[string]any `json:"annotations"`
|
||||
PathAnnotations []map[string]any `json:"pathAnnotations"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Annotations) != 1 || result.Annotations[0]["title"] != "史诗" {
|
||||
t.Fatalf("annotations = %#v", result.Annotations)
|
||||
}
|
||||
if len(result.PathAnnotations) != 1 || result.PathAnnotations[0]["title"] != "装备配置" {
|
||||
t.Fatalf("path annotations = %#v", result.PathAnnotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedRulesSaveAndPreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
|
||||
document := json.RawMessage(`{"version":1,"fields":[{"id":"name","pvfVersions":["90US","90CN","115US"],"target":{"kind":"section","section":"name"},"annotation":{"title":"name","type":"text"}}],"rules":[{"id":"path","pvfVersions":["90US","110US"],"target":{"kind":"path"},"annotation":{"title":"path","type":"text"}}]}`)
|
||||
call := func(method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/rules", document); response.Code != http.StatusOK {
|
||||
t.Fatalf("save: %s", response.Body.String())
|
||||
}
|
||||
response := call(http.MethodGet, "/api/rules", nil)
|
||||
var saved struct {
|
||||
Fields []struct {
|
||||
PVFVersions []string `json:"pvfVersions"`
|
||||
} `json:"fields"`
|
||||
Rules []struct {
|
||||
PVFVersions []string `json:"pvfVersions"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(saved.Fields) != 1 || len(saved.Fields[0].PVFVersions) != 3 || len(saved.Rules) != 1 || len(saved.Rules[0].PVFVersions) != 2 {
|
||||
t.Fatalf("versions lost: %s", response.Body.String())
|
||||
}
|
||||
for _, submitted := range []bool{false, true} {
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
annotations, paths int
|
||||
}{{"90US", 1, 1}, {"90CN", 1, 0}, {"110US", 0, 1}, {"115US", 1, 0}, {"", 0, 0}} {
|
||||
payload := map[string]any{"path": "a.equ", "text": "[name]\n`example`"}
|
||||
if tc.version != "" {
|
||||
payload["pvfVersion"] = tc.version
|
||||
}
|
||||
if submitted {
|
||||
payload["document"] = document
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := call(http.MethodPost, "/api/preview", body)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("preview: %s", response.Body.String())
|
||||
}
|
||||
var result struct {
|
||||
Annotations []any `json:"annotations"`
|
||||
PathAnnotations []any `json:"pathAnnotations"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Annotations) != tc.annotations || len(result.PathAnnotations) != tc.paths {
|
||||
t.Fatalf("%s submitted=%v: %s", tc.version, submitted, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if response := call(http.MethodPost, "/api/preview", []byte(`{"pvfVersion":"invalid"}`)); response.Code != http.StatusBadRequest {
|
||||
t.Fatal("invalid preview version accepted")
|
||||
}
|
||||
invalid := bytes.ReplaceAll(document, []byte("90US"), []byte("invalid"))
|
||||
if response := call(http.MethodPut, "/api/rules", invalid); response.Code != http.StatusBadRequest {
|
||||
t.Fatal("invalid config version accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRulesAPIIgnoresGeneratedPathNotes keeps the editor on the hand-maintained
|
||||
// rules: a sibling paths.json must never round-trip into annotations.json.
|
||||
func TestRulesAPIIgnoresGeneratedPathNotes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rulesPath := filepath.Join(dir, "annotations.json")
|
||||
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := []byte(`{"version":1,"rules":[{"id":"path.dir","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"装备","type":"text"}}]}`)
|
||||
if err := os.WriteFile(filepath.Join(dir, "paths.json"), generated, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &editorServer{rulesPath: rulesPath, runtimePath: filepath.Join(dir, "runtime.json"), token: "test-token"}
|
||||
call := func(method string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, "/api/rules", bytes.NewReader(body))
|
||||
request.Header.Set("X-Annotation-Editor-Token", "test-token")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
if read := call(http.MethodGet, nil); read.Code != http.StatusOK || bytes.Contains(read.Body.Bytes(), []byte("path.dir")) {
|
||||
t.Fatalf("GET /api/rules returned generated path notes: %s", read.Body.String())
|
||||
}
|
||||
saved := call(http.MethodPut, []byte(`{"version":1,"rules":[{"id":"hand","target":{"kind":"section","section":"name"},"annotation":{"title":"名称","type":"text"}}]}`))
|
||||
if saved.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d: %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
for _, path := range []string{rulesPath, filepath.Join(dir, "runtime.json")} {
|
||||
written, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(written, []byte("path.dir")) || bytes.Contains(written, []byte("equipment/**")) {
|
||||
t.Fatalf("%s contains generated path rules: %s", path, written)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Command pvfexport extracts an item_id -> display-name map from a DNF
|
||||
// Script.pvf archive. It reads the stackable.lst and equipment.lst indexes,
|
||||
// resolves each item file, and pulls the [name] section via ScriptMetadata.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// pvfexport -pvf Script.pvf -out items.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
type itemEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Rarity int32 `json:"rarity"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type exportResult struct {
|
||||
PVFPath string `json:"pvf_path"`
|
||||
Total int `json:"total"`
|
||||
Items []itemEntry `json:"items"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
pvfPath := flag.String("pvf", "Script.pvf", "path to Script.pvf")
|
||||
outPath := flag.String("out", "items.json", "output JSON path")
|
||||
compact := flag.Bool("compact", false, "输出紧凑的 {item_id: name} 映射(仅保留有名称的项)")
|
||||
flag.Parse()
|
||||
|
||||
archive, err := pvf.Open(*pvfPath)
|
||||
if err != nil {
|
||||
log.Fatalf("打开 PVF 失败: %v", err)
|
||||
}
|
||||
defer archive.Release()
|
||||
|
||||
items := make([]itemEntry, 0, 200000)
|
||||
seen := make(map[int64]bool)
|
||||
|
||||
// 顺序处理两类物品列表,堆叠道具优先。
|
||||
for _, list := range []struct {
|
||||
root string
|
||||
path string
|
||||
kind string
|
||||
}{
|
||||
{"stackable", "stackable/stackable.lst", "stackable"},
|
||||
{"equipment", "equipment/equipment.lst", "equipment"},
|
||||
} {
|
||||
count, err := exportList(archive, list.root, list.path, list.kind, seen, &items)
|
||||
if err != nil {
|
||||
log.Printf("警告: 处理 %s 失败: %v", list.path, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("%s: 解析 %d 条", list.path, count)
|
||||
}
|
||||
|
||||
result := exportResult{
|
||||
PVFPath: *pvfPath,
|
||||
Total: len(items),
|
||||
Items: items,
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if *compact {
|
||||
// 紧凑映射:{item_id: name},仅保留有名称的项
|
||||
compactMap := make(map[string]string, len(items))
|
||||
for _, it := range items {
|
||||
name := strings.TrimSpace(it.Name)
|
||||
if name != "" {
|
||||
compactMap[strconv.FormatInt(it.ID, 10)] = name
|
||||
}
|
||||
}
|
||||
data, err = json.Marshal(compactMap)
|
||||
} else {
|
||||
data, err = json.MarshalIndent(result, "", " ")
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("序列化 JSON 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*outPath, data, 0o644); err != nil {
|
||||
log.Fatalf("写入 %s 失败: %v", *outPath, err)
|
||||
}
|
||||
fmt.Printf("导出完成: %d 个物品 -> %s\n", len(items), *outPath)
|
||||
}
|
||||
|
||||
func exportList(archive *pvf.Archive, root, listPath, kind string, seen map[int64]bool, items *[]itemEntry) (int, error) {
|
||||
idx, found := archive.Find(listPath)
|
||||
if !found {
|
||||
return 0, fmt.Errorf("未找到 %s", listPath)
|
||||
}
|
||||
text, err := archive.Text(idx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取 %s: %w", listPath, err)
|
||||
}
|
||||
pairs, err := pvf.ParseListText(text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析 %s: %w", listPath, err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, pair := range pairs {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(pair.ID), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
path := cleanPath(pair.Path)
|
||||
itemIdx, ok := resolveItemFile(archive, root, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta, err := archive.ScriptMetadata(itemIdx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
count++
|
||||
*items = append(*items, itemEntry{
|
||||
ID: id,
|
||||
Name: meta.Name,
|
||||
Rarity: meta.Rarity,
|
||||
Path: path,
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// resolveItemFile 复刻服务端 readDungeonDropText 的候选路径策略:
|
||||
// 先直接用 lst 里的路径,再尝试 root/ 前缀,最后尝试 (r) 修订文件名。
|
||||
func resolveItemFile(archive *pvf.Archive, root, listedPath string) (int32, bool) {
|
||||
clean := cleanPath(listedPath)
|
||||
candidates := []string{clean}
|
||||
root = cleanPath(root)
|
||||
if root != "" && !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
|
||||
candidates = append(candidates, root+"/"+clean)
|
||||
}
|
||||
for _, candidate := range append([]string(nil), candidates...) {
|
||||
dir, base := splitPath(candidate)
|
||||
if base == "" || strings.HasPrefix(strings.ToLower(base), "(r)") {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, dir+"(r)"+base)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if idx, found := archive.Find(candidate); found {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cleanPath(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
return strings.TrimPrefix(value, "/")
|
||||
}
|
||||
|
||||
func splitPath(value string) (string, string) {
|
||||
idx := strings.LastIndex(value, "/")
|
||||
if idx < 0 {
|
||||
return "", value
|
||||
}
|
||||
return value[:idx+1], value[idx+1:]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"version": 1,
|
||||
"name": "系统内置",
|
||||
"groups": [
|
||||
{
|
||||
"id": "builtin-list",
|
||||
"name": "列表",
|
||||
"groups": [
|
||||
{
|
||||
"id": "bookmark-group-1",
|
||||
"name": "技能",
|
||||
"entries": [
|
||||
{
|
||||
"path": "skill/atfighterskill.lst",
|
||||
"name": "格斗家·男"
|
||||
},
|
||||
{
|
||||
"path": "skill/atgunnerskill.lst",
|
||||
"name": "神枪手·女"
|
||||
},
|
||||
{
|
||||
"path": "skill/atmageskill.lst",
|
||||
"name": "魔法师·男"
|
||||
},
|
||||
{
|
||||
"path": "skill/atpriestskill.lst",
|
||||
"name": "圣职者·女"
|
||||
},
|
||||
{
|
||||
"path": "skill/atswordmanskill.lst",
|
||||
"name": "鬼剑士·女"
|
||||
},
|
||||
{
|
||||
"path": "skill/creatormageskill.lst",
|
||||
"name": "缔造者"
|
||||
},
|
||||
{
|
||||
"path": "skill/demoniclancerskill.lst",
|
||||
"name": "魔枪士"
|
||||
},
|
||||
{
|
||||
"path": "skill/dsswordmanskill.lst",
|
||||
"name": "黑暗武士"
|
||||
},
|
||||
{
|
||||
"path": "skill/fighterskill.lst",
|
||||
"name": "格斗家·女"
|
||||
},
|
||||
{
|
||||
"path": "skill/gunnerskill.lst",
|
||||
"name": "神枪手·男"
|
||||
},
|
||||
{
|
||||
"path": "skill/mageskill.lst",
|
||||
"name": "魔法师·女"
|
||||
},
|
||||
{
|
||||
"path": "skill/priestskill.lst",
|
||||
"name": "圣职者·男"
|
||||
},
|
||||
{
|
||||
"path": "skill/swordmanskill.lst",
|
||||
"name": "鬼剑士·男"
|
||||
},
|
||||
{
|
||||
"path": "skill/thiefskill.lst",
|
||||
"name": "暗夜使者"
|
||||
},
|
||||
{
|
||||
"path": "skill/knight.lst",
|
||||
"name": "守护者"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"path": "equipment/equipment.lst",
|
||||
"name": "装备"
|
||||
},
|
||||
{
|
||||
"path": "stackable/stackable.lst",
|
||||
"name": "道具"
|
||||
},
|
||||
{
|
||||
"path": "appendage/appendage.lst",
|
||||
"name": "apd"
|
||||
},
|
||||
{
|
||||
"path": "character/character.lst",
|
||||
"name": "职业"
|
||||
},
|
||||
{
|
||||
"path": "creature/creature.lst",
|
||||
"name": "宠物"
|
||||
},
|
||||
{
|
||||
"path": "dungeon/dungeon.lst",
|
||||
"name": "地下城"
|
||||
},
|
||||
{
|
||||
"path": "itemshop/itemshop.lst",
|
||||
"name": "商店"
|
||||
},
|
||||
{
|
||||
"path": "map/map.lst",
|
||||
"name": "map"
|
||||
},
|
||||
{
|
||||
"path": "monster/monster.lst",
|
||||
"name": "怪物"
|
||||
},
|
||||
{
|
||||
"path": "n_quest/quest.lst",
|
||||
"name": "任务"
|
||||
},
|
||||
{
|
||||
"path": "npc/npc.lst",
|
||||
"name": "npc"
|
||||
},
|
||||
{
|
||||
"path": "passiveobject/passiveobject.lst",
|
||||
"name": "被动对象"
|
||||
},
|
||||
{
|
||||
"path": "region/region.lst",
|
||||
"name": "区域"
|
||||
},
|
||||
{
|
||||
"path": "town/town.lst",
|
||||
"name": "城镇"
|
||||
},
|
||||
{
|
||||
"path": "worldmap/worldmap.lst",
|
||||
"name": "副本接口"
|
||||
},
|
||||
{
|
||||
"path": "etc/equipmentpartset.etc",
|
||||
"name": "套装"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bookmark-group-2",
|
||||
"name": "掉落",
|
||||
"entries": [
|
||||
{
|
||||
"path": "etc/itemdropinfo_monster_hell.etc",
|
||||
"name": "深渊掉落"
|
||||
},
|
||||
{
|
||||
"path": "etc/itemdropinfo_clearreward.etc",
|
||||
"name": "翻牌掉落"
|
||||
},
|
||||
{
|
||||
"path": "etc/itemdropinfo_monseter.etc",
|
||||
"name": "怪物掉落"
|
||||
},
|
||||
{
|
||||
"path": "etc/itemdropinfo_monseter_extra.etc",
|
||||
"name": "精英掉落"
|
||||
},
|
||||
{
|
||||
"path": "etc/worlddrop.etc",
|
||||
"name": "全局掉率"
|
||||
},
|
||||
{
|
||||
"path": "etc/independent_drop.etc",
|
||||
"name": "独立掉落"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bookmark-group-3",
|
||||
"name": "常用道具",
|
||||
"entries": [
|
||||
{
|
||||
"path": "stackable/consumption_1254.stk",
|
||||
"name": "红气息"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"path": "etc/disjoint.etc",
|
||||
"name": "分解装备"
|
||||
},
|
||||
{
|
||||
"path": "etc/pcroom.vm",
|
||||
"name": "黑钻抽奖机"
|
||||
},
|
||||
{
|
||||
"path": "etc/pcroom4.vm",
|
||||
"name": "高级黑钻抽奖机"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed annotations.json
|
||||
var AnnotationsJSON []byte
|
||||
|
||||
//go:embed paths.json
|
||||
var PathsJSON []byte
|
||||
|
||||
//go:embed sections.json
|
||||
var SectionsJSON []byte
|
||||
|
||||
//go:embed rendering.json
|
||||
var RenderingJSON []byte
|
||||
|
||||
//go:embed lists.json
|
||||
var ListsJSON []byte
|
||||
|
||||
//go:embed bookmarks.json
|
||||
var BookmarksJSON []byte
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "pvfine built-in list and relation indexes",
|
||||
"relations": {
|
||||
"装备": {
|
||||
"listPath": "equipment/equipment.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"道具": {
|
||||
"listPath": "stackable/stackable.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"apd": {
|
||||
"listPath": "appendage/appendage.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"职业": {
|
||||
"listPath": "character/character.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"宠物": {
|
||||
"listPath": "creature/creature.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"地下城": {
|
||||
"listPath": "dungeon/dungeon.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"商店": {
|
||||
"listPath": "itemshop/itemshop.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"map": {
|
||||
"listPath": "map/map.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"怪物": {
|
||||
"listPath": "monster/monster.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"任务": {
|
||||
"listPath": "n_quest/quest.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"npc": {
|
||||
"listPath": "npc/npc.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"被动对象": {
|
||||
"listPath": "passiveobject/passiveobject.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"区域": {
|
||||
"listPath": "region/region.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"城镇": {
|
||||
"listPath": "town/town.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"副本接口": {
|
||||
"listPath": "worldmap/worldmap.lst",
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name"
|
||||
},
|
||||
"物品": {
|
||||
"kind": "union",
|
||||
"relations": ["装备", "道具"]
|
||||
},
|
||||
"技能": {
|
||||
"kind": "contextual",
|
||||
"contextToken": 0,
|
||||
"idToken": 0,
|
||||
"pathToken": 1,
|
||||
"recordTokens": 2,
|
||||
"nameSection": "name",
|
||||
"contextPaths": {
|
||||
"[swordman]": "skill/swordmanskill.lst",
|
||||
"[at swordman]": "skill/atswordmanskill.lst",
|
||||
"[fighter]": "skill/fighterskill.lst",
|
||||
"[at fighter]": "skill/atfighterskill.lst",
|
||||
"[gunner]": "skill/gunnerskill.lst",
|
||||
"[at gunner]": "skill/atgunnerskill.lst",
|
||||
"[mage]": "skill/mageskill.lst",
|
||||
"[at mage]": "skill/atmageskill.lst",
|
||||
"[priest]": "skill/priestskill.lst",
|
||||
"[at priest]": "skill/atpriestskill.lst",
|
||||
"[thief]": "skill/thiefskill.lst",
|
||||
"[creator mage]": "skill/creatormageskill.lst",
|
||||
"[knight]": "skill/knight.lst",
|
||||
"[demonic swordman]": "skill/dsswordmanskill.lst",
|
||||
"[demonic lancer]": "skill/demoniclancerskill.lst"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+249140
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "pvfine built-in script rendering rules",
|
||||
"rules": [
|
||||
{
|
||||
"id": "file.lst",
|
||||
"match": {
|
||||
"extensions": [
|
||||
".lst"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
"kind": "file"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.world-drop",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "world drop"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 2,
|
||||
"standaloneValues": [
|
||||
-1
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.independent-drop",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "independent drop"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 17,
|
||||
"nestedSections": [
|
||||
"list"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.list",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "list"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.skill-data-up",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "skill data up"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.skill-levelup",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "skill levelup"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.skill",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "skill"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 7,
|
||||
"offset": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.level info",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "level info"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 1,
|
||||
"offset": 1,
|
||||
"tokensPerLineIndex": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.input item",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "input item"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.output item",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "output item"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.basis of rarity dicision.hell",
|
||||
"match": {
|
||||
"glob": "etc/itemdropinfo_monster_hell.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "basis of rarity dicision"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 7,
|
||||
"tokensPerLineByVersion": {
|
||||
"90US": 7,
|
||||
"90CN": 7,
|
||||
"TW": 6,
|
||||
"100CN": 8,
|
||||
"110US": 9,
|
||||
"115CN": 9
|
||||
},
|
||||
"offset": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.basis of rarity dicision",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "basis of rarity dicision"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 7,
|
||||
"tokensPerLineByVersion": {
|
||||
"90US": 7,
|
||||
"90CN": 7,
|
||||
"TW": 6,
|
||||
"100CN": 8,
|
||||
"110US": 9,
|
||||
"115CN": 9
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "section.equipment part set",
|
||||
"match": {},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "equipment part set"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 4,
|
||||
"offset": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.upgrade.table",
|
||||
"match": {
|
||||
"glob": "etc/upgrade.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "table"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 17
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.upgrade.table-type",
|
||||
"match": {
|
||||
"glob": "etc/upgrade.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "table type"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 24,
|
||||
"offset": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.upgrade.safe-upgrade",
|
||||
"match": {
|
||||
"glob": "etc/upgrade.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "safe upgrade"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.amplify.table",
|
||||
"match": {
|
||||
"glob": "etc/amplifyupgrade.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "table"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 17
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.amplify.table-type",
|
||||
"match": {
|
||||
"glob": "etc/amplifyupgrade.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "table type"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 24,
|
||||
"offset": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.amplify.safe-upgrade",
|
||||
"match": {
|
||||
"glob": "etc/amplifyupgrade.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "safe upgrade"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.table",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "table"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 4,
|
||||
"tokensPerLineByVersion": {
|
||||
"115CN": 6
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.item-weights-by-grade",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "item weights by grade"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.item-weights-by-grade-100lv",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "item weights by grade 100lv"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.item-weights-by-rarity",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "item weights by rarity"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.item-weights-by-rarity-100lv",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "item weights by rarity 100lv"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.stat-weights-by-rarity",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "stat weights by rarity"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.separate-upgrade-effect",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "separate upgrade effect"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.activestatus-upgrade-dungeon",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "activestatus upgrade dungeon"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.activestatus-upgrade-pvp",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "activestatus upgrade pvp"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.anti-evil-upgrade",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "anti evil upgrade"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "etc.forge.genuine-buffer-option-apply-weapon",
|
||||
"match": {
|
||||
"glob": "etc/upgrade_separate.etc"
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "genuine buffer option apply weapon"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "user.rule.aaf15bba-9653-41ae-b16c-17f4fb640e66",
|
||||
"match": {
|
||||
"extensions": [
|
||||
".stk"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "cera"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 3,
|
||||
"offset": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "user.rule.1d7969fe-999b-4129-87bd-161e033bbcb0",
|
||||
"match": {
|
||||
"extensions": [
|
||||
".vm"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
"kind": "section",
|
||||
"section": "output"
|
||||
},
|
||||
"format": {
|
||||
"tokensPerLine": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
# 115CN 兼容性检查
|
||||
|
||||
检查日期:2026-10-31。样本位于本地 `testdata/115CN`,共 6,251,369 个文件。
|
||||
本报告验证内核、GUI 数据读取和源码编辑逻辑,不代表已逐项完成桌面窗口交互验收。
|
||||
|
||||
## 容器
|
||||
|
||||
- 独立识别为 `paged115cn` / `115CN`,不冒用 `115US` 身份。
|
||||
- 配套客户端参数和 `sk.dat` 封装在 `internal/pvf/assets/clients/115cn.bin`。
|
||||
日常打开无需同目录的 exe 或 `sk.dat`,也无需执行客户端程序。
|
||||
- 字符串池为 UTF-16,保留分页容器、字符串引用和 indexhash 约定。
|
||||
- 无配套文件、错误外部 `sk.dat`、内存解析均已验证;未修改保存通过 SHA-256 一致性验证。
|
||||
- 客户端参数不输出到日志,不在源码中展开;真实归档、exe 和原始配套文件不提交。
|
||||
|
||||
## GUI 结果
|
||||
|
||||
| 工具 | 样本数量 | 结果和限制 |
|
||||
| --- | ---: | --- |
|
||||
| 商店 | 1,491 | 已适配嵌套商品记录、直接商品列表和根级分页。读取无错误,剩余 16 个没有可展示的分页;529 个文件存在结构、缺失引用或成本提示,不等于全部可编辑。 |
|
||||
| 基础掉率 | 4 | 均为 9 列分组;已放开前后端入口。修改后的四份 payload 可重新解码,额外阈值保持不变。 |
|
||||
| 全局掉落 | 1 | 数据读取通过。 |
|
||||
| 强化/增幅 | 2 | 普通表和安全表读取通过,无格式提示。 |
|
||||
| 锻造 | 1 | 已适配每级 6 列,成功率/材料倍率映射至正确源位置;两项附加系数只读。4/6 列无法唯一识别时拒绝 GUI 编辑。 |
|
||||
| 技能 | 4,981 | 合并远端根级参数适配后读取无错误;36 个带有重复段、异常等级宽度或额外描述绑定等保护提示。1,004 个没有可展示的根级或 dungeon/pvp 参数模式。 |
|
||||
| 任务 | 14,128 | 14,108 个通过前端解析;14,107 个验证了整数编辑只改目标 token。20 个闭合结构无法安全识别,保留 DSL 编辑。 |
|
||||
|
||||
任务嵌套区块整体只读,子字段不会覆盖顶层名称、条件或奖励;正常顶层字段仍可编辑。
|
||||
2,668 个可解析任务带有嵌套、重复或特殊类型字段提示,不意味着整份任务都可由表单覆盖。
|
||||
没有强行接受那 20 个结构不明的任务,也没有清理或重写原始样本。
|
||||
|
||||
## 商店结构
|
||||
|
||||
旧结构在 `[item list]` 内直接列出物品 ID。115CN 改为:
|
||||
|
||||
```text
|
||||
[sell item list]
|
||||
[item]
|
||||
0
|
||||
[index]
|
||||
12345
|
||||
[/item]
|
||||
[/sell item list]
|
||||
```
|
||||
|
||||
`[item]` 的直接整数是记录键,`[index]` 才是物品 ID;示例 ID 仅用于说明。
|
||||
正常样本有五种布局:`sell info/tab` 下直接或按分类存放列表、
|
||||
`sell info` 下直接或按分类存放列表,以及根级 `tab`。
|
||||
|
||||
- 正常闭合结构共解析 27,977 条非空商品记录,全部核对了预览 ID 与二进制编辑定位;
|
||||
`[index] -1` 作为空位保留,不展示成商品。
|
||||
- 支持新增、替换、删除商品;替换只修改 `index`,删除移除整条 `item`,
|
||||
不合并重复物品,不丢弃同记录的限购、购买数量、退款等字段。
|
||||
新增记录使用高于当前文件已有键的记录键,不复制其他商品的局部配置。
|
||||
- 显式分页支持增删改;无分页布局只投影成一个 GUI 页,不擅自重组原文件,
|
||||
其分页管理仍需文本模式。
|
||||
- 记录内的 `price`、`need material` 按对应成本类型覆盖物品文件中的共享成本。
|
||||
局部成本目前只读,带局部成本的记录可替换物品或删除;
|
||||
单条成本修改及包含这些记录的批量成本修改会被拒绝,避免误改共享物品。
|
||||
- 46 个文件带有结构提示,包含缺少分类 ID、未声明分类类型或不可识别的嵌套列表等;
|
||||
声明分类但直接列出商品的记录可以通过“未分类”入口查看,仍保留保护提示。
|
||||
另有缺失物品/材料引用的提示,合计 529 个商店带提示。
|
||||
6 个文件有二进制区块闭合警告,拒绝 GUI 编辑。
|
||||
- 五种真实布局均通过新增、另存临时 PVF、重开及删除新增记录验证;
|
||||
删除后对应商店 payload 与原始内容逐字节一致。未覆盖原始样本。
|
||||
|
||||
## 排版与标注
|
||||
|
||||
- `section.basis of rarity dicision` 及其 `.hell` 规则为 115CN 每组显示 9 项。
|
||||
- `etc.forge.table` 为 115CN 每行显示 6 项,其他版本保留原行宽。
|
||||
- `etc.forge.115cn.table.0` 至 `.5` 独立标注六个字段;旧四列标注不应用于 115CN。
|
||||
- 规则版本校验、编辑入口和归档版本标签均增加 115CN。
|
||||
- 已运行的应用需要重新构建并打开;仅修改仓库 JSON 不会保证旧进程自动加载新配置。
|
||||
|
||||
## 复现
|
||||
|
||||
在仓库根目录使用 PowerShell 7:
|
||||
|
||||
```powershell
|
||||
$env:PVF_TESTFILE = (Resolve-Path testdata/115CN/Script.pvf).Path
|
||||
$env:PVF_TEST_CLIENT_VERSION = '115CN'
|
||||
go test ./internal/pvf -run '^TestReal(BundledPagedClient|PagedClientCompatibility|PagedClientEditRoundTrip)$' -count=1 -v -timeout 20m
|
||||
|
||||
$env:PVF_GUI_TESTFILE = $env:PVF_TESTFILE
|
||||
$env:PVF_GUI_QUEST_CORPUS = Join-Path ([IO.Path]::GetTempPath()) 'pvfine-115cn-quest-corpus.json'
|
||||
go test ./services -run '^TestGUIRealArchiveCompatibility$' -count=1 -v -timeout 20m
|
||||
|
||||
$env:PVF_SHOP_TESTFILE = $env:PVF_TESTFILE
|
||||
go test ./services -run '^TestShopReal115CN' -count=1 -v -timeout 20m
|
||||
|
||||
$env:PVF_GUI_QUEST_EXPECTED_REJECTIONS = '20'
|
||||
Push-Location frontend
|
||||
npm test
|
||||
npm run build
|
||||
Pop-Location
|
||||
```
|
||||
|
||||
任务语料仅写入指定的本地临时文件,不加入仓库。`PVF_GUI_QUEST_EXPECTED_REJECTIONS`
|
||||
只记录这份样本的预期拒绝数,不会放宽应用解析或编辑限制。
|
||||
运行普通 `go test ./...` 前应清除这些真实样本环境变量,避免触发其他版本专用测试。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 60ACT 归档读写
|
||||
|
||||
本地 A1、A4、A5、A12 样本均已通过目录与全部文件 CRC 校验。它们不使用分页保护,不需要客户端 EXE 或 sk.dat。
|
||||
|
||||
| 变体 | 文件数 | 文件头 | 目录项顺序 | 文件 CRC 初始值 |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| A1 | 48,326 | `DNF_SCRIPT_PACK`(15 字节) | 编号、偏移、长度、CRC、路径长度、路径 | 文件偏移 |
|
||||
| A4 | 71,019 | UUID 长度、UUID、版本 | 编号、路径长度、路径、长度、CRC、偏移 | 文件编号 |
|
||||
| A5 | 126,696 | 同 A4 | 同 A4 | 文件编号 |
|
||||
| A12 | 145,184 | 同 A4 | 同 A4 | 文件编号 |
|
||||
|
||||
A1 的签名之后,或其余版本的版本号之后,均为三个小端 uint32:目录密文长度、目录 CRC、文件数。目录后紧接文件数据区,目录中的文件偏移相对于此数据区。
|
||||
|
||||
目录和文件均按四字节对齐。逐 uint32 解密为 `ROR32(cipher XOR CRC, 6)`。目录 CRC 为 `crc32.Update(文件数, IEEE, 对齐后的目录明文)`;文件 CRC 使用上表初始值,对齐后的文件明文参与计算,读取时再截断到声明长度。现有 TW 容器的额外异或常量 `0x81A79011` 不适用于这四个样本。
|
||||
|
||||
文本以国服 GBK 解码,保留注释和排版;有 UTF-8 BOM 时按 UTF-8 读取。语言后缀不用于推断编码。路径按既有 CP949 路径约定读取。带 NUL 的原生资源保留为二进制,不尝试解释成五字节脚本令牌。A5/A12 大多数 ANI 为二进制,可通过通用 ANI 模块解码为只读文本并预览,原始载荷保持不变。
|
||||
|
||||
支持读取、搜索文本、修改、增删文件及保存。磁盘格式没有字符串池或 `stringtable.bin`;打开时为文本脚本建立编辑器内部的令牌视图和字符串池,统一作为 `TypeScript` 使用,兼容名称、稀有度、图标、列表编号、标注、GUI 和批量脚本解析。`.str`、`.txt`、`.nut` 等普通文本仍为 `TypeUnicode`;含 NUL 的二进制资源保持原样。源码支持正负号数字、嵌套 Section、反引号字符串、`//`/`/* */` 注释和 `<table::key>` 本地化引用,兼容 ACT60 根目录的 `string.lst` 字符串表映射。未修改的归档逐字节原样导出;修改后重建目录与文件数据区,保留原始签名、UUID、版本和已有文件编号。未修改文件不经过字符解码再编码,保持原始载荷和对齐字节。A1 文件移动时以新偏移重新计算 CRC 并加密,其他版本沿用未修改文件的原始密文。
|
||||
|
||||
文本修改按原编码保存(默认 GBK,原有 UTF-8 BOM 文件继续使用 UTF-8 BOM),不能用 GBK 表示的字符会报错并保留待保存修改。新增文件路径按 CP949 编码。导入的令牌脚本在保存时转为文本源码,重新打开后继续提供 `TypeScript` 语义视图。直接文本编辑保留原始排版与注释,仅修改注释或空白也会被记录;GUI/令牌编辑按脚本渲染规则输出源码,内部块字符串标记不会写入原生 ACT60 文本。二进制 ANI 可原样保留或替换原始载荷,支持只读文本解码与预览,尚不支持文本重编译。保存前重新解析并校验所有文件,写入失败时不清除内存修改。
|
||||
|
||||
实现位于 `internal/pvf/act60.go` 和 `internal/pvf/act60_save.go`,合成回归覆盖两种目录布局、GBK 文本、二进制、空文件、克隆、比较、原样导出、编辑、增删、连续保存、UTF-8 BOM 保留、编码/写入失败重试与损坏/截断数据。真实样本验证使用:
|
||||
|
||||
```sh
|
||||
PVF_TESTFILE=/path/to/Script.pvf go test ./internal/pvf -run '^TestRealACT60(Parse|EditRoundTrip)$' -v -count=1
|
||||
```
|
||||
|
||||
## pvfUtility 对照
|
||||
|
||||
核对版本 `53dc8e48bba0f7871ca96bcf10bfe6d2028d0582`:其容器读取固定使用 TW 异或常量,没有上述 ACT60 容器分支,因此无法直接打开这些样本。但其 `BinaryAniCompiler` 支持二进制 ANI,后续可参考其格式分析:
|
||||
|
||||
- [容器读取](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Services/PvfCode/PvfGroup.cs#L188)
|
||||
- [加解密算法](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Models/PvfCode/Models/Pvf/PvfAlgorithmHelper.cs#L49)
|
||||
- [二进制 ANI](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Services/PvfCode/Services/BinaryAniCompiler.cs#L166)
|
||||
@@ -0,0 +1,312 @@
|
||||
# 90US 格式规格
|
||||
|
||||
## 总体布局
|
||||
|
||||
```
|
||||
偏移 大小 内容
|
||||
0x00 0x30 (48B) Header —— "HeaD" 加密(可选 Guard)
|
||||
0x30 FileCount × 24B FileTable(明文)
|
||||
... HashTableSize HashTable —— "HASH" 加密
|
||||
... NameTableSize NameTable —— 内含 sTrA/sTrW 两个加密 zlib 段
|
||||
... GroupCount × 8B GRPI(分块索引)—— "GRPI" 加密
|
||||
... BodySize Body:GroupCount 个 zlib 分块,整块 "BodY" 加密
|
||||
```
|
||||
|
||||
## 1. Header(解密后 48 字节,pack=1)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| Signature | u32 | 明文恒为 `0x69706B6E`(LE 字节 `6E 6B 70 69`) |
|
||||
| Guid | 20B | 本文件全 0 |
|
||||
| FileCount | i32 | 本文件 1,008,171 |
|
||||
| Padding | i32 | — |
|
||||
| BodySize | i32 | 54,090,116 |
|
||||
| GroupCount | i32 | 8,019 |
|
||||
| HashTableSize | i32 | 9,868,348 |
|
||||
| NameTableSize | i32 | 12,703,586 |
|
||||
|
||||
校验:`0x30 + FileCount*24 + HashTableSize + NameTableSize + GroupCount*8 + BodySize == 文件大小`。
|
||||
|
||||
## 2. 解密算法(LCG 流 XOR,即 MSVC `rand()` 参数)
|
||||
|
||||
四种 key:`"HeaD"`(头部)、`"HASH"`、`"GRPI"`、`"BodY"`(整块)用 magic `0x269EC3`;
|
||||
`"sTrA"` / `"sTrW"`(Decrypt2)用 magic `0x269EC9`。
|
||||
|
||||
```python
|
||||
def decrypt(data, key, magic=0x269EC3):
|
||||
k = key.encode() # 恰好 4 字节
|
||||
seed = (0x76826701*k[0] + 0x1C1*(k[3] + 0x1C1*(k[2] + 0x1C1*k[1]))) & 0xFFFFFFFF
|
||||
for each 4-byte block:
|
||||
t1 = (0x343FD*seed + magic) & 0xFFFFFFFF
|
||||
seed = (0x343FD*t1 + magic) & 0xFFFFFFFF
|
||||
xor = (seed & 0xFFFF0000) | (t1 >> 16) # 即 C# 的 (uint)((seed>>16)&0xFFFF + t1&0xFFFF0000)
|
||||
block ^= xor # little-endian u32
|
||||
tail(0-3 字节): t1,t2 再各迭代一步,
|
||||
finalKey = (t1 & 0xFFFF0000) | (t2 >> 16),按字节 XOR
|
||||
```
|
||||
|
||||
注意所有运算都是 C# `int` 溢出语义 = mod 2³²;`(seed>>16)&0xFFFF` 因子掩码,算术/逻辑移位等价。
|
||||
|
||||
**Guard 变体**:解密头部前先做 `header[24..28] ^= 0x55`。本文件使用 Guard=True。
|
||||
两种都试,Signature 匹配者胜。
|
||||
|
||||
**种子变体**:另一批归档沿用同一 LCG(乘数 `0x343FD`、magic `0x269EC3`/`0x269EC9`),
|
||||
但分段种子不再由 `"HeaD"/"HASH"/…` 这组固定字符串推出,标准密钥解不开头部。
|
||||
|
||||
这些种子是**该变体的固定常量,而非每个归档各不相同**:同版本先后两份归档
|
||||
(其中一份经第三方工具修改过)的 header/GRPI/Body/sTrA/sTrW 五个种子完全相同,
|
||||
因此直接内置于 `internal/pvf/variant.go` 的 `variantKeys()`:
|
||||
|
||||
| 段 | seed | magic |
|
||||
|---|---|---|
|
||||
| Header | `0x4A454634` | `0x269EC3` |
|
||||
| GRPI | `0x1FBB7078` | `0x269EC3` |
|
||||
| Body | `0xDD4FF706` | `0x269EC3` |
|
||||
| sTrA | `0x712A98D4` | `0x269EC9` |
|
||||
| sTrW | `0x712AE776` | `0x269EC9` |
|
||||
|
||||
若遇到种子未知的其它变体,`recoverHeader` 会从数据反解:签名明文固定为 `nkpi`,
|
||||
可限定首个密钥流 dword 的低 16 位,余下 16 位再用「各段长度之和等于文件大小」
|
||||
筛出唯一解;GRPI 用「最后一块累计压缩大小等于 `BodySize`」反解;Body 与
|
||||
sTrA/sTrW 则利用 zlib 头部 `78 xx` 这两个已知明文,并用解压长度必须等于
|
||||
GRPI/NameTable 声明值来确认。
|
||||
|
||||
**HASH 段**:该变体的 HASH 种子未能反解(其内容既非偏移量也非文件表顺序,
|
||||
排序列表也不呈升序,多种已知明文攻击均不成立)。由于该段只参与段偏移计算、
|
||||
代码从不读取其内容,保存时**原样保留原始密文**,不再用标准密钥重写,
|
||||
以免破坏变体客户端的查找表。标准变体仍按原逻辑重建。
|
||||
|
||||
|
||||
## 3. FileTable(明文,每条 24 字节,pack=1)
|
||||
|
||||
```c
|
||||
struct PvfFileItem {
|
||||
int NameOffset; // 字符串池魔数偏移(见 §5)
|
||||
int PathOffset; // 同上,存目录部分
|
||||
int ChunkIndex; // 第几个 Body 分块
|
||||
int DataOffset; // 文件数据在解压后分块内的偏移
|
||||
int DataSize;
|
||||
int DataType; // 1 = 脚本 token 流;3 = 本地化文本(UTF-16)
|
||||
};
|
||||
```
|
||||
|
||||
完整路径 = `PathOffset 解析串 + "/" + NameOffset 解析串`。
|
||||
|
||||
## 4. GRPI(解密后每条 8 字节)
|
||||
|
||||
```c
|
||||
struct GrpiItem { int CompressedSize; int OriginalSize; };
|
||||
```
|
||||
`CompressedSize` 是**累计**值。第 i 块 Body 字节区间 =
|
||||
`[bodyOffset + g[i-1].CompressedSize, bodyOffset + g[i].CompressedSize)`。
|
||||
整块 `"BodY"` 解密 → 标准 zlib 解压(0x78 0x9C 头 + Adler32 尾)。
|
||||
`OriginalSize` 为该块解压后大小。本文件 54.1MB → 583.6MB(10.79 倍)。
|
||||
|
||||
## 5. NameTable(字符串池)
|
||||
|
||||
布局:8 字节保留头 + 两个 section:
|
||||
|
||||
```
|
||||
sTrA: u32 encSize ^ 0xAA74472E ; u32 rawLen ^ encSize ; encSize 字节密文
|
||||
sTrW: u32 encSize ^ 0x9A82F037 ; u32 rawLen ^ encSize ; encSize 字节密文
|
||||
```
|
||||
密文:`Decrypt2(key)` → zlib 解压。sTrA = UTF-8 以 `\0` 分隔;sTrW = UTF-16LE 以 `\0\0` 分隔。
|
||||
本文件:sTrA 63,000,914B;sTrW 7,035,304B。
|
||||
|
||||
**魔数偏移**(NameOffset/PathOffset/token 字符串值通用):
|
||||
- 偶数 → sTrA,字节偏移 = `off >> 1`
|
||||
- 奇数 → sTrW,字节偏移 = `(off >> 1) * 2`
|
||||
|
||||
### 5.1 写入时该放哪个池(新文本必须遵守,否则游戏里乱码)
|
||||
|
||||
客户端**只从 sTrW 读非 ASCII 文本**,所以新输入的中文/韩文必须写进 sTrW(奇偏移)。
|
||||
实测两代归档的既有文本都严格遵循这条:
|
||||
|
||||
| 归档 | ASCII → sTrA(偶) | 非 ASCII → sTrW(奇) | 反例 |
|
||||
|---|---|---|---|
|
||||
| `90CN\Script.pvf`(Protected 变体,105 万文件) | 9,904 条 | 1,141 条 | 0 |
|
||||
| `testdata/110US.pvf`(Paged110) | 0(其 sTrA 为空) | 24,821 条(ASCII 也在 sTrW) | 0 |
|
||||
|
||||
因此写入规则(`StringOffset` / `scriptStringOffset`):
|
||||
|
||||
1. 先按原样查两个池的索引,命中就沿用原偏移;
|
||||
2. 未命中时:**非 ASCII → sTrW**;ASCII → 若归档的 sTrA 非空则进 sTrA,否则也进 sTrW
|
||||
(110US 的 sTrA 为空,该客户端把所有字符串都放在 sTrW)。
|
||||
|
||||
> 旧实现把一切都 append 到 sTrA,于是新输入的中文被写成 UTF-8、用偶偏移引用,
|
||||
> 客户端按自己的约定取不到这段文本 → 游戏里显示乱码。已修,并有回归测试
|
||||
> (`TestStringOffsetPoolConvention`、`TestVariantChineseNameEncoding`)。
|
||||
>
|
||||
> 另外 90CN 的 sTrA 里本来就有 13.7 万个非 ASCII 字节(历史遗留),所以校验方式是
|
||||
> 「新增文本没有让 sTrA 的非 ASCII 字节变多」,而不是「sTrA 纯 ASCII」。
|
||||
|
||||
## 6. DataType 1 脚本格式
|
||||
|
||||
Token 流,每个 token 5 字节:`u8 type + i32 value`。
|
||||
|
||||
| type | 含义 | 反编译表现 |
|
||||
|---|---|---|
|
||||
| 0 | 整数 | 直接输出数值 |
|
||||
| 2 | 浮点(value 按 f32 位型解释) | 输出浮点;整值写成 `8000.0` 以区分于整数 |
|
||||
| 3 | 标签串(字符串池偏移) | 独占一行,如 `[name]` |
|
||||
| 5 | 块串开头 | `{5=\`...\`}` |
|
||||
| 6 | 行内串 | `` `...` `` |
|
||||
| 7 | 块串(另一形式) | `{7=\`...\`}` |
|
||||
| 8 | 字符串池引用(Paged110) | `{8=\`...\`}`,如 `{8=\`<31::equip_name_1>\`}` |
|
||||
| 10 | 字符串池引用(Paged110) | `{10=\`...\`}`,多行命令文本 |
|
||||
|
||||
## 7. DataType 3 本地化文本(.str 等)
|
||||
|
||||
UTF-16LE 明文。本文件存在韩服转制痕迹:内容实为 **EUC-KR 字节被逐字节按 CP437
|
||||
字体映射进 UTF-16 码位**(如 `한국` = `C7 D1` → `╟╤`)。
|
||||
还原:每个字符 `chr.encode('cp437')` 拼回字节流 → `cp949` 解码。
|
||||
`pvflib.py` 已内置自动检测与还原(`_fix_korean_mojibake`)。
|
||||
|
||||
**写回**:编辑器读到的是还原后的可读韩文,保存时按**原载荷是否伪画**决定编码 ——
|
||||
`SetText` 对 TypeUnicode 会在原载荷通过 `looksLikeCP437Painting` 时用
|
||||
`EncodeKoreanMojibake` 重新伪画回写,否则写普通 UTF-16LE。这样「打开→改→保存」
|
||||
不会把可读韩文变成乱码(检测基于整段载荷的前 2000 字符,短载荷可能判不出)。
|
||||
|
||||
## 8. HashTable(供客户端按路径查找)
|
||||
|
||||
解密后:`u32 count` + `count × (u32 NameOffset, u32 PathOffset)` + `u32 n` + `n × u32`
|
||||
(按解析串字典序排序的偏移表,二分用)。长度满足
|
||||
`hashSize = 4 + count*8 + 4 + n*4`。
|
||||
|
||||
密钥名:
|
||||
- 标准 90US(LegacyNkpi):`keySeed("HASH")` + magic `0x269EC3`。
|
||||
- Protected 变体:**`wideSeed("hash")`**(全小写)+ magic `0x269EC3`。
|
||||
- Paged110:密钥名未取到,但**可由结构反解**(见下),110US 解出
|
||||
`seed=0x5824C072`、magic `0x269EC3`。
|
||||
|
||||
**反解**:段首 dword 就是条目数(等于文件数;90US 变体族会多 19 条,故在
|
||||
文件数 ±64 的窗口内搜)。已知这个明文 dword 就钉住了首个 keystream dword 的低
|
||||
16 位,每个 (magic, count) 只剩 ≤4 个候选种子;再用结构验证:长度精确满足
|
||||
`4+count*8+4+n*4`、抽样的每个 NameOffset/PathOffset 都能解析出池内字符串、
|
||||
尾部偏移表按串严格递增。实现见 `internal/pvf/hashtable.go` 的
|
||||
`recoverHashSeed`;解析时若密钥集未给出 HASH 种子会自动尝试。
|
||||
|
||||
## 解析结果摘要
|
||||
|
||||
- 文件 1,008,171 个,分块 8,019 个,全部解压成功,0 引用越界
|
||||
- 扩展名分布:.ani 570,617 / .equ 148,594 / .act 77,955 / .stk 42,219 / .obj 21,974 / .ai 21,534 / .til 20,792 / .atk 18,896 / .key 16,322 / .map 15,921 …
|
||||
- 顶层目录:equipment 408,432 / monster 211,668 / passiveobject 162,065 / map 79,046 / stackable 46,390 / character 40,203 …
|
||||
|
||||
## 9. 分页容器(110US / 115US)
|
||||
|
||||
新客户端在 90US 逻辑布局之上加了一层「页首保护」:
|
||||
|
||||
- **版本与容器分离**:`paged110` 对应 `110US`,`paged115` 对应 `115US`,
|
||||
两者共享 `pagedContainer`、内容规则、解密和写回实现。版本按实际解封成功的
|
||||
RSA 公钥身份识别,并在页头签名和布局校验通过后采用;不是按目录名、页数或
|
||||
扫描回退路径猜测。已登记 115US 2.38.2.34 客户端的公钥指纹。
|
||||
未登记身份使用 `paged`,其 `ClientVersion()` 为空,仅应用未限定版本的标注。
|
||||
`IsPaged()` 用于容器能力判断;旧 `IsPaged110()` 和 JSON `paged110` 字段
|
||||
保留为兼容别名,不表示客户端版本。通用错误使用 `ErrPagedKeys`、
|
||||
`ErrPagedReadOnly`、`ErrPagedStructureLocked`,旧错误标识保留相同 identity。
|
||||
- 每 **10 MiB 页的前 10,240 字节**用该页 **32 字节密钥做 AES-256-CBC(IV=0)**
|
||||
加密;页内其余字节仍是普通 90US 数据。Guards **就地覆盖逻辑流**
|
||||
(物理长度 == 逻辑长度),所以把页首块解密后,整份文件与 §3–§8 的布局一致。
|
||||
- **页密钥表不在归档内**:同目录 `sk.dat`(1920 B)是 RSA-1024 PKCS#1 v1.5
|
||||
密封的密文,私钥内置于客户端 `DFO.exe`(实现里已内置该私钥)。逐 128 字节块
|
||||
解密后得到 **52 × 32 字节**页密钥表(本文件 52 页)。
|
||||
- **新版客户端兼容**:52 页仅是上述 110US 样本,不是解析上限。内置 RSA
|
||||
无法解封或页头验证失败时,扫描同目录 `DFO.exe` 的 PKCS#8 / PKCS#1 PEM
|
||||
候选,用 `sk.dat` 逐块解封筛选,再尝试 metadata key 候选。先验证第一页
|
||||
签名与完整布局方程,通过后才复制并解密整个归档;不依赖固定 VA 地址。
|
||||
115US 2.38.2.34 的客户端参数、metadata 和密封 `sk.dat` 同样以 `PVFC`
|
||||
二进制资源内置,该样本无需外部配套文件即可打开。
|
||||
115US 样本已验证 81 页、路径和分散块读取,以及无修改保存的 SHA256 一致。
|
||||
此处只验证离线读写,不代表客户端实机接受多余页密钥或修改后的归档。
|
||||
- 该表还要再整体做一次 **AES-256-CBC(IV=0)** 解包,密钥是 `DFO.exe` 中一段
|
||||
**64 个十六进制字符**的「metadata key」(本客户端为 `21ad8ff2…4489`,
|
||||
实现里已内置,必要时会改为扫描可执行文件得到候选)。
|
||||
只解包前 `len & ~0xFF` 字节,尾部余数不动。
|
||||
- 分段密钥名改为:**`iNfO`**(头)/ **`Gidx`**(GRPI)/ **`stAs`**/**`stWs`**(名称池)/
|
||||
**`mAIn`**(body),种子用宽字符公式(与 Protected 变体相同,见 §2 的种子变体),
|
||||
名称池长度掩码为 `3886938090` / `3101599660`。
|
||||
- 头部**没有** `0x55` guard。
|
||||
- **写回已支持**(`SaveTo`):打开时保留解包后的页密钥表(`Archive.pageKeys`),
|
||||
写出时按页对前 10,240 字节重新做 AES-256-CBC(IV=0) 加密(`writePageGuarded`
|
||||
流式写,不需要第二份整库内存)。未修改的归档重新写出**逐字节等于原文件**
|
||||
(已用 543 MB 的 `110US.pvf` 的 MD5 验证)。
|
||||
- **增删文件也已支持**:HASH 段的种子可从结构反解(§8),所以重建时能重新生成
|
||||
HASH、重建名称池;Paged110 的池段用**自己的**密钥与长度掩码写回
|
||||
(`buildNameTable` 过去硬编码 90US 的 `sTrA/sTrW` 与掩码,导致重开时池整个读不到,
|
||||
已修)。代价是要重新压缩 401 MB 的名称池(实测约 35 秒/次);只改文件内容的编辑
|
||||
不触发池重建。
|
||||
- 若 `pageKeys` 不在内存(理论上不会),返回 `ErrPagedReadOnly`;若 HASH 种子
|
||||
既没给出也解不出来,结构性编辑返回 `ErrPagedStructureLocked`。
|
||||
### 9.1 字符串表与 `<表号::键名>` 占位符
|
||||
|
||||
Paged110 的 `.equ`/`.stk` 不直接存显示文本,`[name]` 等字段是 type 8/10 token,
|
||||
内容是 `<表号::键名>` 占位符(如 `<31::equip_name_1>`)。解析方式:
|
||||
|
||||
- 映射文件按**优先级递减**(靠前者胜):`list/n_string.lst` → `n_string.lst` →
|
||||
`list/n_string_translate.lst` → `n_string_translate.lst` →
|
||||
`list/n_string_kor.lst` → `n_string_kor.lst`。
|
||||
基准表 `list/n_string.lst` 指向 `String/*.uv.str`,存的是客户端自己显示的语言
|
||||
(110US 里是中文/英文);`*.kor.str` / `*.translate.str` 是语言覆盖层,只映射
|
||||
3、13 两个表号,仅用于补基准表的空缺。**基准表不能排在后面**:那样会在基准表
|
||||
有中文时也显示韩文。
|
||||
`.lst` 是普通 token 流:`整数`(表号) + `字符串`(`.str` 路径) 交替。
|
||||
- `.str` 是 UTF-16LE 文本,每行 `键>值`,`//` 注释;路径缺失时回退到
|
||||
`.translate.str` / `.kor.str` / `.uv.str` 变体。
|
||||
- 查表从基准表往下取**第一个非空**值:基准表用 `键=` 列出所有已知键,其中装备名
|
||||
有 36.7% 是空值(未翻译),空值不能当命中。`ResolveStringTable` 同时回报来源与
|
||||
是否回退。
|
||||
- 解析不到时**原样保留**占位符。解析是只读的:归档里存的就是占位符,
|
||||
脚本文本不会被改写。
|
||||
- 装备悬浮预览(`services/equipment_preview.go`)、文件树标签、搜索结果、编辑器标注
|
||||
四处一致:只由覆盖层答出的名字会追加 `(未翻译)` 标记
|
||||
(`ScriptMetadata.NameFallback` + `services.markedName`),避免把韩文原文误当成
|
||||
该客户端的文案。
|
||||
- 编辑器不下手改文档,而是把译文作为一条 `type = "placeholder"` 的标注标签挂在
|
||||
占位符后面(tooltip 给出原始占位符与来源 `.str`;来源 <8 MB 时 Cmd/Ctrl+单击
|
||||
可打开该字符串表)。搜索/树的名称(`ScriptMetadata`)同样取解析后的文本,
|
||||
因此中文名可搜。
|
||||
- **就地修改译文**:单击标签 → 弹框 → `SetPlaceholderText`。目标表取「真正答出该键
|
||||
的那一张」(可能是覆盖层);没有任何表有该键时**追加到基准表**(插在尾部 NUL 填充
|
||||
之前 —— 读取端在第一个 NUL 处截断,写在填充之后就读不到)。
|
||||
改动按 **UTF-16LE 字节就地替换**那一行的值,BOM/行尾符/其余字节不变;
|
||||
解析不了的表拒绝写入。改完立刻刷新编辑器标注、文件树标签与搜索索引里该文件的
|
||||
名称,并写入版本历史。改动先落在内存 overlay,需保存才写回归档。
|
||||
- **新建文件的名称**:引用了表里还没有的键时,标签显示红色「未定义」,点它即可创建
|
||||
条目;工具栏「字符串引用」会插入 `{8=`<表号::键名>`}` 并写入对应表。
|
||||
注意标记里的 8 是 token 类型,表号在 `<表号::键名>` 中;写完还要把文件注册进
|
||||
`list/equipment.lst`(或 `list/stackable.lst`)才可被客户端按 ID 找到。
|
||||
|
||||
### 9.2 Paged110 的列表布局(`list/*.lst`)
|
||||
|
||||
90US 把列表放在被索引目录旁边、条目按列表目录相对存放;110US 把所有列表集中到
|
||||
`list/` 下、条目改为**归档根相对**:
|
||||
|
||||
| | 90US | 110US |
|
||||
|---|---|---|
|
||||
| 装备列表 | `equipment/equipment.lst` | `list/equipment.lst` |
|
||||
| 条目 | `character/a.equ` | `equipment/character/a.equ` |
|
||||
|
||||
`Archive.FindList` 两种布局都试(配置 90US 路径也能在新归档上解析),
|
||||
`listPathCandidates` 对条目先按列表目录解释、再按归档根解释(各自再试 `(r)` 变体)。
|
||||
`ScriptMetadata` 支持 8/10 token 并对名称做占位符解析,所以搜索索引、文件树标签
|
||||
用的是显示文本而不是 `<表号::键名>`(只由覆盖层答出的还会加 `(未翻译)`)。
|
||||
|
||||
### 9.3 `list/*_indexhash.etc`(列表的伴随索引)
|
||||
|
||||
110US 的每个列表还配一个 `<列表名>_indexhash.etc`(90US 没有)。格式与 `.lst` 相同:
|
||||
`(type 0 整数 id)(type 6 字符串)`,每条 10 字节;第二个 token 的字符串内容是**十进制
|
||||
uint32**(超过 2^31 所以用字符串存)。实测该值**只与 id 有关**(不同列表里同一 id 的
|
||||
值相同,即使路径完全不同)。生成函数为两轮 `uint32` 混合:
|
||||
`x = (x ^ (x >> 16)) * 0x45d9f3b`,最后再执行一次 `x ^ (x >> 16)`。
|
||||
归档中少量历史/特殊条目可能保留其它值;新增普通列表条目使用该函数。
|
||||
索引文件是列表的**严格超集**(原厂工具维护列表时会顺带补齐),因此新增 id 会缺条目。
|
||||
|
||||
110US 还有一个容易踩的兼容点:第二 token 虽然是 ASCII 十进制文本,仍必须引用
|
||||
UTF-16 字符串池 `sTrW`(magic offset 为奇数)。写到 UTF-8 池 `sTrA` 后,解析器
|
||||
仍可能读回正确数字,但客户端读取新增条目时会出错。
|
||||
|
||||
工具:`Archive.IndexHashCompanionPath` / `IndexHashPairs` / `IndexHashGaps` /
|
||||
`IndexHashSiblingPaths` / `IndexHashValue` / `SetIndexHashEntryForID` /
|
||||
`SetIndexHashEntry`(值写成十进制文本,其余条目字节不变)。
|
||||
详见 `docs/PVF新格式分析.md` §2.16。
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# MCP 服务设计
|
||||
|
||||
状态:首版已实现。工具定义以 services/mcp_tools.go 为准,下文保留设计约定与实现边界。
|
||||
|
||||
当前实现:设置页启停及复制连接配置、本机 Streamable HTTP、共享服务实例、异步任务/状态查询、脚本预览应用、归档生命周期、冻结导入及普通/MOD 导出。界面准备屏障覆盖主窗口与脚本窗口,归档写入后同步标签内容和后端基线再释放屏障,避免将已应用变更误判为本地草稿。script_help 的 types 主题嵌入编辑器同源类型声明。连接无需鉴权,本机连接共享任务与计划。已实现标注搜索、详情和实际文件解释,见 [标注文档协议](MCP标注文档协议.md)。
|
||||
|
||||
当前限制:任务/请求去重历史仅保存在进程内,最多 256 条,重新启用清空;导入最多 10000 文件、单文件 8 MiB、总计 64 MiB;导出最多 10000 文件、内容 128 MiB。大导入快照保存在受限内存中,超限拒绝;尚未使用磁盘溢出存储。预览有效期 10 分钟,差异与日志有响应大小限制。生命周期和导出发布阶段不支持取消。
|
||||
|
||||
## 目标与首版范围
|
||||
|
||||
AI 客户端连接已运行的 pvfine,与桌面界面共享当前 PVF,支持打开、关闭、保存、另存为、普通文件/目录导入、普通导出、导出为 MOD,以及搜索、读取和修改归档内脚本。复用现有 `.pvf.js` 执行能力完成复杂查询和批量变更。
|
||||
|
||||
首版通过已运行的桌面应用管理归档生命周期及文件导入导出,AI 可直接传入本机归档路径和来源/目标路径,无需弹出文件选择框。用户已确认导入范围为普通文件和目录,不含 MOD 包导入。首版不提供无界面的独立归档进程、任意磁盘读写或系统命令工具。应用归档变更或导入后,可由 AI 调用保存/另存为,也可通过现有界面保存。
|
||||
|
||||
这里区分三种数据:归档内脚本、用于操作归档的 `.pvf.js` 程序,以及持久化在用户配置中的文件集。接口及结果必须说明变更的落点。
|
||||
|
||||
## 架构与传输
|
||||
|
||||
```text
|
||||
AI MCP 客户端
|
||||
│ Streamable HTTP /mcp
|
||||
▼
|
||||
internal/mcpserver:协议、身份校验、输入输出约束
|
||||
▼
|
||||
services:面向 AI 的应用门面、任务与预览管理
|
||||
├── ArchiveService / EditorService:归档生命周期、搜索和读取
|
||||
├── ScriptService:编译、运行、预览和应用
|
||||
├── 导入门面 / ExportService:文件导入、普通导出及 MOD 导出
|
||||
└── 共享 core:当前归档、版本、Overlay、事件
|
||||
```
|
||||
|
||||
MCP Server 随桌面应用启动和停止,设置中提供启停开关,默认关闭。启用后使用本地 Streamable HTTP 端点,直接注入界面使用的服务实例;尤其必须共享同一个 ScriptService,避免独立实例绕过运行互斥。
|
||||
|
||||
使用官方 `github.com/modelcontextprotocol/go-sdk`。实现时选择与项目 Go 版本兼容的稳定版本,依靠 SDK 处理协议版本兼容。MCP 2026-07-28 与旧版本在生命周期和取消语义上存在差异,应用层统一使用任务 ID 和 context,避免自行固化某版协议细节。
|
||||
|
||||
固定首选端口可在设置中配置;端口占用时明确报错,界面显示实际端点和连接状态。多个应用实例使用不同端口,并通过 workspace 身份避免操作错归档。仅支持 stdio 的客户端可在后续通过轻量桥接进程连接已有服务,首版不增加桥接进程。
|
||||
|
||||
## 首版工具
|
||||
|
||||
workspace_info 与首次打开归档允许在空工作区调用。其余归档相关操作携带 `workspaceId`;读取返回 `revision`,执行、应用与生命周期写操作携带预期 revision。分页读取后 revision 变化时要求重读,避免混合不同版本的数据。生命周期另外使用 workspaceEpoch 防止空工作区检查与打开提交之间的竞争。
|
||||
|
||||
| 工具 | 输入要点 | 输出与语义 |
|
||||
| --- | --- | --- |
|
||||
| `pvf_workspace_info` | 无 | 是否打开、workspaceEpoch、workspaceId、源路径、归档格式、revision、源文件未保存状态、各窗口草稿状态、运行任务、能力版本 |
|
||||
| `pvf_search` | workspaceId、路径/内容模式、query、scope、cursor、limit | 路径、命中摘要、revision、后续游标;限制单页与总响应大小 |
|
||||
| `pvf_read_file` | workspaceId、path、行范围、expectedRevision | 脚本文本、总行数、revision、截断状态;路径使用归档内部路径 |
|
||||
| `pvf_script_help` | topic | 分主题返回 API 类型、说明和示例;不依赖客户端支持 resources |
|
||||
| `pvf_script_validate` | source | 语法诊断;不会验证业务含义或游戏效果 |
|
||||
| `pvf_script_run` | workspaceId、expectedRevision、source、label、requestId | 立即返回 taskId;脚本在隔离事务中执行 |
|
||||
| `pvf_script_status` | taskId、日志游标 | 状态、进度、分页日志、诊断;完成后返回 planId 和变更摘要 |
|
||||
| `pvf_script_preview` | planId、filter、cursor、limit | 分页差异、changeKey、警告及依赖信息 |
|
||||
| `pvf_script_apply` | planId、expectedRevision、changeKeys、requestId | 应用明确选中的变更,返回新 revision 和修改统计 |
|
||||
| `pvf_script_cancel` | taskId | 取消指定任务;不能误取消界面或其他连接发起的任务 |
|
||||
| `pvf_script_discard` | planId | 释放指定预览及其隔离事务 |
|
||||
| `pvf_archive_open` | path、expectedWorkspaceEpoch、当前 workspaceId/revision(如有)、dirtyPolicy、requestId | 打开或切换归档;返回 taskId,完成后获取新 workspaceId、源路径、revision 与初始化状态 |
|
||||
| `pvf_archive_close` | workspaceId、expectedRevision、dirtyPolicy、requestId | 关闭当前归档;返回 taskId,完成后报告空工作区及新 workspaceEpoch |
|
||||
| `pvf_archive_save` | workspaceId、expectedRevision、requestId | 保存到源路径;返回 taskId,完成后报告 writtenPath、源文件未保存状态及文件标识 |
|
||||
| `pvf_archive_save_as` | workspaceId、expectedRevision、path、overwrite(默认 false)、目标文件标识(覆盖时)、requestId | 生成副本并保留源路径;返回 taskId,完成后同时报告 writtenPath、sourcePath 与源文件未保存状态 |
|
||||
| `pvf_task_status` | taskId | 查询生命周期、导入导出任务进度、结果和错误;与脚本任务共用底层任务管理,保留 script_status 的专用视图 |
|
||||
| `pvf_import_prepare` | workspaceId、expectedRevision、sourcePaths、targetDir、mode、requestId | 冻结本地文件内容并校验映射,返回 taskId;完成后取得 importPlanId 与新增/覆盖摘要 |
|
||||
| `pvf_import_preview` | importPlanId、cursor、limit | 分页返回源路径、归档目标路径、类型、大小、覆盖状态及差异 |
|
||||
| `pvf_import_apply` | importPlanId、expectedRevision、selectedKeys、overwriteKeys、requestId | 将明确选择的冻结内容应用到工作区,不保存源 PVF;返回 taskId 和最终 revision |
|
||||
| `pvf_import_discard` | importPlanId | 释放冻结内容与预览 |
|
||||
| `pvf_export_formats` | 无 | 格式 ID、读写能力与支持操作,可写 MOD 格式包括 110USextend 和 115CN-mod |
|
||||
| `pvf_export_prepare` | workspaceId、expectedRevision、source、scopes、mode、format、name、version、modId、includeDependencies、来源附加参数、requestId | 准备普通/MOD 导出快照,115CN-mod 必须提供唯一 modId;返回 taskId,完成后取得 exportPlanId、总数与警告 |
|
||||
| `pvf_export_preview` | exportPlanId、cursor、limit | 分页输出树、entryKey、依赖提示、跳过项与警告 |
|
||||
| `pvf_export_execute` | exportPlanId、outputParent、outputName(direct 必填)、selectedEntryKeys、acknowledgedWarningIds、requestId | 发布普通文件目录或 MOD 目录;返回 taskId,完成后返回实际输出路径和统计 |
|
||||
| `pvf_export_discard` | exportPlanId | 释放导出快照 |
|
||||
|
||||
`pvf_script_run` 同时支持只读查询:脚本用 `pvf.log` 输出查询结果,没有变更时不保留无用预览。大型查询优先使用搜索/读取工具,避免把所有结果堆入日志。首版不新增脚本 return/结果集语言语义。
|
||||
|
||||
通过 `pvf_script_help` 与可选 MCP resources 暴露相同的分主题文档,例如 `pvfine://script-api/sections`。文档应和 `frontend/src/script-api.d.ts`、脚本能力版本一起维护,并提供一致性检查。归档路径和内容是数据,不作为服务器指令执行。
|
||||
|
||||
`.pvf.js` 程序库的列出、读取和保存可后续映射现有 ListScripts/LoadScript/SaveScript;首版通过 source 执行,不默认改写界面中尚未保存的脚本源码。
|
||||
|
||||
## 典型调用流程
|
||||
|
||||
1. AI 查询工作区;需要时调用 archive_open 打开指定路径,获取 workspaceId 和 revision,搜索候选文件并读取样本。
|
||||
2. 按需查询脚本 API,生成 source,验证语法。
|
||||
3. 提交执行任务;服务在接纳任务时记录任务身份和预期 revision。
|
||||
4. AI 查询进度与运行结果,读取差异及警告。
|
||||
5. AI 按明确的 changeKeys 应用结果;revision 已变化则拒绝并要求重新运行。
|
||||
6. UI 刷新受影响文件并显示未保存状态,AI 调用 archive_save/archive_save_as,或用户使用原有保存入口写回 PVF。
|
||||
7. 结束后可调用 archive_close;有未保存内容时按显式 dirtyPolicy 处理。
|
||||
|
||||
导入分支:import_prepare → import_preview → import_apply → 按需保存。导出分支:export_prepare(mode=direct/mod)→ export_preview → export_execute。两种导出都不要求先保存 PVF。
|
||||
|
||||
例如“把某目录价格低于 10 的装备提高到 10”,可直接复用脚本工作区的 `pvf.glob → file.parse → section.set → file.write`。工具描述应提示保留数值类型、显式处理重复 section,并检查字符串表与列表依赖。
|
||||
|
||||
## 归档生命周期
|
||||
|
||||
### 打开与切换
|
||||
|
||||
`pvf_archive_open` 接受本机绝对文件路径,不弹对话框,不自动猜测路径。空工作区使用 workspace_info 返回的 expectedWorkspaceEpoch;已有归档时还必须匹配 workspaceId 和 expectedRevision。同一路径重新打开也视为新工作区,不能绕过未保存检查。
|
||||
|
||||
打开前取得生命周期操作占用权,在候选归档上完成解析和必要索引准备,成功后才替换当前工作区;失败保留原归档、Overlay 和草稿。切换提交前再次校验工作区身份、revision 和草稿状态,防止解析期间 UI 编辑造成覆盖。后台版本恢复和索引任务按工作区代次过滤,不能将旧归档结果装入新工作区。
|
||||
|
||||
成功返回新的 workspaceId、workspaceEpoch、revision,以及 indexStatus/versionStatus。打开完成不等于后台索引和版本恢复完成;未就绪操作返回 NOT_READY,并说明可查询的状态。
|
||||
|
||||
### 未保存内容与关闭策略
|
||||
|
||||
open/close 的 dirtyPolicy 支持 `reject`(默认)、`save`、`discard`,只处理当前归档的已同步 Overlay:
|
||||
|
||||
| 策略 | 当前源文件有未保存变更时的行为 |
|
||||
| --- | --- |
|
||||
| reject | 返回 UNSAVED_CHANGES,保留当前工作区 |
|
||||
| save | 先保存到当前源文件,成功后再切换/关闭;保存失败则保留当前工作区 |
|
||||
| discard | 明确丢弃当前 Overlay 后切换/关闭;仍需匹配身份和 revision |
|
||||
|
||||
这些策略不隐式应用脚本预览,不清除前端未提交草稿。存在前端草稿时统一返回 UI_DRAFT_PENDING,要求在现有编辑器提交或处理草稿后重试。`discard` 不能绕过草稿保护。独立脚本窗口的未保存 `.pvf.js` 源码属于程序草稿,切换/关闭归档应保留,不将其计入归档 Overlay。
|
||||
|
||||
运行中的脚本或已有待处理预览分别返回 TASK_BUSY / PLAN_PENDING,先通过既有工具取消、应用或丢弃,再操作归档;不能通过 close/open 隐式终止其他调用方的任务。生命周期操作互斥,同时阻止 UI/MCP 开始新的归档写入或脚本任务。
|
||||
|
||||
close 成功释放归档资源、索引与版本会话,并按现有语义清理该源路径的自动备份;桌面应用与 MCP Server 继续运行,允许再次打开。workspaceEpoch 更新,旧工作区句柄失效。同一 requestId 的重试返回原关闭结果;新请求引用旧身份返回 STALE_WORKSPACE。
|
||||
|
||||
### 保存
|
||||
|
||||
`pvf_archive_save` 保存后端已提交的当前 revision 到 sourcePath。前端仍有文本或 GUI 草稿时返回 UI_DRAFT_PENDING,不返回遗漏草稿的“保存成功”。没有源路径时返回 SAVE_AS_REQUIRED。保存使用现有原子写入、源文件备份、版本记录和索引更新流程。
|
||||
|
||||
保存前比较磁盘文件与打开或上次成功保存时的文件标识;不一致返回 SOURCE_CHANGED,禁止覆盖外部修改。文件标识包含必要的文件身份和内容校验,不能只依赖 mtime。文件替换前再次检查;对于不遵守应用锁的外部写入,普通文件系统检查不等于跨进程事务,结果与文档需说明这一边界。
|
||||
|
||||
成功返回 writtenPath、sourcePath、savedRevision、当前 revision、源文件未保存状态和更新后的文件标识。必须区分磁盘替换是否完成和后续版本/缓存维护是否完成:若文件已写入但后续维护失败,返回 diskCommitted=true 及告警,不能将其描述为“未保存”并盲目重试。
|
||||
|
||||
### 另存为:只生成副本
|
||||
|
||||
用户已确认:`pvf_archive_save_as` 将当前内容写入指定目标,sourcePath 保持不变,后续 save 仍写原文件,workspaceId 不因副本生成而变化。
|
||||
|
||||
生成副本不代表原文件已保存:保留相对源文件的未保存状态、待同步变更和自动备份,不将当前版本标记为已写回源文件。若当前内容本来就与源文件一致,仍保持无未保存变更。返回 writtenPath、sourcePath、snapshotRevision、sourceDirty,避免客户端将副本路径误认为当前源路径。
|
||||
|
||||
当前 SaveAsDialog 会清理 mutations/自动备份并重置前端 dirty 基线,不能原样复用为“生成副本”的完整语义。应提取不弹对话框的 SaveCopy(path, options),隔离序列化过程对工作区的副作用;UI 另存为也复用该门面并保留源文件未保存状态。
|
||||
|
||||
目标路径必须是绝对路径,父目录须已存在;目标与源文件等价时返回 SAME_AS_SOURCE,要求调用 save。按规范化路径、符号链接和文件身份识别同一文件,覆盖目标不允许是目录。
|
||||
|
||||
默认 overwrite=false,目标已存在返回 TARGET_EXISTS;显式 overwrite=true 时还需提供此前观察到的目标文件标识,并在提交前复核,目标变化返回 TARGET_CHANGED。冲突结果可返回非敏感文件标识供下一次明确覆盖请求使用。不得以“先判断不存在再普通 rename”实现禁止覆盖,须使用平台支持的原子禁止覆盖提交方式。
|
||||
|
||||
### 前端同步与任务边界
|
||||
|
||||
后端增加统一生命周期门面,UI 打开/关闭/保存/另存为与 MCP 共用互斥和状态检查。原 ArchiveService.Close 会直接丢弃修改,必须由门面保护;不能直接暴露为工具。
|
||||
|
||||
前端所有归档编辑视图上报草稿状态与代次;生命周期操作通过带 operationId 的 prepare/ack 检查各窗口并短暂冻结编辑,收到全部有效响应后复核 revision,再进入提交。超时或窗口状态未知返回 UI_NOT_READY,不能按“无草稿”继续。纯前端 dirty 布尔值的延迟上报不足以消除保存竞争。
|
||||
|
||||
打开、保存和另存为可能耗时,使用 taskId 与 task_status 返回阶段进度,避免长时间阻塞单次 MCP 调用。首版不开放生命周期任务取消:文件替换开始后不能假定可撤销;退出/停用服务等待不可中断提交完成,任务始终报告最终磁盘结果。
|
||||
|
||||
沿用 archive:opened/closed/saved,并增加副本生成事件 archive:copy-saved。事件携带 workspaceId、revision、来源、operationId 和 writtenPath;副本事件不能触发清除原文件 dirty 的逻辑。UI 可在重连时查询后端状态,事件不作为唯一事实来源。
|
||||
|
||||
## 导入、导出与 MOD
|
||||
|
||||
### 普通文件与目录导入
|
||||
|
||||
sourcePaths 使用本机绝对路径,targetDir 使用归档内部路径,空串表示归档根。单文件按文件名映射,目录保留被选目录名称和内部层级,沿用现有 collectImportFiles 行为。例如选择 `/tmp/items/equipment`,导入到根后得到 `equipment/...`。多个来源映射到同一路径时拒绝并列明冲突,不依赖遍历顺序决定覆盖。
|
||||
|
||||
mode 支持现有 text 与 raw:text 按现有 UTF-8 校验及归档文本编码规则写入,raw 按原始数据导入;预览报告实际类型。拒绝符号链接来源、特殊文件及非法归档路径;目录遍历设置文件数量、单文件与总字节数上限,沿用项目对 secrets 的禁止读取要求。
|
||||
|
||||
prepare 只生成隔离预览,展示新增/覆盖、目标路径、文本差异或二进制大小/摘要。现有 PreviewImport 不保留内容,ImportFiles 会重新读取磁盘;需提取保留冻结字节和来源摘要的导入计划,apply 消费相同快照,不能重新读取变化后的来源。大快照可使用受控临时存储,按 TTL、丢弃、归档切换及应用退出清理;分页替代现有最多 500 条后截断的预览形式。
|
||||
|
||||
apply 明确传入 selectedKeys;覆盖已有条目还须在 overwriteKeys 中逐项指定,默认不覆盖。先检查全部目标与依赖,再通过隔离 stage 一次提交所选内容;失败不部分写入归档。提交后的索引或版本维护错误需区分 workspaceCommitted,避免重复导入。成功后更新 revision、树、编辑器与版本状态,源 PVF 保持未保存。
|
||||
|
||||
导入不自动删除来源中缺失的归档文件,也不自动注册 `.lst` 或重写字符串表关系;关联修改需显式导入关联文件或使用脚本处理。工具说明需明确文本与 raw 模式的区别。
|
||||
|
||||
导入应用与脚本应用共用写入门面、revision 检查和前端草稿保护。已有运行任务或待处理写入计划时拒绝开始新导入;导入计划与脚本计划均属于待处理写入计划,open/close/save/save_as 的 PLAN_PENDING 检查须同时覆盖二者。
|
||||
|
||||
### 统一导出与来源
|
||||
|
||||
普通导出和导出为 MOD 共享 prepare/preview/execute,通过 mode=direct 或 mod 区分;MOD 格式通过 export_formats 发现。首版来源包含 selection(指定文件/目录,文件集由调用方展开)、memory(未启用版本控制的内存改动)、working(相对 HEAD 的工作区变更)和 commit(指定提交)。来源附加参数包括 commitID;首版不暴露 reverse 或 archive-diff 来源。
|
||||
|
||||
direct 沿用现有文本/二进制导出规则,不宣称所有类型都能无损往返;无法表达的类型须报告,不能静默导出空文件。全量导出可使用 selection 的归档根 scope,仍受文件数量与总字节上限约束。现有 archive-diff 来源在 AI 能发现/创建比较结果后再接入,首版不要求提供无法发现的 diffID。
|
||||
|
||||
导出读取已提交的后端内容及 Overlay,不应用前端草稿、脚本/导入预览,不创建提交,也不保存 PVF。存在归档文本或 GUI 草稿时返回 UI_DRAFT_PENDING。快照记录 workspaceId、revision、版本会话、HEAD 和来源身份;执行时失效返回 STALE_EXPORT,要求重新准备。
|
||||
|
||||
includeDependencies 必须显式声明。预览返回依赖数量、缺失/跳过原因并支持完整分页。selectedEntryKeys 对应预览中的输出文件路径,由服务映射到逻辑内容项,MOD 元数据由服务重建,不能作为普通内容排除。复用现有从冻结快照排除内容的语义,不重新计算或自动补回依赖;最终选择若移除依赖,服务返回依赖警告,执行须显式匹配 acknowledgedWarningIds。
|
||||
|
||||
### MOD 格式边界
|
||||
|
||||
首版复用 internal/mod 注册的 110USextend Writer,输出为 `<outputParent>/<name>-<version>/`,含 pack.json 和所需的 pvf/、merge/,不是压缩成单个 .mod 文件。
|
||||
|
||||
115CN-mod 扩展输出 `<outputParent>/<name>-<version>.zip`,包含根级 mod.json 和 pvf/ 载荷;`export_prepare` 的 `modId` 为稳定唯一 MOD ID,发布同包更新时复用,不能用显示名称替代。仍复用预览、筛选、依赖警告确认和拒绝覆盖的发布流程。MCP 不安装客户端资源,MOD 包导入使用 GUI。
|
||||
|
||||
name、version 复用现有校验,版本省略或留空默认 1.0。普通脚本放入 pvf/;`.lst`、`.str` 按格式写入 merge/。工作区/提交来源有准确基线时输出新增或修改的表项;无基线的整表按现有格式规则跳过并返回警告,不回退为整表覆盖。
|
||||
|
||||
该格式不支持文件和表项删除,返回 skippedDeletes 与明细;纯删除或筛选后无可应用内容时拒绝输出。direct 的删除项输出修改前内容,这属于文件导出语义,不表示生成删除补丁。不能把导出成功描述为支持全部变更或已验证游戏效果。
|
||||
|
||||
跳过、解析失败、依赖不完整项赋予稳定 warningId,execute 需显式回传已查看的 ID;非法路径和无法表达的内容仍硬失败。首版不实现 MOD Reader,也不把 MOD 目录的 pack.json、merge/ 等包装内容当成普通归档资源自动导入。
|
||||
|
||||
### 发布、计划与任务
|
||||
|
||||
outputParent 使用本机绝对路径。首版 direct 显式指定 outputName,在父目录中新建专属输出目录;MOD 使用 name 作为目录名。同名目录存在一律返回 TARGET_EXISTS,不合并或覆盖。两种模式均先写入同父目录下的临时目录,再原子地以禁止覆盖方式发布,失败只清理本次临时目录。需调整现有 writeDirectDirectory 直接逐文件写入目标的入口,避免半成品和覆盖旧文件。
|
||||
|
||||
复用安全路径校验,检查符号链接、大小写及平台文件名冲突。结果返回 outputPath、文件数、依赖数、来源 revision、格式和警告;错误区分是否已经发布。prepare/apply/execute 按 requestId 去重,发布后丢失响应的重试返回同一结果,不能重复生成目录或误报目录已存在。
|
||||
|
||||
当前 ExportService 仅保留一份短期预览,新 Prepare 会替换旧预览。首版保持数量限制,但纳入共享占用与来源身份;UI 与 MCP 不得相互替换导出预览,已有计划返回 EXPORT_PLAN_PENDING。导入、导出和脚本计划使用不同 ID 命名空间,共用 task_status、TTL;本机连接共享计划。
|
||||
|
||||
导出预览存在期间允许编辑,但编辑使计划过期;open/close 需先显式释放导出计划。执行任务期间阻止切换/关闭及冲突写入,提交/发布阶段不开放取消。UI 显示 AI 来源、进度和最终目录,导入后刷新受影响标签页及目录树。退出/停用时等待不可中断提交,清理其余预览与临时存储。
|
||||
|
||||
## 现有实现需要调整的边界
|
||||
|
||||
### 单运行与单预览
|
||||
|
||||
当前 ScriptService 按实例互斥运行,core 只保存一个 scriptPlan;Run 会丢弃旧预览。首版保持单任务、单预览,新增统一的占用状态,覆盖 UI 和 MCP 两个入口:已有待处理预览时返回 PLAN_PENDING,要求先应用或丢弃,不能静默替换。
|
||||
|
||||
预览记录来源与调用方身份。本机 AI 连接共享任务和预览,UI 可以查看、应用或丢弃 AI 预览;若用户在 UI 处理了计划,AI 后续查询得到对应终态。占用检查和任务接纳必须原子执行,不能只在 MCP handler 中检查一次。
|
||||
|
||||
### 工作区一致性
|
||||
|
||||
继续使用现有 archive 身份和 batchRevision 校验,并增加对外不透明 workspaceId;切换或重新打开归档必须更换身份。传入路径在当前身份下解析,避免长期暴露会因增删文件而改变的 index。
|
||||
|
||||
界面和 AI 均可能编辑工作区。执行开始、运行完成和应用提交分别校验身份与 revision,冲突返回 STALE_WORKSPACE 或 STALE_PLAN,不覆盖最新编辑。多文件应用继续通过现有事务和依赖校验完成,不在 MCP 层循环调用 SetText。
|
||||
|
||||
### 应用副作用
|
||||
|
||||
归档修改应用到 Overlay,沿用版本记录与索引失效逻辑。字符串表关联修改必须一起选中或由服务报告缺失依赖,不能静默部分成功。
|
||||
|
||||
现有文件集脚本会在 Apply 时写入用户配置。首版 MCP 明确拒绝包含文件集变更的计划,返回 UNSUPPORTED_CHANGE,避免将其描述为“仅修改未保存的 PVF”。未来开放时应提供独立能力与副作用说明。
|
||||
|
||||
### UI 同步
|
||||
|
||||
目前运行结果和 planId 主要保存在前端 Pinia 中,仅复用后端日志事件不足以让界面接管 MCP 运行。新增后端任务状态查询,以及 started/completed/preview-created/applied/discarded 状态事件,事件携带 taskId、来源和 workspaceId。
|
||||
|
||||
前端打开脚本工作区时先拉取后端状态,事件用于后续增量刷新;AI 生成的源码作为独立任务展示,不能覆盖用户当前未保存的源码。日志按游标和字节数截断,明确返回 hasMore/truncated。
|
||||
|
||||
### 超时、重试与释放
|
||||
|
||||
沿用现有脚本源码 1 MiB、执行 5 分钟和日志 5000 条限制,再为日志单条大小、总字节、响应大小、预览寿命和任务历史设置上限。Goja 的 context 中断不是内存硬隔离,应限制并行数并验证大分配场景;需要严格内存上限时再考虑执行子进程。
|
||||
|
||||
run/apply、open/close/save/save_as,以及导入导出的 prepare/apply/execute 使用调用方范围内的 requestId 去重。同一 ID 和相同参数返回已有结果,相同 ID 配不同参数返回冲突;保存请求摘要及有限期结果,避免网络重试重复应用、保存或发布。去重记录跨工作区切换保留,重试先查去重记录再验证旧工作区身份。应用重启后无法确定的提交结果要求核对磁盘文件,不直接重放。
|
||||
|
||||
取消按 MCP 所属 taskId 校验;MCP 请求返回后后台脚本任务仍运行,显式 script_cancel 工具负责停止。MCP open/close 先要求处理已有脚本任务和预览;应用退出和服务停用时取消可取消任务、等待不可中断生命周期提交并释放预览。连接中断不立即丢弃预览,按可配置的有限 TTL 回收。
|
||||
|
||||
## 本地访问控制
|
||||
|
||||
只绑定 loopback,校验 Host 和 Origin。首版不做鉴权,连接配置只提供服务 URL;本机客户端共享 MCP 任务和计划。关闭服务时停止接受请求。
|
||||
|
||||
不把 EditorService 或整个 Wails API 自动注册为 MCP 工具。所有能力使用明确工具列表和参数 schema;返回业务错误码、简短描述和可执行的恢复建议。
|
||||
|
||||
## 实施顺序与验收
|
||||
|
||||
1. 服务层增加共享任务管理、预览占用和调用方身份,提取归档生命周期门面、SaveCopy、冻结导入计划及无对话框导出接口;保持现有 UI 可用。
|
||||
2. 加入 MCP Server 和工具,复用现有服务;补充身份、revision、workspaceEpoch、分页、去重与资源释放逻辑。
|
||||
3. 在现有设置末尾增加 MCP 配置区,并接通 UI 状态查询与事件。
|
||||
4. 接通各窗口草稿 prepare/ack 与生命周期事件,验证真实 MCP 客户端的发现、打开、搜索、读取、执行、预览、应用、导入、保存/生成副本、普通导出、MOD 导出及关闭全链路。
|
||||
|
||||
针对性测试覆盖:UI/MCP 并发互斥、待处理预览保护、归档切换、编辑冲突、调用方隔离、重复请求、取消与超时、过期回收、字符串表依赖、文件集变更拒绝、分页截断、Host/Origin 拒绝。生命周期另覆盖:空工作区打开竞争、打开失败保留原状态、dirtyPolicy 三种分支、各窗口草稿与响应超时、外部源文件修改、目标覆盖竞争、路径别名、保存失败重试、磁盘成功后维护失败、生成副本保留源路径/dirty/自动备份、切换后的请求去重及旧任务结果隔离。运行 `go test ./...` 和前端构建;真实 PVF 回归按项目惯例通过 PVF_TESTFILE 启用。
|
||||
|
||||
导入导出另覆盖:来源文件在预览后变化、目录映射冲突、部分选择与覆盖许可、源路径与目标路径混用、快照限额与清理、提交后的维护失败、导出计划互斥和过期、文件筛选后的依赖警告、同名目录竞争、输出失败清理、发布后的重复请求,以及 MOD 无基线表、删除跳过、空包和最终元数据。复用现有 import/export/mod 测试,并运行前端 `npm test` 验证选择和预览交互。
|
||||
|
||||
## 官方参考
|
||||
|
||||
标注说明的后续只读扩展见 [MCP 标注文档查询协议](MCP标注文档协议.md),设计了规则搜索、规则详情和实际文件解释三个工具。该扩展当前仅完成协议设计,尚未注册到 MCP 服务。
|
||||
|
||||
- MCP 传输规范:https://modelcontextprotocol.io/specification/2026-07-28/basic/transports
|
||||
- 官方 Go SDK:https://github.com/modelcontextprotocol/go-sdk
|
||||
- 项目现有 MOD 语义:[MOD.md](MOD.md)
|
||||
@@ -0,0 +1,170 @@
|
||||
# MCP 标注文档查询协议
|
||||
|
||||
状态:已实现首版。本协议扩展现有 MCP 服务,提供只读的 PVF 结构说明和实际标注解释。
|
||||
|
||||
## 目标
|
||||
|
||||
AI 在读取或修改文件前,可按文件路径、Section、关键词、标注类型等条件检索说明;遇到枚举值或资源编号时,可继续查看含义及关联对象。
|
||||
|
||||
规则说明和实例结果使用不同工具:规则适用于某种文件,不代表该文件中一定存在对应 Section、Token 或值;标注说明也不等于可执行的格式约束或游戏行为验证。
|
||||
|
||||
协议不修改标注配置,不重新加载磁盘规则,也不执行标注文案中的指令。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 用途 | 是否需要打开归档 |
|
||||
| --- | --- | --- |
|
||||
| `pvf_annotation_search` | 搜索标注规则及共享字段的说明,返回摘要和 documentId | 显式指定版本或 all 时不需要;current 时需要 |
|
||||
| `pvf_annotation_get` | 获取指定文档的完整规则、字段布局、枚举项或关联定义 | 不需要读取归档内容 |
|
||||
| `pvf_annotation_explain` | 解释当前归档内某个文件实际命中的标注、值与引用对象 | 需要 |
|
||||
|
||||
三者均声明为只读工具,不占用脚本运行或写入计划,不要求处理前端草稿,不触发界面冻结。explain 读取后端已提交的工作区内容,明确返回 contentSource=workspace;不包含编辑器尚未提交的草稿。
|
||||
|
||||
## 规则搜索
|
||||
|
||||
`pvf_annotation_search` 参数:
|
||||
|
||||
| 参数 | 语义 |
|
||||
| --- | --- |
|
||||
| query | 可选关键词;在 ID、描述、标题、正文、Section、共享字段 ID、关联名称及枚举键/说明中检索 |
|
||||
| filePath | 可选的具体归档内部路径,作为现有匹配引擎的输入;可以是尚未创建的文件 |
|
||||
| pathKind | file / directory,默认 file;directory 仅查询目录自身的路径说明 |
|
||||
| extensions | 可选扩展名数组,如 [".equ", ".stk"];用于检索声明适用这些扩展名的规则,不要求真实文件存在 |
|
||||
| section | 可选 Section 名,允许一对外围方括号;匹配目标 Section 名,不解析为文件路径 |
|
||||
| kinds | 可选 rule / field,默认两者 |
|
||||
| targetKinds | 可选目标类型列表,名称来自现有 TargetSpec.kind |
|
||||
| annotationTypes | 可选标注类型列表,名称来自现有 AnnotationSpec.type |
|
||||
| relation | 可选关联名称,按名称精确匹配 |
|
||||
| pvfVersion | current(默认)/ all / 显式版本,如 110US、90CN |
|
||||
| workspaceId | pvfVersion=current 时必填,避免从已经切换的归档推断版本 |
|
||||
| status | effective(默认)/ all;all 可查看禁用、被覆盖或版本不适用的定义及原因 |
|
||||
| cursor / limit | 不透明游标,首次不传;limit 默认 20,范围 1–100 |
|
||||
|
||||
不同条件之间取 AND;同一数组内取 OR。query 按空白拆词、忽略大小写,所有词均须命中文档索引;不同词可命中不同字段,中文按子串匹配。首版不开放任意正则或模糊文件 Glob 的交集查询,避免歧义和不受控的匹配开销。
|
||||
|
||||
filePath 使用现有文件匹配逻辑,不自行实现一套 Glob。extensions 是规则索引过滤条件:显式 Extensions 对应精确匹配,不受扩展名限制的规则也可成为候选;仅以复杂 Glob 限制文件的规则,无法凭扩展名证明适用时返回 applicability=unknown,而非推断为匹配。提供 filePath 后,以该路径的实际匹配结果为准。
|
||||
|
||||
不传 section 时,返回路径、Section 和 Token 等全部类别的候选文档,不能沿用现有 RuleService.Query 在空 section 时仅返回路径规则的行为。不传任何内容过滤条件时允许分页浏览;不会展开全部枚举值或扫描当前归档所有文件。
|
||||
|
||||
pvfVersion=current 从指定工作区取得当前 ClientVersion;未打开归档时返回 NO_ARCHIVE,提示显式指定版本或 all。all 表示不按版本排除候选,返回 applicability=unknown,不能把所有定义标为对当前文件生效。未知显式版本返回 INVALID_VERSION,并列出引擎支持的版本。
|
||||
|
||||
status=effective 对显式版本/current 仅返回经覆盖、禁用及版本筛选后仍有效的定义;pvfVersion=all 时仅按覆盖和禁用筛选,并保留每条定义的 pvfVersions,不判断版本适用性。
|
||||
|
||||
返回结构示意:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rulesRevision": "opaque-revision",
|
||||
"context": {
|
||||
"workspaceId": "current-workspace",
|
||||
"pvfVersion": "110US",
|
||||
"filePath": "equipment/example.equ"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"documentId": "opaque-document-id",
|
||||
"kind": "rule",
|
||||
"ruleId": "实际规则ID",
|
||||
"title": "实际标注标题",
|
||||
"summary": "实际规则说明摘要",
|
||||
"section": "price",
|
||||
"targetKind": "section",
|
||||
"annotationType": "实际标注类型",
|
||||
"pvfVersions": [],
|
||||
"effective": true,
|
||||
"applicability": "matched",
|
||||
"matchReasons": ["filePath", "section", "query"],
|
||||
"valueCount": 0,
|
||||
"relation": null
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
}
|
||||
```
|
||||
|
||||
示例中的规则 ID、标题和类型均为占位说明,实际值来自当前生效配置。摘要最多 512 个 Unicode 字符,附 summaryTruncated;不在截断处切开字符。查询命中枚举文案时,可返回少量 matchedValues 摘要,但不能默认返回整个 values 映射。
|
||||
|
||||
默认顺序为关键词相关度降序,再按 kind、规则 ID、来源层稳定排序。完全命中 ID、Section 或标题时优先于正文子串命中;排序不改变引擎执行规则的先后顺序。
|
||||
|
||||
## 文档详情
|
||||
|
||||
`pvf_annotation_get` 接受 documentId、rulesRevision,以及可选的 value、valuesCursor、valuesLimit。
|
||||
|
||||
documentId 是不透明文档身份,在同一规则快照内定位具体定义及来源层;ruleId 保留现有规则身份。共享字段与普通规则使用不同 kind,不能仅用 ruleId 混合寻址。被覆盖的同 ID 定义也须有不同 documentId。引用新的快照时重新搜索,不能把 documentId 当成永久标识。
|
||||
|
||||
返回以下内容:
|
||||
|
||||
- 原始说明:ID、描述、标题、正文、版本范围、来源类别、禁用/覆盖状态及原因。
|
||||
- 原始定义与解析后的有效定义:展开 rule.field 指向的共享字段,保留 fieldId;按照现有引擎的整块继承/覆盖语义解析 Match、Target、Annotation,不能自行做逐字段深合并。
|
||||
- 文件匹配条件与完整 Target:Section、Index、Range、Offset、RecordTokens、GroupOffset、StandaloneValues、TokensPerLineIndex、ContextIndex、图片相关 Token 索引等;0 值与缺省值不能混淆。
|
||||
- 枚举说明:valueCount、分页 values;给定 value 时精确查询该键。键按字符串保留,不能把 "001" 和 "1" 合并。未找到时返回 found=false,不猜测最接近值。
|
||||
- 关联定义:只展开此文档引用的 RelationSpec 及必要的组合关联;返回上下文要求和所需 `.lst` 路径,不枚举整个归档的关联对象。循环引用、缺失定义单独报告。
|
||||
- 可选 PreviewSpec:共享字段的展示标签、用途与格式,用于辅助理解;不将展示格式当成可写入脚本的值类型。
|
||||
|
||||
来源只提供 builtin/user 等可确认的类别和规则标识,不暴露本机配置绝对路径;来源未知时明确标记 unknown。由路径/Section 说明合入的生成规则,只在具备可靠来源元数据时标记 generated。
|
||||
|
||||
valuesLimit 默认 50、最多 100;正文及原始定义超过响应上限时返回明确的截断或大小错误,不能把不完整规则表示成完整定义。原始规则 JSON 和关联定义按需读取,搜索摘要不默认携带它们。原始及解析后的定义省略 values 映射,显式返回 definitionsOmitValues=true;完整枚举通过独立 values 分页取得。
|
||||
|
||||
## 实际文件解释
|
||||
|
||||
`pvf_annotation_explain` 接受 workspaceId、expectedRevision、filePath,以及可选 rulesRevision、section、occurrence、tokenIndex、startLine、endLine、cursor、limit。
|
||||
|
||||
filePath 必须对应当前归档实际存在的脚本文件;与 search 的假想路径查询不同。occurrence 从 0 开始,仅在给定 section 时有效;tokenIndex 是该 Section 的直接 Token 索引,从 0 开始,必须同时给定 section 和 occurrence。行号从 1 开始,首尾均包含;行范围按与标注区域相交筛选。
|
||||
|
||||
不指定 occurrence 时解释全部同名 Section,并分页返回,不能悄悄只解释第一个。Section/token 条件只筛选此范围内的标注;文件和父目录说明通过独立的 pathContext 返回,避免把文件说明伪装成字段的实际命中。
|
||||
|
||||
返回 rulesRevision、workspaceId、revision、textRevision,以及:
|
||||
|
||||
- pathContext:文件自身和祖先目录的说明,逐项带 scopePath、规则 ID 与文档身份。
|
||||
- annotations:实际标题和说明、类型、ruleIds/documentIds、Section 与 occurrence,以及可确定的原始 Token 值。
|
||||
- 定位:range.start/end 为左闭右开的 0 基 UTF-16 偏移,基于与 read_file 一致的文本并沿用编辑器换行规范化;行号和列号均从 1 开始,列按 UTF-16 码元计数。
|
||||
- 枚举/引用解析:valueLabel,或 reference 的 relation、id、context、name、归档内部 path、resolutionStatus。不向 AI 提供会因文件增删而失效的 fileIndex 作为操作身份。
|
||||
- 图片、字符串占位符和自动列表链接等运行时标注:返回结构化含义,标记 origin=runtime;没有配置规则来源时 documentIds 为空,不能虚构规则 ID。
|
||||
|
||||
resolutionStatus 使用 resolved / missing / context_required / unsupported。例如上下文关联无法确定职业时返回 context_required,不从其它职业列表中挑一个同编号结果。未命中枚举、缺失引用、解析警告均保留,不能将解释为空视为该内容无意义或合法。
|
||||
|
||||
动态分组、分隔符、共享字段和嵌套 Section 的定位复用现有解析与标注引擎。recordIndex 等附加信息只在能够准确追溯时返回,不能根据渲染行号猜测记录位置。文本中的数值范围、格式或业务关系只有在规则明文说明时才作为说明返回,不能生成未定义的强制校验结论。
|
||||
|
||||
## 快照与分页一致性
|
||||
|
||||
新增独立 rulesRevision,表示实际激活的标注规则快照,包括共享字段、关联定义、覆盖/禁用状态。成功激活规则时更新;磁盘文件被修改但未成功加载时,查询继续使用原生效快照及旧 revision,不读取一个与当前引擎不一致的配置。
|
||||
|
||||
搜索游标绑定规则 revision、规范化筛选条件、排序和版本上下文;current 查询还绑定 workspaceId。详情与枚举分页绑定同一快照及 documentId。规则切换返回 STALE_RULES,改变筛选条件继续使用旧游标返回 INVALID_CURSOR。
|
||||
|
||||
explain 的游标另外绑定工作区 revision 和 textRevision。textRevision 标识本次解释使用的完整文本,防止渲染重载或换行差异改变定位,而归档内容 revision 尚未变化。归档、内容、规则或文本改变时要求重新解释,不拼接不同快照的结果。
|
||||
|
||||
不返回需要遍历全部文件才能计算的统计;totalMatches 对规则搜索可提供,对实例解释不强制。默认实例页长 20,最多 100,单次响应设定字节上限;明确返回 nextCursor、truncated 和 warnings。
|
||||
|
||||
这些规则 revision 与现有 MCP 的 workspace revision 分开管理:只改脚本内容不应使静态规则文档失效,只改标注说明也不应伪造归档内容改动。
|
||||
|
||||
## AI 使用流程
|
||||
|
||||
1. 获取工作区身份,搜索目标路径和 Section 的说明。
|
||||
2. 阅读少量命中文档;遇到枚举或复杂记录布局时获取详情。
|
||||
3. 对实际文件调用 explain,确认重复 Section、值含义和关联目标。
|
||||
4. 结合 read_file 与脚本 API 文档生成修改,继续使用既有预览/应用流程。
|
||||
|
||||
例如查装备价格字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceId": "从 workspace_info 获取",
|
||||
"pvfVersion": "current",
|
||||
"filePath": "equipment/example.equ",
|
||||
"section": "price",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
这个参数对象用于 pvf_annotation_search;不需要先将整个标注规则库装入模型上下文。无结果时可去掉 section 或改用 query 搜索,服务不自动放宽条件。
|
||||
|
||||
## 落地与验收
|
||||
|
||||
服务层增加 AnnotationDocumentation 查询门面,基于激活时保留的不可变规则快照建立轻量索引;不要让 MCP 工具读取任意磁盘 JSON。search/get 不读取 PVF 内容,explain 在 core 锁下取得一致文本与版本,再复用 editorAnnotationsLocked 的匹配/解析流程。
|
||||
|
||||
现有 RuleService.Query 可复用规则身份和来源模型,但其路径/Section 过滤面向规则编辑器,不能原样作为全文文档搜索。现有 EditorAnnotation 还需要补充文档身份、路径引用、Section 上下文及明确的坐标单位。
|
||||
|
||||
三个工具追加到现有 MCP 工具列表。首版先实现 search/get,再接入 explain;可选资源 URI 或文档订阅以后添加,不能作为客户端使用协议的前提。
|
||||
|
||||
针对性测试覆盖:空条件分页、关键词与组合过滤、假想路径/真实文件区分、目录说明、通用与版本专属规则、禁用/覆盖定义、共享字段覆盖、超大枚举、重复 Section、动态记录及分隔符、上下文关联、缺失引用、自动生成标注来源、中文/emoji/CRLF 定位、规则热重载失败和各类过期游标。
|
||||
@@ -0,0 +1,78 @@
|
||||
# Mod 格式扩展
|
||||
|
||||
## 分层
|
||||
|
||||
- `internal/mod` 定义中立包模型、格式注册表和独立的 `Reader` / `Writer` 接口,不依赖 Wails、归档服务或版本库。
|
||||
- `Package` 保存名称和操作集合。`replace` 携带明确编码的文件内容,`merge-list` / `merge-strings` 携带键值条目,`delete` 表达整文件删除。
|
||||
- `services.ExportService` 将选中文件、相对 HEAD 的工作区改动或提交前后状态适配为包模型;负责依赖补齐、预检、修订校验和目录发布。
|
||||
- 前端四类导出入口只指定来源,不包含格式专属的目录或编码规则。
|
||||
|
||||
## 添加格式
|
||||
|
||||
向注册表注册稳定 ID、显示名称、支持的操作以及读取器或写入器。读取和写入能力根据实际接口计算,不使用空实现冒充支持。
|
||||
|
||||
写入器先通过 `Validate` 检查包是否可表达,`Files` 描述实际输出路径及对应的包条目,再通过 `Write` 写入隔离的临时目录。`Files` 中没有 `EntryPath` 的文件为必需的生成元数据,不可移除。写入器不负责目录选择、覆盖确认或修改归档。
|
||||
|
||||
导入通过 `Reader.Detect(fs.FS)` 识别格式,`Reader.Read(fs.FS)` 返回同一 `Package`,再由服务层执行导入预览和归档暂存事务。外部文件路径不能直接成为写入路径;导入器需要校验包内路径和操作,并复用现有暂存、索引重建及版本变更记录流程。当前已注册 110USextend 和 115CN-mod,现有“导入”弹窗中的“Mod”选项提供入口。实现 `ArchiveWriter` 的格式直接发布单个 ZIP,其余格式保留目录发布。
|
||||
|
||||
## 110USextend
|
||||
|
||||
导出结果是父目录下的 `<mod名称>-<版本号>/`,包含 `pack.json` 和实际用到的 `pvf/`、`merge/`。版本号可填写,默认或留空为 `1.0`,不强制 semver;最多 128 字节,不允许控制字符和首尾空格。优先级 `100`、启用,`types` 记录整文件替换项的 PVF 类型。文本使用 UTF-8,无 BOM。
|
||||
|
||||
普通脚本和文本整份放入 `pvf/<归档路径>`;`.lst`、`.str` 使用 `merge/<归档路径>`。版本导出只包含新增、修改的表条目(新增表以空表为基线);选中文件、文件集或未启用版本控制的内存改动没有准确基线,跳过显式选择的整表,不全量导出。列表按编号匹配,文字表按第一个 `>` 分割并保留空值及文字中的空格;忽略有缩进的 `//` 注释和空行。
|
||||
|
||||
该格式不支持删除,预检会提示跳过的文件或表条目删除。只有删除或没有可应用条目的包不能导出。无法解析的文字表行跳过;无法解析或读取的依赖表、无法准确提取改动的合并表及未能补齐的依赖也跳过,记录文件路径、行号或原因到 `[pvfine:export]` 日志,并在确认弹窗汇总警告。非法目标路径、不能表达的输出内容仍阻止导出,不以改名绕过安全校验。
|
||||
|
||||
依赖补齐默认关闭。开启后,从同一来源状态补齐脚本引用的文字表条目、表号映射及配置列表中匹配的登记项;显式内容优先,重复项去重。历史版本使用该提交快照,不读取当前工作区内容。不补齐任意脚本、sprite、sounds、npk 资源。
|
||||
|
||||
直接导出保留既有行为:版本新增或修改输出后状态,删除输出前状态。导出不会应用编辑器草稿、创建版本或保存 PVF。
|
||||
|
||||
未启用版本控制的“当前内存改动”从归档 overlay 枚举所有现存修改和新增文件;mod 模式跳过无版本基线的 lst/str,直接导出仍保留整文件;已删除文件不在该范围中。
|
||||
|
||||
所有确认弹窗列出完整输出树,勾选文件可控制最终输出范围。排除作用于已冻结的预览,不重新计算依赖,也不会自动补回被排除的依赖文件。已有预览刷新或切换模式时保留相同输出路径的排除,新弹窗重置;选空时不可导出。mod 元数据根据最终保留条目重新生成。
|
||||
|
||||
未选择合并来源时,只改名称或版本号会复用同一有效内容快照,不重新解码文件、计算差异或扫描依赖。快照过期时间不延长,归档、工作区或版本会话变化后不可复用。日志分别记录锁等待、来源读取、差异提取、依赖补齐和总耗时;普通脚本不读取不必要的修改前内容,无文字表引用时不读取表号映射或文字表。
|
||||
|
||||
## 导出时合并已有 MOD
|
||||
|
||||
导出为 mod 时,可在依赖开关后选择一个相同格式的已有 MOD。110USextend 选择目录;115CN-mod 可选择目录、ZIP 或 `.dnfmod`。普通文件同路径冲突时使用本次内容;同类型的 lst/str 合并操作按键合并,同键使用本次值,保留已有 MOD 的其余文件及键。选择已有 MOD 后,自动读取包内名称及版本号;纯数字分段版本递增最后一段(如 `1.0 → 1.1`、`1.2.3 → 1.2.4`),保留末段前导零,其他版本保留原值并提示手动修改。名称和版本号仍可编辑,ID 使用本次输入。输出目录名为 `<mod名称>-<版本号>/`,ZIP 为 `<mod名称>-<版本号>.zip`,不覆盖已有 MOD。版本号留空时包名采用格式默认版本;名称与版本号组合必须是合法文件名。
|
||||
|
||||
已有内容经过读取、合并和格式校验后进入同一预览树,可以取消勾选。每次准备预览都会重新读取合并来源;执行时使用冻结内容,不再读取源 MOD。切换格式或重新打开弹窗会清除来源;取消文件选择保留原来源。115CN 的全部组件合并为基础内容;互斥组件或组件目标冲突会阻止合并。
|
||||
|
||||
当前仅合并 PVF 内容,不复制 NPK/BK2 等客户端资源、包级依赖/冲突和其他包元数据。
|
||||
|
||||
## Mod 导入
|
||||
|
||||
工具栏“导入”中选择“Mod”和导入格式,再选择单个 mod 目录。格式选项由 `ModImportService.Formats()` 从注册表加载,只列出拥有 `Reader` 的格式;服务按显式传入的格式 ID 调用对应的 `Detect` / `Read`,目录与所选格式不匹配时拒绝预览,不回退到其他读取器。后续格式注册读取器后即可出现在导入选项中。110USextend 目录需包含 `pack.json`。当前支持读取本工具导出的 110USextend 包;`pvf/` 中每个替换文件必须在 `pack.json.types` 中声明类型(1 为脚本、3 为文本),声明的文件必须存在。仅处理 `pvf/` 和 `merge/`,不导入 sprite、sounds、npk 等游戏资源,不支持删除操作。
|
||||
|
||||
预览显示所有文件的新增/更新状态、整文件替换或表合并方式;展开表文件显示每个传入键的新增/覆盖状态、新值和原值。整文件替换使用包中声明的类型;`.lst` 按编号合并,`.str` 按第一个 `>` 拆分后按文字键合并,同键覆盖并保留无关条目。文字表保留空值、值中的空格和 `>`,以及当前表中未改动的行和注释。归档中不存在的合并表会新建;已有合并表类型必须匹配。
|
||||
|
||||
读取器拒绝非法路径、大小写路径冲突、文件/目录冲突、符号链接、非普通文件、敏感配置路径、无效编码、重复传入合并键及无法解析的合并行。包内文本必须是无 BOM 的 UTF-8;合并行忽略空行和有缩进的 `//` 注释。现有目标表无法准确解析时阻止导入,避免丢失条目。
|
||||
|
||||
`ModImportService` 在隔离归档上预检并冻结合并后的文件内容,确认时不重新读取目录。预览有效期为 10 分钟;归档、工作区修订或版本会话发生变化后必须重新预览。取消或重新选择会释放旧预览。确认导入复用文件导入暂存事务、索引更新及版本记录,预检或暂存失败不安装部分文件。启用版本控制时记录一组“导入 Mod”变更,可撤销;不会自动提交版本或保存 PVF。编辑器草稿仍由既有归档变更刷新流程处理。
|
||||
|
||||
## 115CN-mod
|
||||
|
||||
格式 ID 为 `115CN-mod`。配置使用 UTF-8 `mod.json`,要求 `schema: 1`、`client: "115CN"`、`kind: "resource"`。读取目录、`.zip`、`.dnfmod`,配置可在根目录或唯一的一层包目录内;也兼容旧 ZIP 的 GB18030 中文包目录。RAR、7z、JAR 不接受。不解压到游戏目录,不执行包内程序或脚本。
|
||||
|
||||
PVF 操作通过 `component` 关联组件,`source` 相对包根目录,`path` 为小写 PVF 路径:
|
||||
|
||||
- `add`:仅新增,目标存在时整批预检失败。
|
||||
- `replace`:整份替换,目标不存在时新增。
|
||||
- `merge`:仅支持 `.lst` 按编号合并、`.str` 按文字键合并。同键覆盖,其他条目保留。
|
||||
- `type`:示例包使用的可选字段,1 为脚本、3 为文本。省略时 `.str` / `.nut` 推断为 3,其他为 1;合并表必须匹配对应类型。
|
||||
- `patch`:提供的文档片段和示例包未包含标签补丁字段协议,当前明确拒绝,不猜测、忽略或降级成整文件替换。需补充登录器 `ModExamples/README.md` 中的完整协议后接入。
|
||||
|
||||
导入预览按 `defaultSelected` 初始化组件,勾选后从冻结的包内容重新预检。`requires` 必须同时选中,`exclusiveGroup` 同组最多选一项;未知组件、循环依赖、所选组件的重复目标及文件/目录冲突拒绝。全不选时禁止执行。切换不延长原预览的 10 分钟期限,源 ZIP 变化不影响已冻结的内容,归档修订变化后必须重新预览。现有 Unicode 文字表的尾部 NUL 终止符原样保留,其他无法解析的行仍阻止合并。
|
||||
|
||||
`files` 中的客户端资源仅校验声明的组件、路径、size 与 sha256 字段格式,并在预览警告中报告;不读取或校验资源正文摘要、不写入 PVF、不安装或备份 NPK/BK2。包级 `requires / conflicts` 提示用户在登录器核对,不模拟 MOD 管理器的安装、排序、关闭或恢复状态。资源安装、已有视频恢复和依赖管理仍由登录器负责。
|
||||
|
||||
导出复用现有来源、精确表差异和依赖补齐,生成 `<名称>.zip`,根目录为 `mod.json` 和 `pvf/`,单个默认组件 `base`。新增独立 MOD ID 输入框:初次生成唯一 ID,发布同一 MOD 的更新需填回原 ID;ID 只能为 1–64 个小写字母、数字、`-`、`_`。名称/版本/ID 更新复用有效内容快照。筛选文件后同步重建清单,不携带被排除的载荷。ZIP 写完后原子发布,拒绝覆盖已有同名包。MCP `export_prepare` 选择此格式时同样必须提供 `modId`。
|
||||
|
||||
ZIP 拒绝路径穿越、大小写路径冲突、符号链接、特殊文件和敏感配置路径;声明文件读取也检查父目录链接。限制 ZIP 最多 20,000 项、单项 64 MiB、总展开大小 512 MiB,清单 4 MiB。目录包同样限制 PVF 单文件和累计读取大小。不会读取未登记的 PVF 载荷。
|
||||
|
||||
## 验证
|
||||
|
||||
运行 `go test ./...`、前端 `npm test` 和 `npm run build`。真实归档的新增导出验证需要设置 `PVF_TESTFILE`;示例包和游戏目录不作为可写测试目标。
|
||||
|
||||
115CN 示例读取回归:设置 `PVF_MOD_TESTFILE` 为 ZIP 路径,运行 `go test ./internal/mod -run '^TestCN115RealMOD$' -v`。真实归档预检另外设置 `PVF_MOD_ARCHIVE` 为测试 PVF,运行 `go test ./services -run '^TestCN115RealMODPreflight$' -v -timeout 15m`。两者均只读源文件,不自动执行或保存 PVF。
|
||||
@@ -0,0 +1,188 @@
|
||||
# PVF 变体架构
|
||||
|
||||
## 分层
|
||||
|
||||
- `internal/pvf/detect.go`:已知标准格式、替代密钥格式、Paged110 解封依次探测,最后尝试未知格式的头部恢复。结果包含逻辑字节、头部、格式规则以及写回所需状态。
|
||||
- `internal/pvf/archive.go`:公共逻辑容器解析,不负责配套文件发现或分页解封。字符串池和 GRPI 无法解析时返回 `ErrInvalidSection`;HASH 无法恢复时保留读取能力并限制结构修改。
|
||||
- `internal/pvf/format.go`:格式标识、容器类型、内容规则和写入能力。格式标识用于诊断,功能分支依据内容规则。
|
||||
- `internal/pvf/save.go`:统一校验、字符串池修复、重建和状态接纳流程;`writeContainer` 仅负责普通写出或分页包装。
|
||||
- `services/list_registration.go`:依据 `RequiresIndexHash` 决定伴随索引维护及 uint32 ID 约束。界面依据内容能力控制注册和字符串引用入口。
|
||||
|
||||
目前标准与替代密钥格式共享内容规则;110US / 115US 使用分页容器,并启用 UTF-16 字符串策略、indexhash 注册和字符串引用入口。100CN 同样使用分页容器与 indexhash、字符串引用,但保留 ASCII / UTF-16 双池。容器与这些内容规则可以独立组合。
|
||||
|
||||
`IsPaged110` 和信息快照中的 `paged110` 保留兼容用途。新业务功能不应依据格式名称判断是否可用。
|
||||
|
||||
## 写入约束
|
||||
|
||||
`WriteCapabilities` 描述归档的基础写入能力。未知 HASH 时,已有内容替换仍可保存,但新增、删除、字符串池扩展或重映射会在保存前拒绝;`CanSave` 不意味着任意待保存修改都被支持。缺少分页写回状态时不可保存。
|
||||
|
||||
HASH 是否已知通过独立状态记录,数值为零的有效 seed 不再代表未知。格式规则及分页状态必须随批处理、脚本工作区暂存克隆一起保留。
|
||||
|
||||
未修改归档保持原有字节往返约定;启用 UTF-16-only 规则的历史异常归档仍会执行已有字符串池修复,这类修复可能改变输出。保存的结构限制也适用于该修复。
|
||||
|
||||
解析错误明确拒绝不可解码的必需段。当前只在打开时验证首个正文分块,其余分块仍按需解压;成功打开不等于完成整个归档的完整性扫描。
|
||||
|
||||
## 新变体接入
|
||||
|
||||
1. 确定差异属于段参数、物理容器还是内容规则。
|
||||
2. 只有段参数变化时添加已知探测器及参数,复用公共解析、编辑和重建。
|
||||
3. 容器变化时扩展解封和 `writeContainer`,保持公共解析接收逻辑字节。
|
||||
4. 内容行为变化时在 `ContentRules` 中明确表达,并集中配置;不要在服务、编辑器、字符串池中散布格式名称判断。
|
||||
5. 克隆必须复制新增的可变写回状态;不可把配套文件依赖带入普通内容编辑。
|
||||
6. 更新生成的前端绑定必须使用 Wails 生成命令。
|
||||
|
||||
未知密钥恢复只支持当前共享布局,不能自动推断未知容器结构或客户端内容规则。新变体仍需要样本确认,不能因头部恢复成功就声明完整支持。没有配套文件时,被封装的未知头部目前仍可能返回 `ErrBadSignature`,尚不能可靠区分所有缺失依赖与不支持格式。
|
||||
|
||||
## 验证
|
||||
|
||||
`format_test.go` 使用合成数据覆盖:
|
||||
|
||||
- 标准与替代格式的有/无 guard 探测、未修改字节一致性、增删后的保存重开。
|
||||
- Paged110 分页包装、解封后的公共解析、暂存编辑、新增及再次保存;使用合成分页状态,不覆盖真实配套文件解封。
|
||||
- 未知 HASH 的内容保存与结构修改拒绝、已知零值 seed。
|
||||
- 克隆格式状态、字符串池策略与容器的独立组合。
|
||||
- 字符串池长度错误的显式拒绝。
|
||||
|
||||
常规验证:`go test ./...`、`cd frontend && npm run build`。
|
||||
|
||||
真实客户端兼容性仍需使用对应变体样本运行 `PVF_TESTFILE=/path/to/Script.pvf go test ./...`,未匹配内置参数的分页版本还需对应配套文件。测试日志和示例不得包含配套文件内容或密钥;授权内置的客户端解封参数仅存于专用资源。
|
||||
|
||||
## 100CN 样本调查
|
||||
|
||||
100CN 样本的页首保护边界同样是每 10 MiB 页的前 10,240 字节。配套国服
|
||||
`DNF.exe` 的 PVF 加载流程明确读取同目录的 `sk.dat`,先解封页表,再解密各页
|
||||
保护区,最后按宽字符公式处理 `iNfO` 头部。程序内的 `sk.dat` 文件名经过
|
||||
字符串混淆,不能以普通字符串搜索未找到它作为不需要配套文件的依据。
|
||||
|
||||
分页解封现在依次尝试 `DFO.exe` 和 `DNF.exe`;前一个程序不存在或不能解封时
|
||||
仍会继续尝试后一个。只在配套文件和归档头部完整校验通过后接受结果,不凭
|
||||
可执行文件名认定客户端版本。100CN 按成功解封候选的公钥身份登记为
|
||||
`paged100cn`,`ClientVersion()` 返回 `100CN`;未登记身份继续使用通用 `paged`。
|
||||
|
||||
合成测试覆盖两种程序名、两种 PEM 编码、错误 `DFO.exe` 与有效 `DNF.exe`
|
||||
同时存在、无修改字节一致,以及内容编辑和新增文件后的保存重开。
|
||||
|
||||
100CN 的 ASCII 池约 145 MB,非 ASCII 文本使用 UTF-16 池。不能对它执行
|
||||
110US 的 ASCII 池迁移,否则未修改保存也会重建整个归档。装备 indexhash
|
||||
的 227,634 条原始记录均使用 ASCII 池数字引用,数值符合现有哈希算法;
|
||||
更新和新增记录沿用双池策略,现有 ASCII 引用不再被误判为需要修复。
|
||||
|
||||
真实样本验证通过:2,522,676 个文件、30,402 个分块全部解压和数据范围检查,
|
||||
未修改输出 SHA-256 与源文件一致,编辑、新增、删除后保存重开,重建 HASH
|
||||
覆盖当前文件表,未编辑载荷抽样保持一致,并验证新增装备的 LST/indexhash
|
||||
注册。验证范围为离线读写,未进行游戏客户端实机加载。
|
||||
|
||||
真实测试可通过 `PVF_TEST_SIDECAR_DIR` 指定配套目录,避免移动源文件或将
|
||||
配套文件放进仓库。`PVF_TEST_FULL_SCAN=1` 启用全分块及文件数据范围检查:
|
||||
|
||||
```sh
|
||||
PVF_TESTFILE=/path/to/100CN.pvf \
|
||||
PVF_TEST_SIDECAR_DIR=/path/to/client \
|
||||
PVF_TEST_CLIENT_VERSION=100CN PVF_TEST_FULL_SCAN=1 \
|
||||
go test ./internal/pvf -run '^TestRealPagedClient(Compatibility|EditRoundTrip)$' -count=1
|
||||
```
|
||||
|
||||
110US、100CN 和 115US 的完整解封数据统一放在 `internal/pvf/assets/clients/`,
|
||||
各用一个 `go:embed` 二进制资源,包含 PKCS#8 客户端参数、metadata 和加密
|
||||
`sk.dat`。格式为 `PVFC` 签名、uint32 版本号(当前为 1)、uint32 DER 长度、
|
||||
uint32 密封表长度、DER、32 字节 metadata、密封表;整数均为小端。
|
||||
110US 原先的源码参数常量、独立 `sk.dat` 和专用加载函数已移除。
|
||||
|
||||
`paged_embed.go` 使用共用解码器校验版本、精确长度、公钥身份与密封表,
|
||||
统一注册三种客户端,并以 `sync.Once` 缓存校验后的客户端和 RSA 解封结果。
|
||||
身份识别只比较公钥指纹,不再依赖私钥加载。参数不会出现在诊断错误或测试
|
||||
输出中;每次打开仍需校验归档签名与完整布局,不能仅凭资源身份采用内容规则。
|
||||
|
||||
打开时先依次尝试完整的内置 110US、100CN、115US 参数,无需读取同目录文件;
|
||||
内置参数不匹配才查找归档目录中的 `sk.dat`,并尝试 `DFO.exe` / `DNF.exe`
|
||||
内的候选。外部 `sk.dat` 仅作为兜底表,不会阻止已匹配的内置客户端打开。
|
||||
后端也可调用 `OpenWithSidecars` 指定客户端目录。所有候选失败后,错误提示
|
||||
用户将该 PVF 对应版本的 `sk.dat` 和客户端程序与归档放在同一目录后重试。
|
||||
|
||||
真实测试额外覆盖完全没有外部配套文件的打开、从内存 `Parse` 打开、末尾
|
||||
分块读取,以及错误外部文件不覆盖有效内置参数。合成测试覆盖内置参数不
|
||||
匹配时的外部客户端兜底和缺少配套文件的中文提示。
|
||||
|
||||
115US 样本的内置数据包含 81 页的解封参数;已验证独立打开、抽样读取、
|
||||
未修改保存的字节一致性,以及编辑、新增、删除和 LST/indexhash 注册后重开。
|
||||
默认压缩重建字符串池可能让归档超过页表容量,保存时会在超限情况下尝试
|
||||
更高压缩率的字符串池,并保持字符串偏移与未编辑分块不变。仍然超限时,
|
||||
返回 `ErrPagedCapacity` 和所需/可用页数,在写出前拒绝保存;`SaveAs` 保留
|
||||
原目标文件及待保存修改,不扩展客户端配套页表。
|
||||
|
||||
## TW 独立目录格式
|
||||
|
||||
TW 的格式标识为 `tw`,客户端代号为 `TW`。它使用长度前缀 UUID、版本号、
|
||||
目录长度/校验值/文件数,以及变长目录记录;目录和每个文件独立进行
|
||||
CRC 派生的 XOR 与 32 位旋转加解密,没有 S4A21 的 HASH、字符串双池和
|
||||
压缩正文分组。`tw.go` 将它适配为公共 Archive、字符串池和脚本表示,
|
||||
服务层可继续使用现有目录树、搜索、编辑、批处理和字符串引用接口。
|
||||
|
||||
目录路径沿用参考读取器的 CP949 编码;游戏字符串表、`.str` 文本和 `.nut`
|
||||
源码按 TW
|
||||
配置使用 Big5。脚本带 `B0 D0` 前缀,原生 token 编号在读写边界转换;
|
||||
`9 + 10` 字符串引用对适配为公共 `<index::key>` 引用。打开时校验目录和
|
||||
非空文件 CRC、字符串索引及脚本尾部对齐,并建立不可变引用映射。
|
||||
|
||||
未修改保存逐字节保留原包;修改保存复制未编辑文件的密文及已有路径字节,
|
||||
保留 UUID、版本和原有文件编号,向 `stringtable.bin` 追加新字符串,已有
|
||||
字符串编号不变。新增、删除和 `.str` / `.nut` 编辑均写回 TW 格式。字符串表由编辑器
|
||||
维护,不支持直接替换;Big5 无法表示的新字符会在写出前报错。
|
||||
|
||||
`tw_test.go` 覆盖合成归档的繁体文本、引用、二进制文件、增删、克隆、保存
|
||||
重开和损坏拒绝。真实样本及服务层验证使用独立变量,避免其他格式专用测试
|
||||
对 TW 产生不适用的假设:
|
||||
|
||||
```sh
|
||||
PVF_TW_TESTFILE="$PWD/testdata/TW.pvf" \
|
||||
go test ./internal/pvf ./services -run '^TestTW' -count=1
|
||||
```
|
||||
|
||||
真实样本含 951,559 个文件、1,137,629 个字符串;验证范围为离线读写,
|
||||
未进行游戏客户端实机加载。
|
||||
|
||||
### 基础掉率的版本兼容
|
||||
|
||||
“基础掉率”工具支持 90US、90CN、TW、110US 和 100CN。五种版本使用
|
||||
相同的四个掉率文件及 `[basis of rarity dicision]`;TW 每组 6 个值,
|
||||
90 系列常见每组 7 个值,100CN 每组 8 个值,110US 每组 9 个值。
|
||||
读写均按各文件的 section 总长度验证分组宽度,只接受完整的 6/7/8/9 值分组。
|
||||
调整概率只更新前四个累计阈值,保留地狱文件前置值、终止阈值和附加字段。
|
||||
|
||||
真实 TW 的四类掉率读取、调整、TW 格式保存重开和附加字段保留已验证。
|
||||
可使用 `PVF_TW_TESTFILE` 运行 `TestDropRateServiceTWRealRoundTrip`。
|
||||
|
||||
110US 样本的翻牌、小怪、精英段长度分别为 9、36、36,地狱段为
|
||||
1 个前置值加 2 组 9 个值,共 19 个值。100CN 对应长度为 8、32、32、17。
|
||||
本次仍编辑五档基础概率,后续新增阈值不重新解释;前五档终止阈值不在
|
||||
既有兼容范围内时继续拒绝,避免把其他档位的份额归入现有五档。
|
||||
`item drop rarity control`、其他 section 和其他组的内容均原样保留。
|
||||
|
||||
真实高版本验证包括四个文件的读取、第一组概率调整、逐 token 确认仅四个
|
||||
累计阈值变化,以及分页格式保存重开后的完整载荷一致性。仍保留 90US、
|
||||
90CN 和 TW 的原有支持,未验证的 115US 和通用分页格式暂不开放。
|
||||
|
||||
```sh
|
||||
PVF_TESTFILE="$PWD/testdata/110US.pvf" \
|
||||
go test ./services -run '^TestDropRateServicePagedRealRoundTrip$' -count=1
|
||||
PVF_TESTFILE="$PWD/testdata/100CN.pvf" \
|
||||
go test ./services -run '^TestDropRateServicePagedRealRoundTrip$' -count=1
|
||||
```
|
||||
|
||||
### 掉率字段按版本换行
|
||||
|
||||
`config/rendering.json` 中现有的 `section.basis of rarity dicision` 和
|
||||
`section.basis of rarity dicision.hell` 增加 `format.tokensPerLineByVersion`:
|
||||
90US/90CN 为 7、TW 为 6、100CN 为 8、110US 为 9。深渊规则继续使用
|
||||
`offset: 1`,首个数量值独占一行。未配置或未知版本保留 `tokensPerLine: 7`
|
||||
的回退行为,不推测尚未验证的 115US 分组宽度。
|
||||
|
||||
`tokensPerLineByVersion` 是可选的版本→正整数行宽映射;文件级和 section
|
||||
级规则均可使用。解析时先选择现有优先级规则,再按 `ClientVersion()` 取得
|
||||
行宽;如另有 `tokensPerLineIndex`,token 中的动态宽度仍优先。旧规则无需
|
||||
迁移,规则管理界面可通过 JSON 字段调整此映射。
|
||||
|
||||
五个版本的真实样本已检查四类掉率的行数和每行宽度;合成 token 测试覆盖
|
||||
版本映射、未知版本回退、深渊首值单行、普通段分组及原始 token 字节往返。
|
||||
仓库规则修改后,运行中的应用仍需重新加载渲染规则;生产应用须包含更新后
|
||||
的内置配置,不会自动读取当前仓库的 JSON。
|
||||
Loaded 100 of 4388 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user