Author SHA1 Message Date
shenleiandClaude Opus 5.5 84404cf1f1 Model preview: AVA2 render-target window on a Vulkan colour target
Port of AVA2's CRenderTarget / GrpRenderTargetTexture / renderTarget module.
The host gains an RGBA8 offscreen colour pass for D3D colour render targets;
mt_preview shows wearable items on every allowed race and monsters with
motions, reached by X+click in the inventory and shop and from the wiki.
Player races are created as TYPE_PC so their parts exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:48:07 +09:00
shenleiandClaude Opus 5.5 32372c2a73 vip-card: back up the proto rows as REPLACE statements only
A plain mysqldump --where also writes DROP/CREATE TABLE, so restoring the
backup would have emptied item_proto down to the two cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:40:50 +09:00
shenleiandClaude Opus 5.5 5dcac9cbd4 Add VIP cards and the VIP panel (mt_vip)
Cards 38001 (7 days) and 38002 (30 days) extend all seven original premium
services of the account; the server side is metin2-server port_premium.h
(docs/FEATURE-VIP.md).

- mt_vip: /mt_vip 1 status, VIP button by the minimap, remaining time per
  service, chat line on card use.
- tools/server/vip-card: 40250 ships both cards with size 0 (two share a
  cell, neither moves); give them one cell, stacking, a log and no NPC sale,
  and name them on the server.
- locale_proto_patch: optional item shape table (size, flags, anti flags)
  so the client proto matches; data/zh_40250/item_shapes_server.tsv.
- Item mall (9010): 7 days 5,000,000, 30 days 18,000,000 gold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:13:15 +09:00
shenleiandClaude Opus 5.5 b14fb2ffe9 Record the batch chest production deploy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:47:50 +09:00
shenleiandClaude Opus 5.5 b8363693f1 Add batch gift box opening window (mt_chest)
Ctrl + right click on an ITEM_GIFTBOX opens a count window (1/10/50/all,
max 200) that sends /mt_chest and sums the MTCH1 gains into chat. Without
Ctrl or against a server without the command the original use runs.
Exports item.ITEM_TYPE_GIFTBOX. Verified live on the 203 channel5 core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:31:03 +09:00
shenleiandClaude Opus 5.5 10f8c9760e Record the attribute lock production deploy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:11:31 +09:00
shenleiandClaude Opus 5.5 a22c064587 Add attribute lock buttons to the switchbot window
Five per-slot toggles next to start/stop, shown only when the server
sends the MTSB1 lock line; locked attributes are marked in the current
attribute line. Server side: metin2-server docs/FEATURE-ATTR-LOCK.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:06:00 +09:00
45 changed files with 2526 additions and 50 deletions
+7
View File
@@ -54,6 +54,13 @@
服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级),
由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。
## 物品占格与标志(item_shapes_server.tsv)
服务端给了新用途、改过占格或标志的物品(每行 VNUM、size、flags、anti flags,数值),由 `locale_proto_patch`
写进客户端 `item_proto`,背包占格和堆叠与服务端一致;只允许这几条记录的这三个字段变化。目前是 VIP 会员卡
38001/38002(`tools/server/vip-card`):40250 原为 size 0,背包里不显示也不能移动。
该表跟在 `item_stones_server.tsv` 后面传入(位置参数),所以需要石头表也存在。
## 物品说明(itemdesc_translate_zh.tsv)
40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译,
+2 -2
View File
@@ -685,8 +685,8 @@ VNUM LOCALE_NAME
31092 超冷怪的皮肤碎片
35000 药草+
35001 虎肝+
38001 VIP特权
38002 抽奖券(今日)
38001 VIP会员卡(7天)
38002 VIP会员卡(30天)
38003 抽奖券(明日)
38004 抽奖重抽券
38005 高级抽奖转盘券
+6
View File
@@ -0,0 +1,6 @@
# vnum size flags anti flags: item_proto.txt size/flags changed on our server (tools/server/*), mirrored into the
# client proto by locale_proto_patch so the item takes a cell and stacks like on the server.
# 38001/38002 VIP 会员卡 (tools/server/vip-card): 40250 ships them size 0, no flags; now size 1,
# ITEM_STACKABLE | ITEM_LOG (4 | 8192), ANTI_SELL (256).
38001 1 8196 256
38002 1 8196 256
1 # vnum size flags anti flags: item_proto.txt size/flags changed on our server (tools/server/*), mirrored into the
2 # client proto by locale_proto_patch so the item takes a cell and stacks like on the server.
3 # 38001/38002 VIP 会员卡 (tools/server/vip-card): 40250 ships them size 0, no flags; now size 1,
4 # ITEM_STACKABLE | ITEM_LOG (4 | 8192), ANTI_SELL (256).
5 38001 1 8196 256
6 38002 1 8196 256
+45
View File
@@ -0,0 +1,45 @@
# AVA2 批量开箱适配进度(v1)
2026-10-15。修改了 metin2-client 和 metin2-server,AVA2 代码只作参考(`ENABLE_MULTI_CHEST`:`uiinventory.py` 的 Ctrl + 右键、`uichestdropinfo.py`、服务端 `do_chest_drop` / `CHARACTER::OpenChest`)。服务端规则和协议见 `metin2-server/docs/FEATURE-CHEST.md`。
## 与 AVA2 的差异
| 项目 | AVA2 | 本实现 |
| --- | --- | --- |
| 触发 | Ctrl + 右键打开带滑块的窗口;Ctrl + Z + 右键直接开整叠 | Ctrl + 右键打开数量窗口(1 / 10 / 50 / 全部),点“开启”;没有直接开整叠的组合键 |
| 命令 | `/chest_drop WINDOW CELL COUNT` | `/mt_chest 1 open CELL COUNT`,不新增包头 |
| 开箱过程 | 重写的 `GiveItemFromSpecialItemGroupNew` / `AutoGiveItemChest`,单次最多 1000 | 每个礼盒都调用原 `UseItem` 礼盒路径,检查、奖励组抽取、金币/经验、日志、扣除都不变;单次最多 200 |
| 范围 | 一叠 | 同一种礼盒,先开选中的格子,再开背包里其他格子(203 上多数礼盒不叠加) |
| 背包满 | 由 AutoGiveItemChest 处理 | 每开一个前要求至少 3 格空位,不足就停止,奖励不会掉在地上 |
| 不允许批量 | — | 奖励组含刷怪、怪物组、减速、中毒、吸血的礼盒,以及非 ITEM_GIFTBOX 的物品(例如带任务脚本的宝箱) |
## 客户端
- 模块 `mt_chest`(`src/platform/ScriptLib/ChestScript.inc`),在 `PythonBoot.cpp` 中安装。替换了 `uiInventory.InventoryWindow.UseItemSlot`:按住 Ctrl 右键 ITEM_GIFTBOX 时打开窗口,其他情况调用原方法。原方法本身不使用 Ctrl(联机时检查过它的 `co_names`)。
- `PythonItemModule.cpp` 新导出 `item.ITEM_TYPE_GIFTBOX`。
- 窗口显示礼盒名称、背包内总数、数量按钮(`【】` 表示已选;“全部”最多 200)、开启按钮和上次结果。选中格子里的礼盒被移走时,自动改用背包里同一种礼盒的其他格子。
- 等待回复时,以及两次发送间隔不足 0.5 秒时,“开启”按钮不可用。
- 结果:聊天框一行“开启 N 个,金币 +N(停止原因)”,之后每行最多 5 种获得的物品(名称 ×数量)。单个礼盒的提示由服务端暂存,不再逐条刷屏;被原路径拒绝时转发一次拒绝提示。
- 3 秒没有回复:在聊天框提示一次“服务器尚未启用批量开启”,之后 Ctrl + 右键也走原来的单个使用;再收到回复会自动恢复。
- 经验不在汇总里(服务端只汇报背包物品和金币变化)。
## 证据
- `tests/wiki/test_chest.py`(CTest:`port.chest`)共 6 项通过,覆盖:
- 发送一次、等待回复、获得行累积到 done、数量上限 200、负的金币差、发送间隔;
- error 解析,畸形行和其他前缀;
- 超时后标记不支持,迟到的回复会恢复;
- 聊天汇总行(停止原因、金币、每行 5 种);
- 钩子:不按 Ctrl 和非礼盒走原方法,Ctrl + 右键礼盒打开窗口,“全部”,选中格子消失后改用其他格子,命令拦截,超时后回退,窗口关闭;
- 与服务端 `port_chest.h` 的常量、错误码和停止原因一致。
- `tests/wiki` 全部 57 项通过(1 项跳过)。
- 2026-10-15 在 203 channel5 测试核心(13040,独立二进制)用 macOS 客户端以 [SA]Admin 联机验收,礼盒用 `/item` 生成:
- 50037(可叠加,30 个)开 10 个:`done 50037 10 10 done 0`,获得 6 种物品,背包差值与 `MTCH1 got` 完全一致;
- 50070(不叠加,4 个分在 4 格)开全部:`done 50070 4 4 done 100000`,跨格子全部开启,金币 +100000 与余额变化一致;
- 71036(奖励组刷怪):`error kind`,没有扣除;
- 连发两条命令:第二条 `error rate`;
- 50037 剩余 20 个开全部:`done 50037 20 20 done 0`,获得 11 种;
- 不按 Ctrl 右键:原单个使用,礼盒正常扣除;
- 日志库只有原路径写的 `SYSTEM`、`TREASURE_EXP`、`TREASURE_GOLD` 行;syserr 没有新错误(只有原有的 `new_quest_lv52` LUA_ERROR)。
- 2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1(13000)用同一场景复验通过(50037 开 10 个 / 剩余 20 个、50070 跨 4 格、71036 拒绝、限速、普通右键不变)。
- 未做 Android 真机测试;触屏没有 Ctrl,暂无触屏入口。
+4 -1
View File
@@ -115,7 +115,7 @@
2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现(2026-10-16:模型预览已实现,见 FEATURE-MODEL-PREVIEW.md)。见 AVA2-DUNGEON-CALENDAR.md。
2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。
@@ -125,3 +125,6 @@
2026-10-15 换线(计划外追加,参考 AVA2 ENABLE_CHANNEL_CHANGE):仅客户端,按原流程 /logout → 登录目标频道(serverInfo 频道表 + 保存的账号)→ isAutoSelect 进入同一角色,不做服务端传送;macOS 客户端已在 203 正式频道 CH1↔CH2 联机验收,未做 Android 真机测试。见 AVA2-CHANNEL.md。
2026-10-15 世界 Boss 追踪(AVA2 uitrack 世界 Boss 页,只读,不含传送):服务端各核心用原 HEADER_GG_RELAY(名字 #mt_boss)共享定时 Boss 刷新点,旧核心忽略;客户端 mt_boss 窗口 + 刷新提醒,查询种类 bosses(能力位 256);已在 203 两个 channel5 测试核心上做跨核心联机验收,正式核心尚未部署。见 AVA2-BOSS.md。
2026-10-15 属性锁定(阶段 6 后半,参考 AVA2 ENABLE_LOCK_ATTR):服务端新表 mt_item_attr_lock(按物品 ID 存锁定掩码,各核心启动时自动建表;不改动物品结构、db 缓存和客户端封包),CItem::ChangeAttribute 保留锁定槽位,手动、自动、GM 洗练全部生效;精炼时锁定随属性复制;最多 2 组,加成武器的 0/1 槽算一组;锁定免费。客户端在自动洗练窗口里加 5 个锁定按钮。已在 203 channel5 测试核心联机验收(洗 10 次锁定的 2 条不变,重新登录后仍在);2026-10-15 16:08 部署到 203 正式环境(服务端 dc71593),CH1 复验通过;未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.md。
2026-10-15 批量开箱(阶段 3 的交互子批,参考 AVA2 ENABLE_MULTI_CHEST /chest_drop):服务端 mt_chest 对同一种礼盒(ITEM_GIFTBOX)逐个调用原 UseItem 开箱路径,可跨格子(203 上多数礼盒不叠加),单次最多 200 个,背包少于 3 格空位即停止;奖励组含刷怪/减速/中毒/吸血的礼盒不允许批量。客户端 Ctrl + 右键礼盒打开数量窗口(1/10/50/全部),结果汇总到聊天框。已在 203 channel5 测试核心联机验收;2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1 复验通过;未做 Android 真机测试和触屏入口。见 AVA2-CHEST.md 和服务端 FEATURE-CHEST.md。
+20 -3
View File
@@ -1,4 +1,4 @@
# AVA2 自动洗练适配进度(v1,不含属性锁定)
# AVA2 自动洗练适配进度(v1 + 属性锁定)
2026-10-15。修改了 metin2-client 和 metin2-server,AVA2 代码只作参考。服务端规则和协议见 `metin2-server/docs/FEATURE-SWITCHBOT.md`。
@@ -23,9 +23,26 @@
- 命令间隔至少 0.3 秒,同一时间只有一个请求在等待回复。4 秒没有回复就显示“服务器尚未启用自动洗练”。`attrs` 只接受完整、连续的回复,不完整的整批丢弃。
- 文字都用 GBK 可编码的字符(不用 ◀/▶/⚠)。
## 属性锁定(2026-10-15)
- 服务端规则、存储和协议见 `metin2-server/docs/FEATURE-ATTR-LOCK.md`。锁定的槽位在所有洗练方式下都保持不变:手动用道具、自动洗练、GM 命令。
- 窗口底部“开始/停止”右边有 5 个按钮,每条有属性的普通槽一个:`锁N` 表示未锁,`【锁N】` 表示已锁。点击切换。选属性时和洗练中不能点。
- 当前属性行里,已锁的属性前面加 `[锁]`。状态行提示“已锁定的属性在每次洗练中保持不变”。
- 服务端在 `attrs_end` 之后下发 `MTSB1 lock CELL VNUM MASK`。旧服务端不发这一行,所以不显示按钮。
- 错误提示:`slot`(该条属性不能锁定),`max`(最多锁定 2 条,且至少留 1 条可洗),`db`(保存失败)。
- 与 AVA2 的差异:
- 锁定免费。AVA2 每次锁定或解锁消耗 50348/50349,203 的 item_proto 里没有这两个道具。
- 锁定状态存在服务端独立表里,不占属性槽。
- 不要求物品正好有 5 条属性,但至少要留 1 条不锁。
- 联机测试(channel5 13040,盔甲 11209,背包 19 格):
- 锁第 2、4 条后,自动洗 10 次,这两条每次都不变,其余 3 条每次都变;
- 锁第 3 组时提示 max;
- 重新登录后锁定状态仍在;
- 解锁后表里的行被删除。
## 证据
- `tests/wiki/test_switchbot.py`(CTest:`port.switchbot`)共 6 项通过,覆盖:
- `tests/wiki/test_switchbot.py`(CTest:`port.switchbot`)共 7 项通过(第 7 项是锁定协议和按钮),覆盖:
- 请求节流和合并;
- 不完整的 attrs 回复被拒绝;
- start 命令的拼装和校验;
@@ -54,4 +71,4 @@
- Android 真机测试。
- macOS 构建需要 `-DMT_40250_SOURCE=<含 EterPack/EterPack.cpp 的源码目录>` 来生成 pack 密钥,否则 system.py 读不出来(RunMain Error)。本机没有 40250 源码,这次用的是 AVA2 的 `TEST/3. Source BIN/Client`,两者密钥相同。
- 服务端还没有部署到 203 正式环境。
- 属性锁定(阶段 6 后半):需要单独的存储设计。
- 属性锁定已完成(见上),2026-10-15 16:08 已部署到 203 正式环境(服务端 dc71593),CH1 联机验收通过。
+81
View File
@@ -0,0 +1,81 @@
# Model preview
Ported from AVA2's render-target window (`EterLib/CRenderTarget*`, `GrpRenderTargetTexture.*`, `UserInterface/CPythonRenderTargetModule.cpp`, `root/uirendertarget.py`). The preview shows:
- a wearable item on a character of every race that may wear it;
- a monster, with its motions.
It is client only; the server is not involved.
## Entry points
| Where | How |
|---|---|
| Inventory | hold X and left-click the item |
| NPC shop | hold X and left-click the item |
| Wiki detail | the "模型预览" line, on weapon / armor / costume rows that have a model, and on every monster |
Without X the original click path runs unchanged. The inventory wrapper chains onto `mt_mobile_inventory`'s, because `mt_preview` is installed after it. There is no touch entry other than the wiki.
## Window (`ScriptLib/ModelPreviewScript.inc`, module `mt_preview`)
### Item mode
- `ItemSpec(vnum)` accepts:
- a weapon other than an arrow → `SetWeapon`;
- a body armor or a body costume → `SetArmor`;
- a hair costume → `SetHair`, which reads value3.
- The races are those not excluded by `ITEM_ANTIFLAG_{MALE,FEMALE,WARRIOR,ASSASSIN,SURA,SHAMAN}`, ordered warrior, assassin, sura, shaman, with male first. The window starts on the player's own race if it is allowed.
- `<` / `>` step through the allowed races.
### Mob mode
- `SelectModel(vnum)`.
- Motion buttons: 待机 1, 攻击 13, 受击 5, 死亡 11, 技能1–4 33–36 (`InterceptOnceMotion`).
### Controls
- Dragging with the left button turns the camera: absolute yaw/pitch from the press point, pitch clamped to ±60°.
- 放大 / 缩小 change the zoom in 5 % steps, clamped to −8..+12. Windows have no wheel event.
- 自动旋转 spins the model.
- 复位 rebuilds the model, which also resets the camera.
### Lifetime
- Render target index 1 is created at 300×330 when the window opens.
- It is removed on close or Esc, and on every re-open before it is created again. `GameWindow.Close` also calls `renderTarget.Destroy()`.
- The host frees the GPU image 120 frames after its last use.
## Native side
- `renderTarget` Python module (`port/UserInterface/PythonRenderTargetModule.cpp`) → `CRenderTargetManager` → `CRenderTarget`, which owns a `CInstanceBase` and a `CGraphicRenderTargetTexture`.
- `CPythonApplication` calls `DeformModels` / `UpdateModels` / `RenderModels` around the character manager.
### Port differences from AVA2
- Player races (0..7) are created as `TYPE_PC`. 40250 reserves the armor/weapon/hair parts only for PCs, so AVA2's `TYPE_NPC` crashed on the first `ChangeArmor`.
- The model sits at the world origin, where culling hides it. Instead of AVA2's `SetAlwaysRender`, it is shown again before `Deform` and `Render`. Its text tail is detached.
- The background quad is drawn inside the render target, in `RenderModel`. `RenderBackgrounds` is a no-op.
- Wiki affects are not ported.
- The texture has 1 mip level.
### Host (Vulkan)
- A colour render target is `D3DFMT_X8R8G8B8` / `A8R8G8B8` with `D3DUSAGE_RENDERTARGET`, named `rt:<id>:<w>x<h>:c`.
- It gets its own RGBA8 offscreen pass (pipeline key bits 29–30: 0 none, 1 shadow, 2 colour) and is cleared to black.
- Shadow targets keep the previous path.
- Side effect: SnowEnvironment's blur targets now take the colour path. Snow blur is off by default.
## Verification
- `tests/wiki/test_model_preview.py` covers:
- `ItemSpec` races and slots;
- race stepping and fallback;
- motions and zoom clamps;
- drag;
- reset;
- the view drawing the target;
- no live target after close, Esc, or 5 re-opens;
- the inventory and shop wrappers;
- wiki links and module order.
- ctest 49/49.
- Live, channel5 13040, macOS client, 2026-10-16:
- armor 11299 on a warrior;
- sword 19 after a 90°/20° turn;
- wild dog 101 with the attack motion;
- boss 691 zoomed in with spin.
- The main scene was unchanged.
- 120 open/close cycles (30 paced, plus 60 in one frame) did not grow RSS: 668 MB before, 593 MB after.
+4
View File
@@ -350,11 +350,15 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
limits = names_dir / "item_limits_server.tsv"
if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file():
raise ValueError("stone overlay requires the item limits table argument")
if (names_dir / "item_shapes_server.tsv").is_file() and not (names_dir / "item_stones_server.tsv").is_file():
raise ValueError("item shapes require the stone overlay argument")
result = subprocess.run(
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
str(item_names), str(mob_names), str(locale_dir / "item_proto"),
str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else [])
+ ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else []),
# item_shapes_server.tsv follows the stone overlay (positional): size and flags of new-use items.
+ ([str(names_dir / "item_shapes_server.tsv")] if (names_dir / "item_shapes_server.tsv").is_file() else []),
check=True, capture_output=True, text=True,
)
report["proto"] = {
+10 -8
View File
@@ -173,7 +173,7 @@ void VulkanWindow::prepare_draws(const std::vector<Render3DDraw>& draws, const T
RenderTarget* target = nullptr;
if (draw.render_target) {
target = get_render_target(
Render3DRenderTargetName(draw.render_target, draw.target_width, draw.target_height));
Render3DRenderTargetName(draw.render_target, draw.target_width, draw.target_height, draw.target_color));
if (!target) continue;
}
if (draw.clear_flags && target) {
@@ -257,7 +257,8 @@ void VulkanWindow::prepare_draws(const std::vector<Render3DDraw>& draws, const T
}
}
const auto pipeline = get_pipeline(cull, depth, blend, draw.lines,
static_cast<std::uint8_t>(draw.z_func), target != nullptr, &draw);
static_cast<std::uint8_t>(draw.z_func),
std::uint8_t(target ? (target->color ? 2 : 1) : 0), &draw);
// D3DTOP_DISABLE (1) on stage 0 or 1 ends the cascade before stage 1 samples.
const bool bind_tex1 = !draw.texture1.empty() && draw.color_op[0] > 1 && draw.color_op[1] > 1;
const auto descriptor = get_texture_descriptor(
@@ -265,8 +266,9 @@ void VulkanWindow::prepare_draws(const std::vector<Render3DDraw>& draws, const T
auto constants = make_push_constants(draw, skin_offset_encoded);
if (draw.pretransformed) {
const float screen_width = float(logical_width());
const float screen_height = float(logical_height());
// PORT: a pretransformed draw into a render target is in the target's pixels.
const float screen_width = target ? float(target->width) : float(logical_width());
const float screen_height = target ? float(target->height) : float(logical_height());
// Screen pixels (y down) to D3D NDC (y up); the shader's Y flip then maps to Vulkan.
constants.mvp = {2.0f / screen_width, 0, 0, 0,
0, -2.0f / screen_height, 0, 0,
@@ -448,7 +450,7 @@ void VulkanWindow::record_passes(std::uint32_t image_index, const FrameDraws& fr
for (std::size_t i = 0; i < frame.offscreen_draws.size();) {
RenderTarget* target = frame.offscreen_draws[i].target;
VkRenderPassBeginInfo offscreen_begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
offscreen_begin.renderPass = offscreen_pass_;
offscreen_begin.renderPass = target->color ? offscreen_color_pass_ : offscreen_pass_;
offscreen_begin.framebuffer = target->framebuffer;
offscreen_begin.renderArea.extent = {target->width, target->height};
vkCmdBeginRenderPass(f_->command, &offscreen_begin, VK_SUBPASS_CONTENTS_INLINE);
@@ -559,10 +561,10 @@ VulkanWindow::Readback VulkanWindow::record_readbacks(std::uint32_t image_index)
const char* dump_rt = std::getenv("MT_DUMP_RT");
if (readback.bmp && dump_rt && *dump_rt) {
readback.dump_rt = dump_rt;
const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4;
for (auto& entry : render_targets_) {
RenderTarget& rt = entry.second;
Readback::RtDump dump{VK_NULL_HANDLE, VK_NULL_HANDLE, rt.width, rt.height};
const std::uint32_t texel = !rt.color && offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4;
Readback::RtDump dump{VK_NULL_HANDLE, VK_NULL_HANDLE, rt.width, rt.height, texel};
VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
info.size = VkDeviceSize(rt.width) * rt.height * texel; info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
check(vkCreateBuffer(device_, &info, nullptr, &dump.buffer), "vkCreateBuffer rt dump");
@@ -615,9 +617,9 @@ void VulkanWindow::finish_readbacks(Readback& readback) {
for (auto& sink : readback.sinks) sink(bgra.data(), w, h);
}
vkUnmapMemory(device_, readback.memory);
const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4;
for (std::size_t n = 0; n < readback.rt_dumps.size(); ++n) {
const auto& dump = readback.rt_dumps[n];
const std::uint32_t texel = dump.texel;
void* data = nullptr;
check(vkMapMemory(device_, dump.memory, 0, VK_WHOLE_SIZE, 0, &data), "vkMapMemory rt dump");
const auto* bytes = static_cast<const std::uint8_t*>(data);
+12 -5
View File
@@ -104,8 +104,13 @@ void VulkanWindow::create_offscreen_pass() {
VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT;
offscreen_format_ = (properties.optimalTilingFeatures & wanted) == wanted
? VK_FORMAT_R5G6B5_UNORM_PACK16 : VK_FORMAT_R8G8B8A8_UNORM;
offscreen_pass_ = make_offscreen_pass(offscreen_format_);
offscreen_color_pass_ = make_offscreen_pass(VK_FORMAT_R8G8B8A8_UNORM);
}
VkRenderPass VulkanWindow::make_offscreen_pass(VkFormat format) {
VkAttachmentDescription attachments[2]{};
attachments[0].format = offscreen_format_; attachments[0].samples = VK_SAMPLE_COUNT_1_BIT;
attachments[0].format = format; attachments[0].samples = VK_SAMPLE_COUNT_1_BIT;
attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE;
attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
@@ -146,15 +151,17 @@ void VulkanWindow::create_offscreen_pass() {
pass_info.attachmentCount = 2; pass_info.pAttachments = attachments;
pass_info.subpassCount = 1; pass_info.pSubpasses = &subpass;
pass_info.dependencyCount = 2; pass_info.pDependencies = dependencies;
check(vkCreateRenderPass(device_, &pass_info, nullptr, &offscreen_pass_), "vkCreateRenderPass offscreen");
VkRenderPass pass = VK_NULL_HANDLE;
check(vkCreateRenderPass(device_, &pass_info, nullptr, &pass), "vkCreateRenderPass offscreen");
return pass;
}
VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend,
bool lines, std::uint8_t z_func, bool offscreen, const Render3DDraw* draw) {
bool lines, std::uint8_t z_func, std::uint8_t offscreen, const Render3DDraw* draw) {
std::array<std::uint32_t, 17> key{};
key[0] = std::uint32_t(cull) | (std::uint32_t(depth) << 8) |
(std::uint32_t(blend) << 16) | (lines ? (1u << 24) : 0u) |
(std::uint32_t(z_func & 0xfu) << 25) | (offscreen ? (1u << 29) : 0u);
(std::uint32_t(z_func & 0xfu) << 25) | (std::uint32_t(offscreen & 3u) << 29);
static const bool specialize = std::getenv("MT_GENERIC_SHADERS") == nullptr;
key[1] = specialize ? 1u : 0u;
if (specialize && draw) {
@@ -296,7 +303,7 @@ VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std
pipeline_info.pMultisampleState = &multisample; pipeline_info.pDepthStencilState = &depth_stencil;
pipeline_info.pColorBlendState = &blending;
pipeline_info.pDynamicState = &dynamic_info;
pipeline_info.layout = layout_; pipeline_info.renderPass = offscreen ? offscreen_pass_ : pass_;
pipeline_info.layout = layout_; pipeline_info.renderPass = offscreen == 2 ? offscreen_color_pass_ : offscreen ? offscreen_pass_ : pass_;
VkPipeline handle = VK_NULL_HANDLE;
check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &handle), "vkCreateGraphicsPipelines");
pipelines_.emplace(key, handle);
+18 -7
View File
@@ -270,15 +270,23 @@ void VulkanWindow::ensure_ui_capacity(std::size_t command_count) {
}
VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& name) {
if (auto it = render_targets_.find(name); it != render_targets_.end()) return &it->second;
if (auto it = render_targets_.find(name); it != render_targets_.end()) {
it->second.last_used_frame = frame_number_;
return &it->second;
}
unsigned id = 0, w = 0, h = 0;
if (std::sscanf(name.c_str(), "rt:%u:%ux%u", &id, &w, &h) != 3 || !w || !h || w > 4096 || h > 4096)
int end = 0;
if (std::sscanf(name.c_str(), "rt:%u:%ux%u%n", &id, &w, &h, &end) != 3 || !w || !h || w > 4096 || h > 4096)
return nullptr;
const bool color = name.compare(std::size_t(end), std::string::npos, ":c") == 0;
if (!color && name[std::size_t(end)] != '\0') return nullptr;
RenderTarget& target = render_targets_[name];
target.width = w;
target.height = h;
target.color = color;
target.last_used_frame = frame_number_;
auto make_image = [&](VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect,
VkImage& image, VkDeviceMemory& memory, VkImageView& view) {
VkImage& image, VkDeviceMemory& memory, VkImageView& view, bool opaque = false) {
VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
info.imageType = VK_IMAGE_TYPE_2D;
info.format = format;
@@ -299,18 +307,20 @@ VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& n
VkImageViewCreateInfo view_info{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
view_info.image = image; view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; view_info.format = format;
view_info.subresourceRange = {aspect, 0, 1, 0, 1};
// A colour target is D3DFMT_X8R8G8B8: sampled alpha is always 1.
if (opaque) view_info.components.a = VK_COMPONENT_SWIZZLE_ONE;
check(vkCreateImageView(device_, &view_info, nullptr, &view), "vkCreateImageView render target");
};
make_image(offscreen_format_,
make_image(color ? VK_FORMAT_R8G8B8A8_UNORM : offscreen_format_,
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
VK_IMAGE_ASPECT_COLOR_BIT, target.texture.image, target.texture.memory, target.texture.view);
VK_IMAGE_ASPECT_COLOR_BIT, target.texture.image, target.texture.memory, target.texture.view, color);
make_image(VK_FORMAT_D32_SFLOAT,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
VK_IMAGE_ASPECT_DEPTH_BIT, target.depth_image, target.depth_memory, target.depth_view);
const VkImageView views[2] = {target.texture.view, target.depth_view};
VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO};
fb.renderPass = offscreen_pass_;
fb.renderPass = color ? offscreen_color_pass_ : offscreen_pass_;
fb.attachmentCount = 2; fb.pAttachments = views;
fb.width = w; fb.height = h; fb.layers = 1;
check(vkCreateFramebuffer(device_, &fb, nullptr, &target.framebuffer), "vkCreateFramebuffer render target");
@@ -343,7 +353,8 @@ void VulkanWindow::initialize_render_target(RenderTarget& target) {
barriers[1].subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
vkCmdPipelineBarrier(f_->command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 2, barriers);
const VkClearColorValue white{{1.0f, 1.0f, 1.0f, 1.0f}};
const float shade = target.color ? 0.0f : 1.0f;
const VkClearColorValue white{{shade, shade, shade, 1.0f}};
const VkClearDepthStencilValue far{1.0f, 0};
vkCmdClearColorImage(f_->command, target.texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &white, 1,
&barriers[0].subresourceRange);
+1 -1
View File
@@ -181,7 +181,7 @@ VulkanWindow::PreparedDraw VulkanWindow::scene_composite_draw() {
for (int i = 0; i < 16; ++i) m[i] = i % 5 == 0 ? 1.0f : 0.0f;
PreparedDraw composite{};
composite.geometry = &scene_quad_;
composite.pipeline = get_pipeline(0, 0, 0, false, 4, false, &quad);
composite.pipeline = get_pipeline(0, 0, 0, false, 4, 0, &quad);
composite.descriptor = scene_texture_.descriptor;
composite.constants = make_push_constants(quad, 0.0f);
// Unit square (y down) to D3D NDC (y up), as the pretransformed draws map screen pixels.
+13
View File
@@ -430,6 +430,19 @@ void VulkanWindow::prune_textures() {
it = textures_.erase(it);
} else ++it;
}
// PORT: render targets too (a closed model preview); a later use recreates and clears one.
for (auto it = render_targets_.begin(); it != render_targets_.end();) {
if (frame_number_ - it->second.last_used_frame > static_retention_frames) {
for (auto pair = paired_descriptors_.begin(); pair != paired_descriptors_.end();) {
if (pair->first.find("\n" + it->first + "\n") != std::string::npos) {
vkFreeDescriptorSets(device_, descriptor_pool_, 1, &pair->second.set);
pair = paired_descriptors_.erase(pair);
} else ++pair;
}
release_render_target(it->second);
it = render_targets_.erase(it);
} else ++it;
}
}
} // namespace mt_host
+1
View File
@@ -125,6 +125,7 @@ VulkanWindow::~VulkanWindow() {
for (auto& entry : textures_) release_texture(entry.second);
for (auto& entry : render_targets_) release_render_target(entry.second);
if (offscreen_pass_) vkDestroyRenderPass(device_, offscreen_pass_, nullptr);
if (offscreen_color_pass_) vkDestroyRenderPass(device_, offscreen_color_pass_, nullptr);
destroy_scene_target();
release_geometry(scene_quad_);
if (scene_pass_) vkDestroyRenderPass(device_, scene_pass_, nullptr);
+13 -6
View File
@@ -56,8 +56,9 @@ class VulkanWindow {
VkDescriptorSet set = VK_NULL_HANDLE;
std::uint64_t last_used_frame = 0;
};
// An offscreen D3D render-target texture (the 40250 character shadow map, "rt:<id>:<w>x<h>"):
// drawn by the frame's offscreen pass ahead of the back-buffer pass and sampled by that pass.
// An offscreen D3D render-target texture (the 40250 character shadow map, "rt:<id>:<w>x<h>", or a
// colour target such as the model preview, "rt:<id>:<w>x<h>:c"): drawn by the frame's offscreen pass
// ahead of the back-buffer pass and sampled by that pass.
// Between frames the colour image stays SHADER_READ_ONLY_OPTIMAL and the depth image
// DEPTH_STENCIL_ATTACHMENT_OPTIMAL; the render pass keeps (LOAD/STORE) both, like a D3D surface.
struct RenderTarget {
@@ -67,7 +68,9 @@ class VulkanWindow {
VkImageView depth_view = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE;
std::uint32_t width = 0, height = 0;
bool initialized = false; // cleared to white / depth 1 by the first frame that uses it
bool initialized = false; // cleared (shadow white, colour black) / depth 1 by the first frame that uses it
bool color = false; // RGBA8 drawn with offscreen_color_pass_; otherwise offscreen_format_
std::uint64_t last_used_frame = 0; // pruned with the textures once unused
};
struct PreparedDraw {
Geometry* geometry = nullptr;
@@ -223,7 +226,7 @@ private:
};
// Host copies of the swapchain image (--screenshot-out) and, with MT_DUMP_RT, of every render target.
struct Readback {
struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h; };
struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h, texel; };
bool screenshot = false; // the swapchain image is copied (a BMP or a frame sink wants it)
bool bmp = false; // --screenshot-out
std::vector<FrameSink> sinks;
@@ -297,7 +300,9 @@ private:
// The render pass of offscreen render-target textures: colour (R5G6B5 like the D3D surface when
// the device can render to it) + D32 depth, both loaded and stored, colour left shader-readable.
// PORT: plus offscreen_color_pass_, the same pass over RGBA8 for colour targets.
void create_offscreen_pass();
VkRenderPass make_offscreen_pass(VkFormat format);
// The reduced-resolution scene pass: pass_'s attachments (so pass_ pipelines draw in it) at
// scene_extent_, resolving into the sampled scene_texture_. Unsupported when the swapchain
@@ -322,9 +327,10 @@ private:
// the layouts offscreen_pass_ expects. Recorded outside any render pass.
void initialize_render_target(RenderTarget& target);
// offscreen: for offscreen_pass_ (single-sampled render-target texture) instead of pass_.
// offscreen: 1 for offscreen_pass_, 2 for offscreen_color_pass_ (single-sampled render-target
// texture) instead of pass_.
VkPipeline get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend,
bool lines = false, std::uint8_t z_func = 4, bool offscreen = false,
bool lines = false, std::uint8_t z_func = 4, std::uint8_t offscreen = 0,
const Render3DDraw* draw = nullptr);
VkDescriptorSet allocate_texture_descriptor_set(VkImageView view0, VkImageView view1,
@@ -478,6 +484,7 @@ private:
std::unordered_map<std::string, PairedDescriptor> paired_descriptors_;
std::unordered_map<std::string, RenderTarget> render_targets_;
VkRenderPass offscreen_pass_ = VK_NULL_HANDLE;
VkRenderPass offscreen_color_pass_ = VK_NULL_HANDLE;
float render_scale_ = 1.0f;
bool scene_supported_ = false;
VkRenderPass scene_pass_ = VK_NULL_HANDLE;
+36 -4
View File
@@ -192,6 +192,13 @@ HRESULT CpuSurface::LockRect(D3DLOCKED_RECT* locked, const RECT* rect, DWORD fla
return S_OK;
}
// PORT: a 32-bit render-target texture (the AVA2 model preview's CGraphicRenderTargetTexture), drawn on the
// GPU with full state; the R5G6B5 ones are the character shadow map.
bool is_color_target(const D3DSURFACE_DESC& desc)
{
return (desc.Usage & D3DUSAGE_RENDERTARGET) && (desc.Format == D3DFMT_X8R8G8B8 || desc.Format == D3DFMT_A8R8G8B8);
}
D3DSURFACE_DESC surface_desc(UINT width, UINT height, D3DFORMAT format, DWORD usage, D3DPOOL pool)
{
D3DSURFACE_DESC desc = {};
@@ -454,7 +461,10 @@ public:
return S_OK;
}
auto* surface = static_cast<CpuSurface*>(m_renderTarget);
if (!surface->parent || surface->level != 0 || surface->desc.Format != D3DFMT_R5G6B5)
const bool colorTarget = surface->parent && surface->level == 0 && is_color_target(surface->desc);
if (colorTarget && !g_gpu_render_targets)
return S_OK;
if (!colorTarget && (!surface->parent || surface->level != 0 || surface->desc.Format != D3DFMT_R5G6B5))
return S_OK;
if (g_gpu_render_targets)
{
@@ -463,6 +473,7 @@ public:
clear.render_target = surface->parent->id;
clear.target_width = surface->desc.Width;
clear.target_height = surface->desc.Height;
clear.target_color = colorTarget;
clear.clear_flags = flags & (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER);
clear.clear_color = color;
clear.clear_z = depth;
@@ -1140,11 +1151,30 @@ private:
if (m_renderTarget == m_backBuffer)
Render3DAdd(std::move(draw));
else if (g_gpu_render_targets)
record_shadow(std::move(draw));
{
auto* surface = static_cast<CpuSurface*>(m_renderTarget);
if (surface->parent && surface->level == 0 && is_color_target(surface->desc))
record_color(std::move(draw), *surface);
else
record_shadow(std::move(draw));
}
else
rasterize_shadow(draw);
}
// PORT: a draw into a colour render target keeps every state the back buffer would get.
void record_color(Render3DDraw draw, const CpuSurface& surface)
{
if (!draw.shared_geometry && draw.indices.size() < (draw.lines ? 2u : 3u))
return;
draw.render_target = surface.parent->id;
draw.target_width = surface.desc.Width;
draw.target_height = surface.desc.Height;
draw.target_color = true;
draw.ui_offset_x = 0;
Render3DAdd(std::move(draw));
}
// GPU counterpart of rasterize_shadow: the draw goes to the renderer's offscreen pass with the
// states rasterize_shadow implements -- a flat D3DRS_TEXTUREFACTOR fill, no culling, no texture,
// light, fog or blending, depth test LESSEQUAL with depth writes -- so both paths draw the same map.
@@ -1257,6 +1287,8 @@ std::string MtCpuTextureNameFromHandle(const IDirect3DBaseTexture8* handle)
const D3DSURFACE_DESC& desc = entry.second->levels[0].desc;
if (g_gpu_render_targets && (desc.Usage & D3DUSAGE_RENDERTARGET) && desc.Format == D3DFMT_R5G6B5)
return Render3DRenderTargetName(entry.first, desc.Width, desc.Height);
if (g_gpu_render_targets && is_color_target(desc))
return Render3DRenderTargetName(entry.first, desc.Width, desc.Height, true);
if (entry.second == handle && !entry.second->levels.empty())
return "mem:cpu_" + std::to_string(entry.first) + "@" + std::to_string(entry.second->revision);
}
@@ -1406,9 +1438,9 @@ bool IsNativeTerrainRenderEnabled()
void SetGpuRenderTargetsEnabled(bool enabled) { g_gpu_render_targets = enabled; }
bool IsGpuRenderTargetsEnabled() { return g_gpu_render_targets; }
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height)
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height, bool color)
{
return "rt:" + std::to_string(id) + ":" + std::to_string(width) + "x" + std::to_string(height);
return "rt:" + std::to_string(id) + ":" + std::to_string(width) + "x" + std::to_string(height) + (color ? ":c" : "");
}
void Render3DBeginFrame()
+5 -1
View File
@@ -38,6 +38,9 @@ struct Render3DDraw {
// viewport is then in the target's pixels. Only emitted while GPU render targets are enabled.
std::uint32_t render_target = 0;
std::uint32_t target_width = 0, target_height = 0;
// PORT: an X8R8G8B8 / A8R8G8B8 colour target (the AVA2 model preview, CRenderTarget) drawn with the
// draw's own states, named with Render3DRenderTargetName(..., true); otherwise the R5G6B5 shadow map.
bool target_color = false;
// Stage 0/1 textures, named like UIRenderTextureName: the pack path of a file texture or
// "mem:<id>@<revision>"; empty when the stage has no texture.
@@ -143,7 +146,8 @@ bool LookupGpuSkinSubrange(const void* vertex_buffer_base, std::uint32_t stride,
// default (the Godot consumer has no offscreen pass); the native Vulkan renderer turns it on.
void SetGpuRenderTargetsEnabled(bool enabled);
bool IsGpuRenderTargetsEnabled();
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height);
// A colour target's name ends in ":c".
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height, bool color = false);
void Render3DBeginFrame();
void Render3DAdd(Render3DDraw draw);
+219
View File
@@ -0,0 +1,219 @@
// PORT: AVA2-style batch chest opening (ENABLE_MULTI_CHEST, Ctrl + right click on a gift box), driven by the
// server's /mt_chest command. Every box still goes through the original gift-box use path on the server; this
// window only picks the box and the count and shows the summed gains. Without Ctrl, or against a server without
// the command, the original single use runs. Protocol: metin2-server docs/FEATURE-CHEST.md.
static const char c_szChestScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import grp
import item
import net
import player
import ui
import uiInventory
import game
from mt_biolog import Action, Text, L
from mt_wiki import B
g_window=None
MAX=200
COUNTS=(1,10,50)
SEND_GAP=.5
TIMEOUT=3.
GIFTBOX=getattr(item,'ITEM_TYPE_GIFTBOX',23)
CHAT_ITEMS=5
ERRORS={'rate':'操作过快','invalid':'参数无效','busy':'当前状态不能开启宝箱','item':'宝箱已不在原位置',
'kind':'这种宝箱不能批量开启,请单个开启','full':'背包空位不足,至少保留 3 格'}
REASONS={'done':'','empty':'宝箱已全部开启','full':'背包空位不足,已停止','busy':'状态变化,已停止','rejected':'宝箱无法开启,已停止'}
def Number(text,low=0,high=4294967295):
sign=-1 if text[:1]=='-' else 1
digits=text[1:] if sign<0 else text
if not digits.isdigit() or len(digits)>12:raise ValueError(text)
value=sign*int(digits)
if not low<=value<=high:raise ValueError(text)
return value
class Opener(object):
# Protocol state only. send(text) posts a chat command, now() is seconds.
def __init__(self,send,now,listener=None):
self.send=send;self.now=now;self.listener=listener
self.waiting=None;self.nextSend=0;self.unsupported=False
self.gains=[];self.result=None;self.error=None
def Busy(self):return self.waiting is not None or self.now()<self.nextSend
def Open(self,cell,count):
self.Tick()
if self.unsupported or self.Busy():return False
now=self.now();count=max(1,min(MAX,int(count)))
self.send('/mt_chest 1 open %d %d'%(cell,count))
self.waiting=now+TIMEOUT;self.nextSend=now+SEND_GAP;self.gains=[];self.error=None
return True
def Tick(self):
if self.waiting is not None and self.now()>=self.waiting:
self.waiting=None;self.unsupported=True;self.Notify('unsupported')
def Notify(self,kind):
if self.listener:self.listener(kind)
def Receive(self,line):
if not line.startswith('MTCH1 '):return False
words=line.split()
try:
if words[1]=='got' and len(words)==4:
self.gains.append((Number(words[2]),Number(words[3],1)));return True
if words[1]=='done' and len(words)==7 and words[5] in REASONS:
vnum,asked,opened=[Number(w,0,100000000) for w in words[2:5]]
self.result=dict(vnum=vnum,asked=asked,opened=opened,reason=words[5],
gold=Number(words[6],-2**62,2**62),gains=self.gains)
self.gains=[];self.error=None;kind='done'
elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error'
else:return True
except ValueError:return True
self.waiting=None;self.unsupported=False;self.Notify(kind)
return True
def ItemName(vnum):
item.SelectItem(vnum);return item.GetItemName()
def Batchable(vnum):
if not vnum:return False
item.SelectItem(vnum);return item.GetItemType()==GIFTBOX
def Summary(result):
# Chat lines for one finished batch: the count line, then the gains a few items per line.
lines=['批量开启 %s:开启 %d 个'%(ItemName(result['vnum']),result['opened'])+
(',金币 %+d'%result['gold'] if result['gold'] else '')+
('(%s)'%REASONS[result['reason']] if REASONS[result['reason']] else '')]
gains=['%s ×%d'%(ItemName(vnum),count) for vnum,count in result['gains']]
for i in range(0,len(gains),CHAT_ITEMS):lines.append('获得:'+','.join(gains[i:i+CHAT_ITEMS]))
return lines
class ChestWindow(ui.Window):
def __init__(self,opener):
ui.Window.__init__(self);self.SetWindowName('ChestWindow');self.AddFlag('float');self.AddFlag('movable')
self.opener=opener;self.cell=-1;self.vnum=0;self.count=1;self.SetSize(332,286)
self.heading=Text(self,18,18,'批量开启宝箱',0xffffd68b)
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
self.name=Text(self,18,58,'',0xffe8dec8)
self.owned=Text(self,18,82,'',0xffc3b7a1)
self.countButtons=[Action(self,18+i*76,108,68,'',lambda count=count:self.SetCount(count)) for i,count in enumerate(COUNTS)]
self.allButton=Action(self,246,108,68,'全部',lambda:self.SetCount(MAX))
self.openButton=Action(self,18,170,296,'',self.OpenBoxes)
self.status=Text(self,18,234,'',0xffc3b7a1)
self.hint=Text(self,18,258,'Ctrl + 右键宝箱打开本窗口,单次最多 %d 个'%MAX,0xff8f8676)
self.Hide()
def Buttons(self):return [self.closeButton,self.allButton,self.openButton]+self.countButtons
def Select(self,cell,vnum):
if self.vnum!=vnum:self.count=1;self.opener.result=None;self.opener.error=None
self.cell=cell;self.vnum=vnum;self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
def Owned(self):return player.GetItemCountByVnum(self.vnum) if self.vnum else 0
def Amount(self):return max(1,min(self.count,self.Owned(),MAX))
def SetCount(self,count):self.count=count;self.Refresh()
def Cell(self):
# The chosen cell while it still holds the box, otherwise any bag cell that does (the server does the same).
if player.GetItemIndex(self.cell)==self.vnum:return self.cell
for cell in range(player.INVENTORY_PAGE_SIZE*player.INVENTORY_PAGE_COUNT):
if player.GetItemIndex(cell)==self.vnum:return cell
return -1
def OpenBoxes(self):
cell=self.Cell()
if cell<0:self.Refresh();return
if self.opener.Open(cell,self.Amount()):self.cell=cell
self.Refresh()
def Refresh(self):
owned=self.Owned()
self.name.SetText(L(B(ItemName(self.vnum)) if self.vnum else ''))
self.owned.SetText(L('背包内共 %d 个'%owned))
for button,count in zip(self.countButtons,COUNTS):
button.SetText(L('【%d】'%count if self.count==count else '%d'%count));button.SetEnabled(owned>0)
self.allButton.SetText(L('【全部】' if self.count==MAX else '全部'));self.allButton.SetEnabled(owned>0)
self.openButton.SetText(L('开启 %d 个'%self.Amount() if owned else '没有宝箱'))
self.openButton.SetEnabled(owned>0 and not self.opener.Busy() and not self.opener.unsupported)
self.status.SetText(L(self.StatusText()))
def StatusText(self):
o=self.opener
if o.unsupported:return '!服务器尚未启用批量开启'
if o.waiting is not None:return '正在开启……'
if o.error:return '!'+ERRORS[o.error]
r=o.result
if r and r['vnum']==self.vnum:
text='上次:开启 %d/%d 个,获得 %d 种物品'%(r['opened'],min(r['asked'],r['opened']+self.Owned()),len(r['gains']))
return text+(','+REASONS[r['reason']] if REASONS[r['reason']] else '')
return '获得的物品会汇总显示在聊天框'
def OnRender(self):
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
def OnUpdate(self):
busy=self.opener.Busy();self.opener.Tick()
if busy and not self.opener.Busy():self.Refresh() # the send gap ran out, or the request timed out
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide()
for b in self.Buttons():b.event=None
def OnOpener(kind):
if kind=='unsupported':chat.AppendChat(chat.CHAT_TYPE_INFO,L('服务器尚未启用批量开启,请单个开启宝箱'))
elif kind=='done':
for line in Summary(g_opener.result):chat.AppendChat(chat.CHAT_TYPE_INFO,L(line))
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[g_opener.error]))
if g_window:g_window.Refresh()
g_opener=Opener(net.SendChatPacket,app.GetTime,OnOpener)
def CtrlPressed():return app.IsPressed(app.DIK_LCONTROL) or app.IsPressed(app.DIK_RCONTROL)
_open=game.GameWindow.Open
_close=game.GameWindow.Close
_command=game.GameWindow.BINARY_ServerCommand_Run
_use=uiInventory.InventoryWindow.UseItemSlot
def Open(self):
global g_window
_open(self);g_opener.waiting=None;g_window=ChestWindow(g_opener)
def Close(self):
global g_window
if g_window:g_window.Destroy();g_window=None
_close(self)
def Command(self,line):
if g_opener.Receive(line):return 1
return _command(self,line)
def UseItemSlot(self,slot):
if g_window and not g_opener.unsupported and CtrlPressed():
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot)
vnum=player.GetItemIndex(cell)
if Batchable(vnum):g_window.Select(cell,vnum);return
return _use(self,slot)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.BINARY_ServerCommand_Run=Command
uiInventory.InventoryWindow.UseItemSlot=UseItemSlot
)PY";
@@ -0,0 +1,255 @@
// PORT: model preview, from AVA2's render-target window (uirendertarget.py). A wearable item is shown on a
// character of every race that may wear it, a monster with its motions; drag rotates, buttons zoom. Entry points:
// X + click on an inventory or shop item, and a "模型预览" line in the wiki detail. The native side is the
// renderTarget module (UserInterface/PythonRenderTargetModule.cpp). Design: docs/FEATURE-MODEL-PREVIEW.md.
static const char c_szModelPreviewScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import game
import grp
import item
import player
import renderTarget
import ui
import wndMgr
from mt_biolog import Action, Text, L
INDEX=1
VIEW_W=300
VIEW_H=330
MARGIN=15
TOP=58
ROW=54
ZOOM_MIN=-8
ZOOM_MAX=12
ITEM_ZOOM=2
PITCH_LIMIT=60.
# Paired by job: warrior, assassin, sura, shaman; male first. race%4 is the job, these races are male.
RACES=(0,4,5,1,2,6,7,3)
MALE=(0,5,2,7)
JOBS=('武士','刺客','修罗','法师')
MOTIONS=((1,'待机'),(13,'攻击'),(5,'受击'),(11,'死亡'),(33,'技能1'),(34,'技能2'),(35,'技能3'),(36,'技能4'))
g_window=None
def RaceName(race):
return '%s(%s)'%(JOBS[race%4],'男' if race in MALE else '女')
def ItemSpec(vnum):
# (slot, races that may wear it) for a wearable item with a model, None otherwise.
try:
item.SelectItem(vnum)
kind=item.GetItemType();sub=item.GetItemSubType()
except (RuntimeError,TypeError):return None
if kind==item.ITEM_TYPE_WEAPON and sub!=item.WEAPON_ARROW:slot='weapon'
elif kind==item.ITEM_TYPE_ARMOR and sub==item.ARMOR_BODY:slot='armor'
elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_BODY:slot='armor'
elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_HAIR:slot='hair'
else:return None
jobs=(item.ITEM_ANTIFLAG_WARRIOR,item.ITEM_ANTIFLAG_ASSASSIN,item.ITEM_ANTIFLAG_SURA,item.ITEM_ANTIFLAG_SHAMAN)
races=[r for r in RACES if not item.IsAntiFlag(jobs[r%4]) and
not item.IsAntiFlag(item.ITEM_ANTIFLAG_MALE if r in MALE else item.ITEM_ANTIFLAG_FEMALE)]
if not races:return None
return slot,races
def Previewable(kind,vnum):
return kind=='mob' or ItemSpec(vnum) is not None
class View(ui.Window):
# The render target is drawn over this window; a left drag turns the camera around the model.
def __init__(self,owner):
ui.Window.__init__(self);self.owner=owner;self.drag=None
def OnMouseLeftButtonDown(self):
x,y=wndMgr.GetMousePosition();o=self.owner;self.drag=(x,y,o.yaw,o.pitch);return True
def OnMouseLeftButtonUp(self):
self.drag=None;return True
def OnUpdate(self):
if self.drag:
x,y=wndMgr.GetMousePosition();x0,y0,yaw,pitch=self.drag
self.owner.Turn(yaw+(x-x0)*.5,pitch+(y-y0)*.3)
def OnRender(self):
x,y=self.GetGlobalPosition();w=self.GetWidth();h=self.GetHeight()
grp.SetColor(0xff000000);grp.RenderBar(x,y,w,h)
renderTarget.RenderTexture(INDEX,x,y,x+w,y+h)
grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,h)
class PreviewWindow(ui.Window):
def __init__(self):
ui.Window.__init__(self);self.SetWindowName('ModelPreviewWindow');self.AddFlag('float');self.AddFlag('movable')
width=VIEW_W+2*MARGIN;self.SetSize(width,TOP+VIEW_H+ROW+15)
self.heading=Text(self,18,18,'模型预览',0xffffd68b)
self.closeButton=Action(self,width-76,4,64,'关闭',self.Close)
self.view=View(self);self.view.SetParent(self);self.view.SetPosition(MARGIN,TOP);self.view.SetSize(VIEW_W,VIEW_H);self.view.Show()
self.note=Text(self,MARGIN+8,TOP+8,'',0xffc3b7a1)
y=TOP+VIEW_H+6
self.prevRace=Action(self,MARGIN,y,56,'<',lambda:self.StepRace(-1))
self.raceName=Text(self,width/2,y+16,'',0xffe8dec8);self.raceName.SetHorizontalAlignCenter()
self.nextRace=Action(self,width-MARGIN-56,y,56,'>',lambda:self.StepRace(1))
step=(VIEW_W+6)/4
self.motions=[Action(self,MARGIN+(i%4)*step,y+(i//4)*ROW,step-6,name,(lambda m=motion:self.Motion(m)))
for i,(motion,name) in enumerate(MOTIONS)]
self.controls=[Action(self,MARGIN+i*step,0,step-6,name,event) for i,(name,event) in enumerate((
('放大',lambda:self.Zoom(-1)),('缩小',lambda:self.Zoom(1)),('自动旋转',self.ToggleSpin),('复位',self.Apply)))]
self.kind=None;self.vnum=0;self.slot=None;self.races=[];self.race=0
self.yaw=0.;self.pitch=0.;self.zoom=0;self.spin=False;self.ready=False
self.Hide()
def Open(self,kind,vnum):
if kind=='mob':slot,races=None,[]
else:
spec=ItemSpec(vnum)
if not spec:return False
slot,races=spec
renderTarget.Remove(INDEX)
if not renderTarget.Create(INDEX,VIEW_W,VIEW_H):return False
renderTarget.SetVisibility(INDEX,True)
self.kind=kind;self.vnum=vnum;self.slot=slot;self.races=races
if races:
try:own=player.GetRace()
except RuntimeError:own=-1
self.race=own if own in races else races[0]
self.Layout();self.Apply()
if not self.IsShow():self.SetCenterPosition()
ui.Window.Show(self);self.SetTop()
return True
def Layout(self):
item_mode=self.kind!='mob';rows=1 if item_mode else (len(MOTIONS)+3)//4
y=TOP+VIEW_H+6
for w in (self.prevRace,self.nextRace,self.raceName):
if item_mode:w.Show()
else:w.Hide()
for b in self.motions:
if item_mode:b.Hide()
else:b.Show()
bottom=y+rows*ROW
step=(VIEW_W+6)/4
for i,b in enumerate(self.controls):b.SetPosition(MARGIN+i*step,bottom)
self.SetSize(VIEW_W+2*MARGIN,bottom+ROW+10)
many=len(self.races)>1
self.prevRace.SetEnabled(many);self.nextRace.SetEnabled(many)
def Apply(self):
# Rebuilds the model: also the reset button, since SelectModel puts the camera back.
renderTarget.ResetModel(INDEX)
self.yaw=0.;self.pitch=0.;self.zoom=0
if self.kind=='mob':
self.ready=bool(renderTarget.SelectModel(INDEX,self.vnum))
note='' if self.ready else '该怪物没有模型'
else:
self.ready=bool(renderTarget.SelectModel(INDEX,self.race))
if self.ready:
if self.slot=='armor':renderTarget.SetArmor(INDEX,self.vnum)
elif self.slot=='weapon':renderTarget.SetWeapon(INDEX,self.vnum)
else:renderTarget.SetHair(INDEX,self.vnum)
# The default camera cuts the feet of a player race in this view; start two zoom steps out.
if self.ready:
for _ in range(ITEM_ZOOM):renderTarget.Zoom(INDEX,1)
note='' if self.ready else '模型加载失败'
self.raceName.SetText(L(RaceName(self.race)))
renderTarget.SetRotation(INDEX,self.spin)
renderTarget.RotateEyeAroundTarget(INDEX,0.,0.)
self.note.SetText(L(note))
def StepRace(self,delta):
if len(self.races)<2:return
self.race=self.races[(self.races.index(self.race)+delta)%len(self.races)];self.Apply()
def Motion(self,motion):
if self.ready and not renderTarget.SetMotionIndex(INDEX,motion):self.note.SetText(L('该怪物没有此动作'))
else:self.note.SetText('')
def Zoom(self,direction):
if not self.ready or not ZOOM_MIN<=self.zoom+direction<=ZOOM_MAX:return
self.zoom+=direction;renderTarget.Zoom(INDEX,direction)
def Turn(self,yaw,pitch):
self.yaw=yaw%360.;self.pitch=max(-PITCH_LIMIT,min(PITCH_LIMIT,pitch))
renderTarget.RotateEyeAroundTarget(INDEX,self.pitch,self.yaw)
def ToggleSpin(self):
self.spin=not self.spin;renderTarget.SetRotation(INDEX,self.spin)
self.controls[2].SetText('停止旋转' if self.spin else '自动旋转')
def Close(self):
self.Hide()
def Hide(self):
# The target holds a GPU texture and a model; drop both whenever the window is not shown.
# ui.Window.__init__ hides the window before the view exists.
if getattr(self,'view',None):self.view.drag=None
self.ready=False
renderTarget.Remove(INDEX)
ui.Window.Hide(self)
def OnRender(self):
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=None;self.prevRace.event=self.nextRace.event=None
for b in self.motions+self.controls:b.event=None
self.view.owner=None
def Preview(kind,vnum):
if not g_window:return False
if not g_window.Open(kind,vnum):
chat.AppendChat(chat.CHAT_TYPE_INFO,L('该物品没有可预览的模型'));return False
return True
def KeyHeld():
return app.IsPressed(app.DIK_X)
_open=game.GameWindow.Open
_close=game.GameWindow.Close
def Open(self):
global g_window
_open(self);g_window=PreviewWindow()
def Close(self):
global g_window
if g_window:g_window.Destroy();g_window=None
renderTarget.Destroy()
_close(self)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
try:
import uiInventory
_inventorySelect=uiInventory.InventoryWindow.SelectItemSlot
def InventorySelect(self,slot):
if KeyHeld():
vnum=player.GetItemIndex(self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot))
if vnum and ItemSpec(vnum):Preview('item',vnum);return
return _inventorySelect(self,slot)
uiInventory.InventoryWindow.SelectItemSlot=InventorySelect
except (ImportError,AttributeError):pass
try:
import shop
import uiShop
_shopSelect=uiShop.ShopDialog.SelectItemSlot
def ShopSelect(self,slot):
if KeyHeld():
real=getattr(self,'_ShopDialog__GetRealIndex',None)
vnum=shop.GetItemID(real(slot) if real else slot)
if vnum and ItemSpec(vnum):Preview('item',vnum);return
return _shopSelect(self,slot)
uiShop.ShopDialog.SelectItemSlot=ShopSelect
except (ImportError,AttributeError):pass
)PY";
+9
View File
@@ -44,6 +44,7 @@
#include "UserInterface/PythonSystem.h"
#include "UserInterface/AbstractApplication.h"
#include "UserInterface/PythonApplication.h"
#include "UserInterface/CRenderTargetManager.h"
#include "EterBase/Timer.h"
#include "PythonBoot.h"
#include "../UserInterface/UserInterface.h"
@@ -157,6 +158,7 @@ struct GameSingletons
CGuildMarkDownloader kGuildMarkDownloader;
CGuildMarkUploader kGuildMarkUploader;
CPythonSystem pySystem; // last CPythonApplication member (m_pySystem)
CRenderTargetManager renderTargets; // PORT: AVA2 model preview; destroyed first, its models use the rest
};
std::unique_ptr<GameSingletons> g_game_singletons;
bool g_host_hardware_cursor_enabled = false;
@@ -911,6 +913,7 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
initsafebox();
initguild();
initServerStateChecker();
initRenderTarget(); // PORT: AVA2 model preview
init_2v0_gameplay_stubs();
ClearLogBoxMessage();
@@ -1001,9 +1004,12 @@ namespace PythonBoot
#include "WikiEntryScript.inc"
#include "SwitchbotScript.inc"
#include "PickupScript.inc"
#include "ChestScript.inc"
#include "DisplayScript.inc"
#include "ChannelScript.inc"
#include "BossScript.inc"
#include "VipScript.inc"
#include "ModelPreviewScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript)
{
@@ -1079,9 +1085,12 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
install_script_module("mt_wiki_entries", c_szWikiEntryScript);
install_script_module("mt_switchbot", c_szSwitchbotScript);
install_script_module("mt_pickup", c_szPickupScript);
install_script_module("mt_chest", c_szChestScript);
install_script_module("mt_display", c_szDisplayScript);
install_script_module("mt_channel", c_szChannelScript);
install_script_module("mt_boss", c_szBossScript);
install_script_module("mt_vip", c_szVipScript);
install_script_module("mt_preview", c_szModelPreviewScript);
if (g_touch_input)
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
}
+39 -6
View File
@@ -1,6 +1,7 @@
// PORT: AVA2-style automatic attribute change (uiswitchbot.py), driven by the server's /mt_switchbot
// command. Every roll runs on the server through the original change-attribute scroll path; this window
// only picks the item, scroll and targets and shows progress. Protocol: metin2-server docs/FEATURE-SWITCHBOT.md.
// only picks the item, scroll and targets and shows progress. The lock buttons toggle the server-side attribute
// lock that every reroll keeps. Protocol: metin2-server docs/FEATURE-SWITCHBOT.md, FEATURE-ATTR-LOCK.md.
static const char c_szSwitchbotScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
@@ -21,6 +22,7 @@ g_entry=None
USE_CHANGE_ATTRIBUTE=18 # CItemData::EUseSubTypes; the server re-checks the scroll subtype.
LIMITS=(10,50,100,200,500,1000)
MAX_TARGETS=5
LOCK_GROUPS=2
SEND_GAP=.3
TIMEOUT=4.
REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达到次数上限','no_scroll':'洗练道具已用完',
@@ -28,7 +30,8 @@ REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达
'stopped':'已手动停止','idle':'未运行'}
ERRORS={'rate':'操作过快,请稍后','invalid':'参数无效','item':'物品不存在','equipped':'请先卸下装备或结束交易','no_attr':'该物品不能洗练',
'scroll':'没有可用的洗练道具','target':'目标属性超出可洗范围','running':'已有洗练在进行','busy':'当前状态不能洗练',
'unsupported':'服务器尚未启用自动洗练'}
'unsupported':'服务器尚未启用自动洗练','slot':'该条属性不能锁定','max':'最多锁定 2 条,且至少留 1 条可洗',
'db':'锁定保存失败,请稍后再试'}
def Number(text,low=0,high=4294967295):
@@ -43,7 +46,7 @@ class Bot(object):
def __init__(self,send,now,listener=None):
self.send=send;self.now=now;self.listener=listener
self.queue=[];self.nextSend=0;self.waiting=None
self.allowed={};self.loading=None;self.state=None;self.error=None;self.unsupported=False
self.allowed={};self.locks={};self.loading=None;self.state=None;self.error=None;self.unsupported=False
def Enqueue(self,verb,text):
if verb=='attrs' and self.waiting and self.waiting[2]==text:return
@@ -67,6 +70,14 @@ class Bot(object):
parts.append('%d %d'%(apply,value))
self.error=None;self.Enqueue('start',' '.join(parts));return None
def Lock(self,cell,slot):
if not 0<=slot<5 or cell not in self.locks:return
self.error=None;self.Enqueue('lock','/mt_switchbot 1 lock %d %d'%(cell,slot))
def LockMask(self,cell,vnum):
lock=self.locks.get(cell)
return lock[1] if lock and lock[0]==vnum else None
def Stop(self):self.Enqueue('stop','/mt_switchbot 1 stop')
def Status(self):self.Enqueue('status','/mt_switchbot 1 status')
def Running(self):return bool(self.state and self.state['active'])
@@ -106,6 +117,10 @@ class Bot(object):
if not row or Number(words[1])!=row['cell'] or Number(words[2])!=row['count'] or len(row['rows'])!=row['count']:
raise ValueError('partial')
self.allowed[row['cell']]=row;self.Answered('attrs');self.Notify('attrs')
elif kind=='lock':
# Follows attrs_end (and answers lock). Servers without the lock never send it, so no buttons show.
if len(words)!=4:raise ValueError('lock')
self.locks[Number(words[1])]=(Number(words[2]),Number(words[3],0,31));self.Answered('lock');self.Notify('lock')
elif kind=='state':
if len(words)!=9 or words[8] not in REASONS:raise ValueError('state')
self.state=dict(active=Number(words[1],0,1)==1,cell=Number(words[2]),vnum=Number(words[3]),rolls=Number(words[4]),
@@ -155,13 +170,14 @@ class SwitchbotWindow(ui.Window):
pick=Action(self,18,y,100,'',lambda i=i:self.Pick(i))))
self.pickPrev=Action(self,18,0,80,'上一页',lambda:self.PickPage(-1));self.pickNext=Action(self,0,0,80,'下一页',lambda:self.PickPage(1))
self.startButton=Action(self,18,0,120,'开始洗练',self.Start);self.stopButton=Action(self,146,0,120,'停止',self.bot.Stop)
self.lockButtons=[Action(self,0,0,40,'',lambda i=i:self.ToggleLock(i)) for i in range(5)]
self.status=Text(self,18,0,'',0xffc3b7a1);self.current=Text(self,18,0,'',0xffefdfc3)
self.Reflow();self.Hide()
def Buttons(self):
rows=[b for r in self.rows for b in (r['minus'],r['plus'],r['remove'],r['pick'])]
return [self.closeButton,self.itemPrev,self.itemNext,self.scrollPrev,self.scrollNext,self.limitButton,self.addButton,
self.pickPrev,self.pickNext,self.startButton,self.stopButton]+self.altButtons+rows
self.pickPrev,self.pickNext,self.startButton,self.stopButton]+self.altButtons+self.lockButtons+rows
def Open(self):
self.Reflow();self.Scan();self.SetCenterPosition();self.Show();self.SetTop();self.bot.Status();self.RequestAttrs()
@@ -181,6 +197,7 @@ class SwitchbotWindow(ui.Window):
# The server binds the bot to the item ID and reports its current cell; follow it after a move.
if selected and selected[0] in self.bot.allowed and selected[1]==state['vnum']:
self.bot.allowed[state['cell']]=dict(self.bot.allowed[selected[0]],cell=state['cell'])
if selected[0] in self.bot.locks:self.bot.locks[state['cell']]=self.bot.locks[selected[0]]
selected=(state['cell'],state['vnum'])
self.items=items;self.scrolls=sorted(scrolls.items())
self.itemIndex=next((i for i,v in enumerate(items) if v==selected),
@@ -235,6 +252,14 @@ class SwitchbotWindow(ui.Window):
target=self.alternatives[self.alt][index];maximum=self.Maximum(target[0])
target[1]=max(1,min(maximum,target[1]+direction*Step(maximum)));self.Refresh()
def Locks(self):
selected=self.Selected()
return self.bot.LockMask(*selected) if selected else None
def ToggleLock(self,slot):
selected=self.Selected()
if selected and not self.bot.Running():self.bot.Lock(selected[0],slot);self.Refresh()
def Remove(self,index):del self.alternatives[self.alt][index];self.Refresh()
def Start(self):
@@ -280,6 +305,11 @@ class SwitchbotWindow(ui.Window):
for b in (self.pickPrev,self.pickNext):
if self.picking and pages>1:b.Show()
else:b.Hide()
locks=self.Locks()
for i,b in enumerate(self.lockButtons):
filled=selected and player.GetItemAttribute(player.INVENTORY,selected[0],i)[0]
if locks is None or self.picking or not filled:b.Hide();continue
b.SetText(L(B(u'【锁%d】'%(i+1) if locks>>i&1 else u'锁%d'%(i+1))));b.SetEnabled(not running);b.Show()
self.pickPrev.SetEnabled(self.pickPage>0);self.pickNext.SetEnabled(self.pickPage+1<pages)
self.startButton.SetEnabled(not running and bool(selected) and any(self.alternatives));self.stopButton.SetEnabled(running)
self.status.SetText(L(B(self.StatusText(allowed))));self.current.SetText(self.CurrentText(selected))
@@ -290,14 +320,15 @@ class SwitchbotWindow(ui.Window):
if self.Selected() and allowed is None:return u'正在读取可洗属性…'
if state and (state['active'] or state['reason']!='idle'):
return u'%s · %d / %d 次 · 剩余道具 %d'%(U(REASONS[state['reason']]),state['rolls'],state['limit'],state['scrolls'])
if self.Locks():return u'已锁定的属性在每次洗练中保持不变'
return u'任一方案的属性全部达标即停止;只看前 5 条普通属性'
def CurrentText(self,selected):
if not selected:return ''
parts=[]
parts=[];locks=self.Locks() or 0
for i in range(5):
apply,value=player.GetItemAttribute(player.INVENTORY,selected[0],i)
if apply:parts.append(AffectText(apply,value))
if apply:parts.append((L('[锁]') if locks>>i&1 else '')+AffectText(apply,value))
return ' / '.join(parts)
def Reflow(self):
@@ -313,6 +344,8 @@ class SwitchbotWindow(ui.Window):
bottom=height-104
self.pickPrev.SetPosition(18,bottom);self.pickNext.SetPosition(width-98,bottom)
self.startButton.SetPosition(18,bottom);self.stopButton.SetPosition(146,bottom)
step=(width-292)//5
for i,b in enumerate(self.lockButtons):b.SetPosition(274+i*step,bottom);b.SetSize(step-4,48);b.label.SetPosition((step-4)//2,24)
self.status.SetPosition(18,height-46);self.current.SetPosition(18,height-22)
self.SetCenterPosition();self.Refresh()
+176
View File
@@ -0,0 +1,176 @@
// PORT: VIP panel. The server's VIP cards (38001 7 days, 38002 30 days, sold by 百店老板娘) extend the seven
// original premium services of the account; this window shows what is left of each, asked with /mt_vip, and
// reports card use in the chat. The premium icons (affects 500..506) are the original ones.
// Protocol: metin2-server docs/FEATURE-VIP.md.
static const char c_szVipScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import grp
import net
import ui
import game
import weakref
from mt_biolog import Action, Text, L
g_window=None
g_entry=None
SEND_GAP=1.
TIMEOUT=3.
MAX_DAYS=400
CARDS={38001:7,38002:30}
# PREMIUM_EXP .. PREMIUM_GOLD, the order of MTVIP1 times.
SERVICES=('经验加成','物品掉落翻倍','仓库扩展到 3 页','金币直接进背包','钓鱼成功率提升','结婚亲密度加快','金币掉落翻倍')
ERRORS={'rate':'操作过快,请稍后再试','invalid':'参数无效','full':'VIP 剩余时间不能超过 %d 天'%MAX_DAYS}
def Number(text,low=0,high=2**31-1):
if not text.isdigit() or len(text)>10:raise ValueError(text)
value=int(text)
if not low<=value<=high:raise ValueError(text)
return value
def Remaining(seconds):
seconds=int(seconds)
if seconds<=0:return '未开通'
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600);minutes=rest//60
if days:return '剩余 %d 天 %d 小时'%(days,hours)
if hours:return '剩余 %d 小时 %d 分'%(hours,minutes)
return '剩余 %d 分'%max(1,minutes)
class Status(object):
# Protocol state only. send(text) posts a chat command, now() is seconds.
def __init__(self,send,now,listener=None):
self.send=send;self.now=now;self.listener=listener
self.waiting=None;self.nextSend=0;self.unsupported=False
self.times=None;self.at=0;self.error=None;self.added=None
def Query(self):
self.Tick()
now=self.now()
if self.waiting is not None or now<self.nextSend:return False
self.send('/mt_vip 1 status');self.waiting=now+TIMEOUT;self.nextSend=now+SEND_GAP
return True
def Tick(self):
if self.waiting is not None and self.now()>=self.waiting:
self.waiting=None;self.unsupported=True;self.Notify('unsupported')
def Left(self,index):
if self.times is None:return 0
return max(0,self.times[index]-(self.now()-self.at))
def Notify(self,kind):
if self.listener:self.listener(kind)
def Receive(self,line):
if not line.startswith('MTVIP1 '):return False
words=line.split()
try:
if words[1]=='times' and len(words)==2+len(SERVICES):
self.times=[Number(w) for w in words[2:]];self.at=self.now();self.error=None;kind='times'
elif words[1]=='added' and len(words)==4:
self.added=(Number(words[2]),Number(words[3],1,MAX_DAYS));kind='added'
elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error'
else:return True
except ValueError:return True
if kind!='added':self.waiting=None
self.unsupported=False;self.Notify(kind)
return True
class VipWindow(ui.Window):
def __init__(self,status):
ui.Window.__init__(self);self.SetWindowName('VipWindow');self.AddFlag('float');self.AddFlag('movable')
self.status=status;self.SetSize(332,118+len(SERVICES)*24)
self.heading=Text(self,18,18,'VIP 会员',0xffffd68b)
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
self.names=[Text(self,18,64+i*24,name,0xffe8dec8) for i,name in enumerate(SERVICES)]
self.values=[Text(self,190,64+i*24,'',0xffc3b7a1) for i in range(len(SERVICES))]
self.note=Text(self,18,self.GetHeight()-44,'',0xffc3b7a1)
self.hint=Text(self,18,self.GetHeight()-22,'VIP 卡在各村百店老板娘处购买,使用后全部特权延长',0xff8f8676)
self.second=-1
self.Hide()
def Open(self):
self.status.Query();self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
def Toggle(self):
if self.IsShow():self.Hide()
else:self.Open()
def Refresh(self):
s=self.status
for i,value in enumerate(self.values):
left=s.Left(i)
value.SetText(L(Remaining(left) if s.times is not None else '……'))
value.SetPackedFontColor(0xff9fe08b if left>0 else 0xff8f8676)
if s.unsupported:note='!服务器尚未启用 VIP 卡'
elif s.error:note='!'+ERRORS[s.error]
elif s.times is None:note='正在查询……'
elif any(s.Left(i)>0 for i in range(len(SERVICES))):note='VIP 生效中'
else:note='尚未开通 VIP'
self.note.SetText(L(note))
def OnUpdate(self):
self.status.Tick()
second=int(app.GetTime())
if second!=self.second:self.second=second;self.Refresh()
def OnRender(self):
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=None
class Entry(Action):
def __init__(self,window):
Action.__init__(self,None,0,0,64,'VIP',Toggle)
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('VipEntry');self.OnUpdate()
def OnUpdate(self):
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+408)
def OnStatus(kind):
s=g_status
if kind=='added':
vnum,days=s.added
chat.AppendChat(chat.CHAT_TYPE_INFO,L('VIP 会员已延长 %d 天,全部特权立即生效'%days))
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
if g_window:g_window.Refresh()
g_status=Status(net.SendChatPacket,app.GetTime,OnStatus)
def Toggle():
if g_window:g_window.Toggle()
_open=game.GameWindow.Open
_close=game.GameWindow.Close
_command=game.GameWindow.BINARY_ServerCommand_Run
def Open(self):
global g_window,g_entry
_open(self);g_status.waiting=None;g_status.times=None;g_window=VipWindow(g_status);g_entry=Entry(self)
def Close(self):
global g_window,g_entry
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
if g_window:g_window.Destroy();g_window=None
_close(self)
def Command(self,line):
if g_status.Receive(line):return 1
return _command(self,line)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+7 -1
View File
@@ -184,6 +184,9 @@ class WikiWindow(ui.Window):
if row:self.Select('mobs',row)
else:self.QueryRemote('mob',vnum[1],0)
elif vnum[0]=='items':self.Open(vnum[1])
elif vnum[0]=='preview':
import mt_preview
mt_preview.Preview(vnum[1],vnum[2])
return
row=self.catalogue.FindItem(vnum)
if row:self.Select('items',row)
@@ -232,12 +235,15 @@ class WikiWindow(ui.Window):
else:
cost=self.catalogue.FindItem(offer['cost'])
lines.append((u'研究兑换:'+(cost['name'] if cost else u'#%d'%offer['cost'])+u' ×%d'%offer['amount'],offer['cost'] if cost else 0))
if row['kind'] in ('ITEM_WEAPON','ITEM_ARMOR','ITEM_COSTUME'):
import mt_preview
if mt_preview.Previewable('item',vnum):lines.append((u'模型预览',('preview','item',vnum)))
lines.append((u'查看获取来源',('query','sources',vnum)))
if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum)))
else:
lines=[(u'类型:'+U('矿石' if row['kind']=='STONE' else '怪物')+u' / '+U(RANK_NAMES.get(row['rank'],'普通')),0),(u'等级:%d 生命:%d'%(row['level'],row['hp']),0),
(u'基础攻击:%d—%d'%(row['attack_min'],row['attack_max']),0),
(u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'查看服务端掉落列表',('query','mob',vnum))]
(u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'模型预览',('preview','mob',vnum)),(u'查看服务端掉落列表',('query','mob',vnum))]
if self.remote:lines=self.RemoteLines()
self.links=lines
pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount)
@@ -23,6 +23,7 @@
#include "UserInterface/PythonSystem.h"
#include "../ScriptLib/PythonBoot.h"
#include "ServerClock.h"
#include "UserInterface/CRenderTargetManager.h"
#include "../EterBase/PerfCounters.h"
#if defined(__linux__) || defined(__ANDROID__)
@@ -213,6 +214,7 @@ void CPythonApplication::RenderGame()
CCullingManager::Instance().Process();
CPythonCharacterManager::Instance().Deform();
CRenderTargetManager::Instance().DeformModels(); // PORT: AVA2 model preview
CEffectManager::Instance().Update();
if (!SkipDiagnosticRender("characters")) CPythonBackground::Instance().RenderCharacterShadowToTexture();
@@ -239,6 +241,7 @@ void CPythonApplication::RenderGame()
CPythonBackground::Instance().SetCharacterDirLight();
if (!SkipDiagnosticRender("characters")) CPythonCharacterManager::Instance().Render();
CRenderTargetManager::Instance().RenderModels(); // PORT: AVA2, under the character light
CPythonBackground::Instance().SetBackgroundDirLight();
CPythonBackground::Instance().RenderWater();
@@ -279,6 +282,8 @@ void CPythonApplication::UpdateGame()
s.BuildViewFrustum();
}
CRenderTargetManager::Instance().UpdateModels(); // PORT: AVA2 model preview
TPixelPosition kPPosMainActor;
CPythonPlayer::Instance().NEW_GetMainActorPosition(&kPPosMainActor);
+2
View File
@@ -326,6 +326,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_channel.py)
add_test(NAME port.boss
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py)
add_test(NAME port.chest
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_chest.py)
endif()
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)
+1
View File
@@ -163,6 +163,7 @@ class CGraphicBase
void SetOrtho3D(float hres, float vres, float zmin, float zmax);
void SetPerspective(float fov, float aspect, float nearz, float farz);
float GetFOV();
float GetAspect() { return ms_fAspect; } // PORT: AVA2 model preview restores the perspective
void GetClipPlane(float * fNearY, float * fFarY)
{
*fNearY = ms_fNearY;
+131
View File
@@ -0,0 +1,131 @@
#include "StdAfx.h"
#include "../EterBase/Stl.h"
#include "GrpRenderTargetTexture.h"
#include "StateManager.h"
CGraphicRenderTargetTexture::CGraphicRenderTargetTexture(): m_d3dFormat{ D3DFMT_UNKNOWN }, m_depthStencilFormat{ D3DFMT_UNKNOWN }
{
Initialize();
memset(&m_renderRect, 0, sizeof(m_renderRect));
}
CGraphicRenderTargetTexture::~CGraphicRenderTargetTexture()
{
Reset();
}
void CGraphicRenderTargetTexture::ReleaseTextures()
{
safe_release(m_lpd3dRenderTexture);
safe_release(m_lpd3dRenderTargetSurface);
safe_release(m_lpd3dDepthSurface);
safe_release(m_lpd3dOriginalRenderTarget);
safe_release(m_lpd3dOldDepthBufferSurface);
memset(&m_renderRect, 0, sizeof(m_renderRect));
}
bool CGraphicRenderTargetTexture::Create(const int width, const int height, const D3DFORMAT texFormat, const D3DFORMAT depthFormat)
{
Reset();
m_height = height;
m_width = width;
if (!CreateRenderTexture(width, height, texFormat))
return false;
if (!CreateRenderDepthStencil(width, height, depthFormat))
return false;
return true;
}
void CGraphicRenderTargetTexture::CreateTextures()
{
if (CreateRenderTexture(m_width, m_height, m_d3dFormat))
CreateRenderDepthStencil(m_width, m_height, m_depthStencilFormat);
}
bool CGraphicRenderTargetTexture::CreateRenderTexture(const int width, const int height, const D3DFORMAT format)
{
m_d3dFormat = format;
// PORT: one level (AVA2 asked for a full chain, 0). Only level 0 is a render target on the recording
// device, and the preview is drawn 1:1.
if (FAILED(ms_lpd3dDevice->CreateTexture(width, height, 1, D3DUSAGE_RENDERTARGET, m_d3dFormat, D3DPOOL_DEFAULT, &m_lpd3dRenderTexture)))
return false;
if (FAILED(m_lpd3dRenderTexture->GetSurfaceLevel(0, &m_lpd3dRenderTargetSurface)))
return false;
return true;
}
bool CGraphicRenderTargetTexture::CreateRenderDepthStencil(const int width, const int height, const D3DFORMAT format)
{
m_depthStencilFormat = format;
return (ms_lpd3dDevice->CreateDepthStencilSurface(width, height, m_depthStencilFormat, D3DMULTISAMPLE_NONE, &m_lpd3dDepthSurface)) == D3D_OK;
}
void CGraphicRenderTargetTexture::SetRenderTarget()
{
ms_lpd3dDevice->GetRenderTarget(&m_lpd3dOriginalRenderTarget);
ms_lpd3dDevice->GetDepthStencilSurface(&m_lpd3dOldDepthBufferSurface);
ms_lpd3dDevice->SetRenderTarget(m_lpd3dRenderTargetSurface, m_lpd3dDepthSurface);
}
void CGraphicRenderTargetTexture::ResetRenderTarget()
{
ms_lpd3dDevice->SetRenderTarget(m_lpd3dOriginalRenderTarget, m_lpd3dOldDepthBufferSurface);
safe_release(m_lpd3dOriginalRenderTarget);
safe_release(m_lpd3dOldDepthBufferSurface);
}
void CGraphicRenderTargetTexture::Clear()
{
ms_lpd3dDevice->Clear(0, nullptr, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, 0x00000000 /* PORT: D3DCOLOR_ARGB(0, 0, 0, 0) */, 1.0f, 0);
}
void CGraphicRenderTargetTexture::Render() const
{
D3DSURFACE_DESC desc;
m_lpd3dRenderTargetSurface->GetDesc(&desc);
const auto imgWidth = static_cast<float>(m_renderRect.right - m_renderRect.left);
const auto imgHeight = static_cast<float>(m_renderRect.bottom - m_renderRect.top);
const auto su = 0.0f;
const auto sv = 0.0f;
const auto eu = imgWidth / static_cast<float>(desc.Width);
const auto ev = imgHeight / static_cast<float>(desc.Height);
TPDTVertex pVertices[4];
pVertices[0].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.top - 0.5f, 0.0f);
pVertices[0].texCoord = TTextureCoordinate(su, sv);
pVertices[0].diffuse = 0xffffffff;
pVertices[1].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.top - 0.5f, 0.0f);
pVertices[1].texCoord = TTextureCoordinate(eu, sv);
pVertices[1].diffuse = 0xffffffff;
pVertices[2].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.bottom - 0.5f, 0.0f);
pVertices[2].texCoord = TTextureCoordinate(su, ev);
pVertices[2].diffuse = 0xffffffff;
pVertices[3].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.bottom - 0.5f, 0.0f);
pVertices[3].texCoord = TTextureCoordinate(eu, ev);
pVertices[3].diffuse = 0xffffffff;
if (SetPDTStream(pVertices, 4))
{
SetDefaultIndexBuffer(DEFAULT_IB_FILL_RECT);
STATEMANAGER.SetTexture(0, GetRenderTargetTexture());
STATEMANAGER.SetTexture(1, nullptr);
STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE);
STATEMANAGER.DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 4, 0, 2);
}
}
void CGraphicRenderTargetTexture::Reset()
{
Destroy();
ReleaseTextures();
m_d3dFormat = D3DFMT_UNKNOWN;
m_depthStencilFormat = D3DFMT_UNKNOWN;
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
// PORT: AVA2 CGraphicRenderTargetTexture (model preview). On the recording device a 32-bit render-target
// texture is a GPU colour target ("rt:<id>:<w>x<h>:c"); see platform/EterLib/RecordingDevice.cpp.
#include "GrpBase.h"
#include "GrpTexture.h"
class CGraphicRenderTargetTexture : public CGraphicTexture
{
public:
CGraphicRenderTargetTexture();
virtual ~CGraphicRenderTargetTexture();
public:
bool Create(int width, int height, D3DFORMAT texFormat, D3DFORMAT depthFormat);
void CreateTextures();
bool CreateRenderTexture(int width, int height, D3DFORMAT format);
bool CreateRenderDepthStencil(int width, int height, D3DFORMAT format);
void SetRenderTarget();
void ResetRenderTarget();
void SetRenderingRect(RECT* rect){ m_renderRect = *rect; }
RECT* GetRenderingRect(){ return &m_renderRect; }
LPDIRECT3DTEXTURE8 GetRenderTargetTexture() const { return m_lpd3dRenderTexture; }
void ReleaseTextures();
static void Clear();
void Render() const;
protected:
void Reset();
LPDIRECT3DTEXTURE8 m_lpd3dRenderTexture{};
LPDIRECT3DSURFACE8 m_lpd3dRenderTargetSurface{};
LPDIRECT3DSURFACE8 m_lpd3dDepthSurface{};
LPDIRECT3DSURFACE8 m_lpd3dOriginalRenderTarget{};
LPDIRECT3DSURFACE8 m_lpd3dOldDepthBufferSurface{};
D3DFORMAT m_d3dFormat;
D3DFORMAT m_depthStencilFormat;
RECT m_renderRect{};
};
+324
View File
@@ -0,0 +1,324 @@
#include "StdAfx.h"
#include "CRenderTarget.h"
#include "InstanceBase.h"
#include "../EterLib/Camera.h"
#include "../EterLib/GrpImageInstance.h"
#include "../EterLib/ResourceManager.h"
#include "../EterLib/StateManager.h"
#include "../EterPythonLib/PythonGraphic.h"
CRenderTarget::~CRenderTarget() {}
CRenderTarget::CRenderTarget(const DWORD width, const DWORD height) :
m_pModel(nullptr),
m_background(nullptr),
m_modelRotation(0),
m_pmodelRotationActive(true),
m_visible(false),
m_bShowModel(true),
m_bScale(1.0),
m_v3AngularVelocity(0.0f, 0.0f, 0.0f),
m_v3Eye(0.0f, 0.0f, 0.0f),
m_v3Target(0.0f, 0.0f, 0.0f),
m_v3Up(0.0f, 0.0f, 1.0f)
{
auto pTex = std::make_unique<CGraphicRenderTargetTexture>();
if (!pTex->Create(width, height, D3DFMT_X8R8G8B8, D3DFMT_D16))
{
TraceError("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture %dx%d", width, height);
throw std::runtime_error("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture");
}
m_renderTargetTexture = std::move(pTex);
}
void CRenderTarget::RenderTexture() const
{
m_renderTargetTexture->Render();
}
void CRenderTarget::SetRenderingRect(RECT* rect) const
{
m_renderTargetTexture->SetRenderingRect(rect);
}
void CRenderTarget::CreateTextures() const
{
m_renderTargetTexture->CreateTextures();
}
void CRenderTarget::ReleaseTextures() const
{
m_renderTargetTexture->ReleaseTextures();
}
void CRenderTarget::ResetModel()
{
// PORT: AVA2 also cleared its wiki affects here; the port has none.
m_pModel.reset();
}
void CRenderTarget::SetScale(float fScale)
{
m_bScale = fScale;
if (IsReady())
SelectModel(m_pModel->GetRace(), true);
}
void CRenderTarget::SelectModel(const int index, bool isFromScale)
{
if (m_pModel && !isFromScale && m_pModel->GetRace() == index)
return;
if (isFromScale && m_pModel)
m_pModel.reset();
CInstanceBase::SCreateData kCreateData{};
// PORT: AVA2 created every model as TYPE_NPC. Here CActorInstance reserves the armor/weapon/hair parts for
// TYPE_PC only, so a player race created as an NPC crashes on the first ChangeArmor.
kCreateData.m_bType = index < NRaceData::JOB_MAX_NUM * 2 ? CActorInstance::TYPE_PC : CActorInstance::TYPE_NPC;
kCreateData.m_dwRace = index;
auto model = std::make_unique<CInstanceBase>();
if (!model->Create(kCreateData))
{
ResetModel();
return;
}
m_pModel = std::move(model);
// PORT: the preview has no name; the empty text tail Create attaches would follow the world origin.
m_pModel->DetachTextTail();
m_pModel->NEW_SetPixelPosition(TPixelPosition(0, 0, 0));
m_pModel->SetRotation(0.0f);
float fTargetHeight = 110.0f;
m_v3Eye.x = 0.0f;
m_v3Eye.y = -1000.0f;
m_v3Eye.z = 600.0f;
m_v3Target.x = 0.0f;
m_v3Target.y = 0.0f;
m_v3Target.z = 95.0f;
if (index > (NRaceData::JOB_MAX_NUM * 2))
{
const auto ptrGraphicInstance = m_pModel->GetGraphicThingInstancePtr();
if (ptrGraphicInstance) {
const float fRaceHeight = (ptrGraphicInstance->GetHeight()/m_bScale);
fTargetHeight = fRaceHeight / 2.0f;
m_v3Eye.y = -(fRaceHeight * 8.9f);
m_v3Eye.z = 0.0f;
m_v3Target.z = fRaceHeight / 2.0f;
}
}
auto& camera_manager = CCameraManager::instance();
camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA);
camera_manager.GetCurrentCamera()->SetTargetHeight(fTargetHeight);
camera_manager.ResetToPreviousCamera();
m_modelRotation = 0.0f;
m_v3AngularVelocity = D3DXVECTOR3(0.0f, 0.0f, 0.0f);
}
bool CRenderTarget::CreateBackground(const char* imgPath, const DWORD width, const DWORD height)
{
if (m_background)
return false;
m_background = std::make_unique<CGraphicImageInstance>();
const auto graphic_image = dynamic_cast<CGraphicImage*>(CResourceManager::instance().GetResourcePointer(imgPath));
if (!graphic_image)
{
m_background.reset();
return false;
}
m_background->SetImagePointer(graphic_image);
return true;
}
// PORT: AVA2 drew its background in a pass of its own through CGraphicImageInstance::Render. The port's image
// instance goes to the UI command list, not the device, so RenderModel draws the background into the target
// itself: one textured quad that fills it under a target-sized ortho.
void CRenderTarget::RenderBackground() const
{
if (!m_background || m_background->IsEmpty())
return;
CGraphicTexture* texture = m_background->GetTexturePointer();
if (!texture)
return;
const auto& rectRender = *m_renderTargetTexture->GetRenderingRect();
const auto width = static_cast<float>(rectRender.right - rectRender.left);
const auto height = static_cast<float>(rectRender.bottom - rectRender.top);
auto& python_graphic = CPythonGraphic::Instance();
python_graphic.PushState();
STATEMANAGER.SaveTransform(D3DTS_WORLD, &CGraphicBase::GetIdentityMatrix());
STATEMANAGER.SetTransform(D3DTS_VIEW, &CGraphicBase::GetIdentityMatrix());
python_graphic.SetOrtho2D(width, height, 1.0f);
STATEMANAGER.SaveRenderState(D3DRS_ZENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_ZWRITEENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_LIGHTING, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_FOGENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_ALPHABLENDENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_ALPHATESTENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MINFILTER, D3DTEXF_LINEAR);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MAGFILTER, D3DTEXF_LINEAR);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MIPFILTER, D3DTEXF_NONE);
STATEMANAGER.SaveTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
TPDTVertex vertices[4];
const float x[4] = { 0.0f, width, 0.0f, width };
const float y[4] = { 0.0f, 0.0f, height, height };
for (int i = 0; i < 4; ++i)
{
vertices[i].position = TPosition(x[i], y[i], 0.0f);
vertices[i].texCoord = TTextureCoordinate(x[i] / width, y[i] / height);
vertices[i].diffuse = 0xffffffff;
}
STATEMANAGER.SetTexture(0, texture->GetD3DTexture());
STATEMANAGER.SetTexture(1, nullptr);
STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE);
STATEMANAGER.DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, vertices, sizeof(TPDTVertex));
STATEMANAGER.RestoreTextureStageState(1, D3DTSS_COLOROP);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MIPFILTER);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MAGFILTER);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MINFILTER);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_ALPHAOP);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLOROP);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLORARG1);
STATEMANAGER.RestoreRenderState(D3DRS_CULLMODE);
STATEMANAGER.RestoreRenderState(D3DRS_ALPHATESTENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_ALPHABLENDENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_FOGENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_LIGHTING);
STATEMANAGER.RestoreRenderState(D3DRS_ZWRITEENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_ZENABLE);
STATEMANAGER.RestoreTransform(D3DTS_WORLD);
python_graphic.PopState();
}
void CRenderTarget::UpdateModel()
{
if (!IsReady())
return;
if (m_pmodelRotationActive && m_bShowModel)
{
if (m_modelRotation < 360.0f)
m_modelRotation += 1.0f / 60 * 60.0f;
else
m_modelRotation = 0.0f / 60 * 60.0f;
m_pModel->SetRotation(m_modelRotation);
}
else
m_pModel->SetRotation(0.0f);
m_pModel->Transform();
if(m_pmodelRotationActive)
m_pModel->GetGraphicThingInstanceRef().RotationProcess();
}
void CRenderTarget::DeformModel()
{
if (!IsReady())
return;
// PORT: stands in for AVA2's SetAlwaysRender. The model sits at the world origin, so the culling pass hides it.
m_pModel->GetGraphicThingInstanceRef().Show();
m_pModel->Deform();
}
void CRenderTarget::RenderModel()
{
if (!IsReady())
return;
auto& python_graphic = CPythonGraphic::Instance();
auto& camera_manager = CCameraManager::instance();
auto& state_manager = CStateManager::Instance();
auto& rectRender = *m_renderTargetTexture->GetRenderingRect();
const auto width = static_cast<float>(rectRender.right - rectRender.left);
const auto height = static_cast<float>(rectRender.bottom - rectRender.top);
// PORT: nothing to draw before the window has given its rect.
if (width <= 0.0f || height <= 0.0f)
return;
m_renderTargetTexture->SetRenderTarget();
m_renderTargetTexture->Clear();
python_graphic.SetViewport(0.0f, 0.0f, width, height);
RenderBackground();
python_graphic.ClearDepthBuffer();
const auto fov = python_graphic.GetFOV();
const auto aspect = python_graphic.GetAspect();
float near_y, far_y;
python_graphic.GetClipPlane(&near_y, &far_y);
state_manager.SetRenderState(D3DRS_FOGENABLE, FALSE);
python_graphic.PushState();
camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA);
camera_manager.GetCurrentCamera()->SetViewParams(m_v3Eye, m_v3Target, m_v3Up);
camera_manager.GetCurrentCamera()->RotateEyeAroundTarget(m_v3AngularVelocity.z, m_v3AngularVelocity.x);
python_graphic.UpdateViewMatrix();
python_graphic.SetPerspective(10.0f, width / height, 100.0f, 15000.0f);
if (m_bShowModel)
{
m_pModel->GetGraphicThingInstanceRef().Show();
m_pModel->Update();
m_pModel->Render();
}
// PORT: AVA2's RenderAllAttachingEffectWiki (attached effects) is not ported.
camera_manager.ResetToPreviousCamera();
python_graphic.RestoreViewport();
python_graphic.PopState();
python_graphic.SetPerspective(fov, aspect, near_y, far_y);
m_renderTargetTexture->ResetRenderTarget();
state_manager.SetRenderState(D3DRS_FOGENABLE, TRUE);
}
void CRenderTarget::SetModelV3Eye(float x, float y, float z)
{
m_v3Eye.x = x;
m_v3Eye.y = y;
m_v3Eye.z = z;
}
void CRenderTarget::SetModelV3Target(float x, float y, float z)
{
m_v3Target.x = x;
m_v3Target.y = y;
m_v3Target.z = z;
}
void CRenderTarget::RotateEyeAroundTarget(float fPitchDegree, float fRollDegree)
{
m_v3AngularVelocity.x = fRollDegree;
m_v3AngularVelocity.z = fPitchDegree;
}
void CRenderTarget::Zoom(float fRatio)
{
if (fRatio == 1.0f)
return;
D3DXVECTOR3 v3Temp = m_v3Eye - m_v3Target;
v3Temp *= fRatio;
m_v3Eye = v3Temp + m_v3Target;
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// PORT: AVA2 CRenderTarget (EterLib/CRenderTarget.h): one model drawn into its own colour render target, which
// a UI window then shows. It lives here, not in EterLib, because it owns a CInstanceBase.
#include <memory>
#include "../EterLib/GrpRenderTargetTexture.h"
class CInstanceBase;
class CGraphicImageInstance;
class CRenderTarget
{
public:
CRenderTarget(DWORD width, DWORD height);
~CRenderTarget();
void SetVisibility(bool isShow){ m_visible = isShow; }
void SetRotation(bool bRot) { m_pmodelRotationActive = bRot; }
bool IsReady() { return (m_visible && m_pModel); }
std::unique_ptr<CInstanceBase>& GetModel() { return m_pModel; }
void RotateEyeAroundTarget(float fPitchDegree, float fRollDegree);
void Zoom(float fRatio);
void SelectModel(int index, bool isFromScale= false);
void ResetModel();
void UpdateModel();
void DeformModel();
void RenderModel();
void SetRenderingRect(RECT* rect) const;
void CreateTextures() const;
void ReleaseTextures() const;
void RenderTexture() const;
bool CreateBackground(const char* imgPath, DWORD width, DWORD height);
void RenderBackground() const;
void SetModelV3Eye(float x, float y, float z);
void SetModelV3Target(float x, float y, float z);
void SetModelStatus(bool bStatus) { m_bShowModel = bStatus; }
float GetScale() { return m_bScale; }
void SetScale(float fScale);
bool IsShow() { return m_visible; }
private:
std::unique_ptr<CInstanceBase> m_pModel;
std::unique_ptr<CGraphicImageInstance> m_background;
std::unique_ptr<CGraphicRenderTargetTexture> m_renderTargetTexture;
float m_modelRotation;
bool m_pmodelRotationActive;
bool m_visible;
bool m_bShowModel;
float m_bScale;
D3DXVECTOR3 m_v3AngularVelocity;
D3DXVECTOR3 m_v3Eye;
D3DXVECTOR3 m_v3Target;
D3DXVECTOR3 m_v3Up;
};
@@ -0,0 +1,74 @@
#include "StdAfx.h"
#include "CRenderTargetManager.h"
#include "../EterBase/Stl.h"
#include <memory>
CRenderTargetManager::CRenderTargetManager(){}
CRenderTargetManager::~CRenderTargetManager()
{
Destroy();
}
void CRenderTargetManager::Destroy()
{
m_renderTargets.clear();
}
void CRenderTargetManager::DeformModels()
{
for (const auto& elem : m_renderTargets)
elem.second->DeformModel();
}
void CRenderTargetManager::UpdateModels()
{
for (auto& elem : m_renderTargets)
elem.second->UpdateModel();
}
// PORT: the background is drawn into the target by RenderModel (CRenderTarget::RenderBackground).
void CRenderTargetManager::RenderBackgrounds()
{
}
void CRenderTargetManager::RenderModels()
{
for (const auto& elem : m_renderTargets)
elem.second->RenderModel();
}
void CRenderTargetManager::CreateRenderTargetTextures()
{
for (const auto& elem : m_renderTargets)
elem.second->CreateTextures();
}
void CRenderTargetManager::ReleaseRenderTargetTextures()
{
for (const auto& elem : m_renderTargets)
elem.second->ReleaseTextures();
}
bool CRenderTargetManager::CreateRenderTarget(const int index, const int width, const int height)
{
// PORT: the size check guards the recording device, which allocates the target's bytes up front.
if (index < 1 || GetRenderTarget(index) || width < 1 || height < 1 || width > 2048 || height > 2048)
return false;
try
{
m_renderTargets.emplace(index, std::make_shared<CRenderTarget>(width, height));
}
catch (const std::exception&)
{
return false;
}
return true;
}
void CRenderTargetManager::RemoveRenderTarget(const int index)
{
const auto it = m_renderTargets.find(index);
if (it != m_renderTargets.end())
{
it->second->ResetModel();
m_renderTargets.erase(index);
}
}
std::shared_ptr<CRenderTarget> CRenderTargetManager::GetRenderTarget(const int index)
{
const auto it = m_renderTargets.find(index);
if (it != m_renderTargets.end())
return it->second;
return nullptr;
}
@@ -0,0 +1,31 @@
#pragma once
// PORT: AVA2 CRenderTargetManager (EterLib/CRenderTargetManager.h).
#include <unordered_map>
#include <cstdint>
#include "../EterBase/Singleton.h"
#include <memory>
#include "CRenderTarget.h"
class CRenderTargetManager : public CSingleton<CRenderTargetManager>
{
public:
CRenderTargetManager();
virtual ~CRenderTargetManager();
std::shared_ptr<CRenderTarget> GetRenderTarget(int index);
bool CreateRenderTarget(int index, int width, int height);
void CreateRenderTargetTextures();
void ReleaseRenderTargetTextures();
void RemoveRenderTarget(const int index);
void Destroy();
void DeformModels();
void UpdateModels();
void RenderBackgrounds();
void RenderModels();
protected:
std::unordered_map<int, std::shared_ptr<CRenderTarget>> m_renderTargets;
};
@@ -618,6 +618,7 @@ void initItem()
PyModule_AddIntConstant(poModule, "ITEM_TYPE_POLYMORPH", CItemData::ITEM_TYPE_POLYMORPH);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_TREASURE_BOX", CItemData::ITEM_TYPE_TREASURE_BOX);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_TREASURE_KEY", CItemData::ITEM_TYPE_TREASURE_KEY);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_GIFTBOX", CItemData::ITEM_TYPE_GIFTBOX); // PORT: batch chest opening
PyModule_AddIntConstant(poModule, "ITEM_TYPE_PICK", CItemData::ITEM_TYPE_PICK);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_BLEND", CItemData::ITEM_TYPE_BLEND);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_DS", CItemData::ITEM_TYPE_DS);
@@ -0,0 +1,282 @@
// PORT: AVA2 CPythonRenderTargetModule (module renderTarget), the subset the model preview uses. AVA2's window
// manager created the target for a registered window and drew it; here Create and RenderTexture do that, called
// from the script's own window (ScriptLib/ModelPreviewScript.inc).
#include "StdAfx.h"
#include "CRenderTargetManager.h"
#include "InstanceBase.h"
#include "../GameLib/ItemManager.h"
namespace
{
// The default of CPythonApplication's camera zoom speed (PythonApplicationCamera.cpp), which AVA2 read.
const float c_fRenderTargetZoomSpeed = 0.05f;
std::shared_ptr<CRenderTarget> Target(PyObject* poArgs)
{
int iIndex;
if (!PyTuple_GetInteger(poArgs, 0, &iIndex))
return nullptr;
return CRenderTargetManager::Instance().GetRenderTarget(iIndex);
}
bool Integer(PyObject* poArgs, int pos, int* value)
{
return PyTuple_GetInteger(poArgs, pos, value);
}
}
PyObject* renderTargetCreate(PyObject* poSelf, PyObject* poArgs)
{
int iIndex, iWidth, iHeight;
if (!Integer(poArgs, 0, &iIndex) || !Integer(poArgs, 1, &iWidth) || !Integer(poArgs, 2, &iHeight))
return Py_BadArgument();
return Py_BuildValue("i", CRenderTargetManager::Instance().CreateRenderTarget(iIndex, iWidth, iHeight) ? 1 : 0);
}
PyObject* renderTargetRemove(PyObject* poSelf, PyObject* poArgs)
{
int iIndex;
if (!Integer(poArgs, 0, &iIndex))
return Py_BadArgument();
CRenderTargetManager::Instance().RemoveRenderTarget(iIndex);
return Py_BuildNone();
}
PyObject* renderTargetExists(PyObject* poSelf, PyObject* poArgs)
{
int iIndex;
if (!Integer(poArgs, 0, &iIndex))
return Py_BadArgument();
return Py_BuildValue("i", CRenderTargetManager::Instance().GetRenderTarget(iIndex) ? 1 : 0);
}
PyObject* renderTargetRenderTexture(PyObject* poSelf, PyObject* poArgs)
{
int l, t, r, b;
if (!Integer(poArgs, 1, &l) || !Integer(poArgs, 2, &t) || !Integer(poArgs, 3, &r) || !Integer(poArgs, 4, &b))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsShow())
{
RECT rect = { l, t, r, b };
target->SetRenderingRect(&rect);
target->RenderTexture();
}
return Py_BuildNone();
}
PyObject* renderTargetSelectModel(PyObject* poSelf, PyObject* poArgs)
{
int modelIndex;
if (!Integer(poArgs, 1, &modelIndex))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SelectModel(modelIndex);
return Py_BuildValue("i", target && target->GetModel() ? 1 : 0);
}
PyObject* renderTargetSetArmor(PyObject* poSelf, PyObject* poArgs)
{
int iItemVnum;
if (!Integer(poArgs, 1, &iItemVnum))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->GetModel()->ChangeArmor(iItemVnum);
return Py_BuildNone();
}
PyObject* renderTargetSetWeapon(PyObject* poSelf, PyObject* poArgs)
{
int iItemVnum;
if (!Integer(poArgs, 1, &iItemVnum))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->GetModel()->ChangeWeapon(iItemVnum);
return Py_BuildNone();
}
PyObject* renderTargetSetHair(PyObject* poSelf, PyObject* poArgs)
{
int iItemVnum;
if (!Integer(poArgs, 1, &iItemVnum))
return Py_BadArgument();
bool isValue = false;
const auto target = Target(poArgs);
if (target)
{
PyTuple_GetBoolean(poArgs, 2, &isValue);
if (!isValue)
{
CItemData* pItemData;
if (!CItemManager::Instance().GetItemDataPointer(iItemVnum, &pItemData))
return Py_BuildNone();
iItemVnum = pItemData->GetValue(3);
}
if (target->IsReady())
target->GetModel()->SetHair(iItemVnum);
}
return Py_BuildNone();
}
PyObject* renderTargetSetMount(PyObject* poSelf, PyObject* poArgs)
{
int iRace;
if (!Integer(poArgs, 1, &iRace))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->GetModel()->MountHorse(iRace);
return Py_BuildNone();
}
PyObject* renderTargetSetRotation(PyObject* poSelf, PyObject* poArgs)
{
bool bRot;
if (!PyTuple_GetBoolean(poArgs, 1, &bRot))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetRotation(bRot);
return Py_BuildNone();
}
PyObject* renderTargetSetVisibility(PyObject* poSelf, PyObject* poArgs)
{
bool isShow;
if (!PyTuple_GetBoolean(poArgs, 1, &isShow))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetVisibility(isShow);
return Py_BuildNone();
}
PyObject* renderTargetSetBackground(PyObject* poSelf, PyObject* poArgs)
{
char* szPathName;
if (!PyTuple_GetString(poArgs, 1, &szPathName))
return Py_BadArgument();
const auto target = Target(poArgs);
// PORT: the background is stretched over the target when drawn, so the sizes AVA2 passed are unused.
return Py_BuildValue("i", target && target->CreateBackground(szPathName, 0, 0) ? 1 : 0);
}
PyObject* renderTargetResetModel(PyObject* poSelf, PyObject* poArgs)
{
const auto target = Target(poArgs);
if (target)
target->ResetModel();
return Py_BuildNone();
}
PyObject* renderTargetDestroy(PyObject* poSelf, PyObject* poArgs)
{
CRenderTargetManager::Instance().Destroy();
return Py_BuildNone();
}
PyObject* renderTargetSetModelV3Eye(PyObject* poSelf, PyObject* poArgs)
{
float x, y, z;
if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetModelV3Eye(x, y, z);
return Py_BuildNone();
}
PyObject* renderTargetSetModelV3Target(PyObject* poSelf, PyObject* poArgs)
{
float x, y, z;
if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetModelV3Target(x, y, z);
return Py_BuildNone();
}
PyObject* renderTargetZoom(PyObject* poSelf, PyObject* poArgs)
{
int iDirection;
if (!Integer(poArgs, 1, &iDirection))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->Zoom(1.0f + c_fRenderTargetZoomSpeed * float(iDirection));
return Py_BuildNone();
}
PyObject* renderTargetRotateEyeAroundTarget(PyObject* poSelf, PyObject* poArgs)
{
float fPitch, fRoll;
if (!PyTuple_GetFloat(poArgs, 1, &fPitch) || !PyTuple_GetFloat(poArgs, 2, &fRoll))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->RotateEyeAroundTarget(fPitch, fRoll);
return Py_BuildNone();
}
PyObject* renderTargetSetScale(PyObject* poSelf, PyObject* poArgs)
{
float fScale;
if (!PyTuple_GetFloat(poArgs, 1, &fScale))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetScale(fScale);
return Py_BuildNone();
}
PyObject* renderTargetIsShow(PyObject* poSelf, PyObject* poArgs)
{
const auto target = Target(poArgs);
return Py_BuildValue("i", target ? target->IsShow() : 0);
}
PyObject* renderTargetSetMotionIndex(PyObject* poSelf, PyObject* poArgs)
{
int iMotionIndex;
if (!Integer(poArgs, 1, &iMotionIndex))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
return Py_BuildValue("i", target->GetModel()->GetGraphicThingInstanceRef().InterceptOnceMotion(iMotionIndex) ? 1 : 0);
return Py_BuildValue("i", 0);
}
void initRenderTarget()
{
static PyMethodDef s_methods[] =
{
{ "Create", renderTargetCreate, METH_VARARGS },
{ "Remove", renderTargetRemove, METH_VARARGS },
{ "Exists", renderTargetExists, METH_VARARGS },
{ "RenderTexture", renderTargetRenderTexture, METH_VARARGS },
{ "SelectModel", renderTargetSelectModel, METH_VARARGS },
{ "SetVisibility", renderTargetSetVisibility, METH_VARARGS },
{ "SetBackground", renderTargetSetBackground, METH_VARARGS },
{ "SetArmor", renderTargetSetArmor, METH_VARARGS },
{ "SetWeapon", renderTargetSetWeapon, METH_VARARGS },
{ "SetHair", renderTargetSetHair, METH_VARARGS },
{ "SetMount", renderTargetSetMount, METH_VARARGS },
{ "SetRotation", renderTargetSetRotation, METH_VARARGS },
{ "ResetModel", renderTargetResetModel, METH_VARARGS },
{ "Destroy", renderTargetDestroy, METH_VARARGS },
{ "SetModelV3Eye", renderTargetSetModelV3Eye, METH_VARARGS },
{ "SetModelV3Target", renderTargetSetModelV3Target, METH_VARARGS },
{ "Zoom", renderTargetZoom, METH_VARARGS },
{ "RotateEyeAroundTarget", renderTargetRotateEyeAroundTarget, METH_VARARGS },
{ "SetScale", renderTargetSetScale, METH_VARARGS },
{ "IsShow", renderTargetIsShow, METH_VARARGS },
{ "SetMotionIndex", renderTargetSetMotionIndex, METH_VARARGS },
{ NULL, NULL, 0 },
};
Py_InitModule("renderTarget", s_methods);
}
+1
View File
@@ -70,6 +70,7 @@ void initMessenger();
void initsafebox();
void initguild();
void initServerStateChecker();
void initRenderTarget(); // PORT: AVA2 CPythonRenderTargetModule
extern const std::string& ApplicationStringTable_GetString(DWORD dwID);
extern const std::string& ApplicationStringTable_GetString(DWORD dwID, LPCSTR szKey);
+26 -4
View File
@@ -2,7 +2,9 @@
// Runtime loading remains the original 40250 path and binary format.
// An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few
// item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the
// server enforces. An optional 14-row stone overlay restores extracted +6 stone data.
// server enforces. An optional 14-row stone overlay restores extracted +6 stone data. An optional shape table
// (vnum, size, flags, anti flags) mirrors items the server gives a new use (tools/server/vip-card), so they
// take a cell, stack and refuse NPC sale as the server does.
#include "GameLib/StdAfx.h"
#include "GameLib/ItemData.h"
#include "UserInterface/StdAfx.h"
@@ -25,6 +27,7 @@ namespace {
using Item = CItemData::TItemTable;
using Mob = CPythonNonPlayer::TMobTable;
std::map<DWORD, std::array<long, 11>> stones;
std::map<DWORD, std::array<DWORD, 3>> shapes;
constexpr std::array<DWORD, 4> item_key{{173217, 72619434, 408587239, 27973291}};
constexpr std::array<DWORD, 4> mob_key{{4813894, 18955, 552631, 6822045}};
@@ -109,6 +112,12 @@ void apply_limits<Item>(Item& record, const std::map<DWORD, Limits>& limits, std
record.aApplies[0].bType = s[3]; record.aApplies[0].lValue = s[4];
for (int i = 0; i < 6; ++i) record.alValues[i] = s[5+i];
}
const auto shape = shapes.find(record.dwVnum);
if (shape != shapes.end()) {
record.bSize = static_cast<BYTE>(shape->second[0]);
record.dwFlags = shape->second[1];
record.dwAntiFlags = shape->second[2];
}
const auto found = limits.find(record.dwVnum);
if (found == limits.end()) return;
std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits));
@@ -120,6 +129,8 @@ bool limits_field(std::size_t, DWORD, const std::map<DWORD, Limits>&) { return f
template <>
bool limits_field<Item>(std::size_t offset, DWORD vnum, const std::map<DWORD, Limits>& limits) {
if (shapes.count(vnum) && (offset == offsetof(Item, bSize) ||
(offset >= offsetof(Item, dwAntiFlags) && offset < offsetof(Item, dwFlags)+sizeof(Item::dwFlags)))) return true;
if (stones.count(vnum) && (offset == offsetof(Item, bType) || offset == offsetof(Item, bSubType) ||
(offset >= offsetof(Item, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) ||
(offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) ||
@@ -199,14 +210,25 @@ std::vector<BYTE> patch(const char* source, const char* names_path,
} // namespace
int main(int argc, char** argv) {
if (argc != 7 && argc != 8 && argc != 9) {
if (argc < 7 || argc > 10) {
std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob"
" [item_limits [item_stones]]\n";
" [item_limits [item_stones [item_shapes]]]\n";
return 2;
}
try {
static CLZO lzo;
if (argc == 9) {
if (argc == 10) {
std::ifstream stream(argv[9]);
if (!stream) throw std::runtime_error("cannot read item shapes");
std::string line;
while (std::getline(stream, line)) {
if (line.empty() || line[0] == '#') continue;
std::istringstream row(line); DWORD vnum; std::array<DWORD,3> values{};
if (!(row >> vnum >> values[0] >> values[1] >> values[2]) || values[0] < 1 || values[0] > 3 ||
!shapes.emplace(vnum, values).second) throw std::runtime_error("bad item shape row: " + line);
}
}
if (argc >= 9) {
std::ifstream stream(argv[8]);
if (!stream) throw std::runtime_error("cannot read stone overlay");
std::string line;
+133
View File
@@ -0,0 +1,133 @@
import re
import sys
import types
import unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
class Widget(object):
def __init__(self,*a,**k):self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0)
def __getattr__(self,name):return lambda *a,**k:None
def Show(self):self.shown=True
def Hide(self):self.shown=False
def IsShow(self):return self.shown
def SetText(self,text):self.text=text
def SetPosition(self,x,y):self.pos=(x,y)
def GetGlobalPosition(self):return self.pos
def SetSize(self,w,h):self.size=(w,h)
def GetWidth(self):return self.size[0]
def GetHeight(self):return self.size[1]
class Action(Widget):
def __init__(self,parent,x,y,width,text,event):
Widget.__init__(self);self.pos=(x,y);self.event=event;self.enabled=True;self.text=text;self.shown=True
def SetEnabled(self,enabled):self.enabled=enabled
NAMES={50100:'Box',27001:'Potion',110000:'Soul',30000:'Ore'}
TYPES={50100:23,27001:3,30000:5}
class ChestTests(unittest.TestCase):
def setUp(self):
self.time=1.;self.sent=[];self.chat=[];self.used=[];self.ctrl=False;self.bag={}
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','item','net','player','ui','uiInventory','mt_biolog','mt_wiki')}
app=mods['app'];app.GetTime=lambda:self.time;app.DIK_LCONTROL=29;app.DIK_RCONTROL=157
app.IsPressed=lambda key:self.ctrl and key==29
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['net'].SendChatPacket=self.sent.append
selected=[0];it=mods['item']
it.ITEM_TYPE_GIFTBOX=23;it.SelectItem=lambda v:selected.__setitem__(0,v)
it.GetItemName=lambda:NAMES.get(selected[0],'?');it.GetItemType=lambda:TYPES.get(selected[0],0)
pl=mods['player'];pl.INVENTORY_PAGE_SIZE=45;pl.INVENTORY_PAGE_COUNT=5
pl.GetItemIndex=lambda c:self.bag.get(c,(0,0))[0]
pl.GetItemCountByVnum=lambda v:sum(n for vnum,n in self.bag.values() if vnum==v)
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,
BINARY_ServerCommand_Run=lambda s,line:7))
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(UseItemSlot=lambda s,slot:self.used.append(slot),
_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+45))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
mods['ui'].Window=Widget
mods['mt_biolog'].Action=Action;mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:Widget();mods['mt_biolog'].L=lambda t:t
mods['mt_wiki'].B=lambda t,limit=0:t
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_chest'};exec(script('ChestScript.inc'),self.ns)
self.opener=self.ns['Opener'](self.sent.append,lambda:self.time)
def tearDown(self):
for name,value in self.previous.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
def test_open_sends_once_and_collects_gains_until_done(self):
self.assertTrue(self.opener.Open(50,7));self.assertFalse(self.opener.Open(50,7))
self.assertEqual(self.sent,['/mt_chest 1 open 50 7'])
self.time+=1;self.assertFalse(self.opener.Open(50,7)) # still waiting for the reply
for line in ('MTCH1 got 27001 7','MTCH1 got 110000 2','MTCH1 done 50100 7 7 done 700'):
self.assertTrue(self.opener.Receive(line))
r=self.opener.result
self.assertEqual((r['vnum'],r['asked'],r['opened'],r['reason'],r['gold']),(50100,7,7,'done',700))
self.assertEqual(r['gains'],[(27001,7),(110000,2)]);self.assertEqual(self.opener.gains,[])
self.assertTrue(self.opener.Open(50,500));self.assertEqual(self.sent[-1],'/mt_chest 1 open 50 200') # capped
self.opener.Receive('MTCH1 done 50100 200 0 rejected -50');self.assertEqual(self.opener.result['gold'],-50)
self.time+=.1;self.assertFalse(self.opener.Open(50,1)) # inside the send gap
def test_errors_malformed_and_foreign_lines(self):
self.opener.Open(1,1);self.opener.Receive('MTCH1 error kind')
self.assertEqual(self.opener.error,'kind');self.assertIsNone(self.opener.waiting)
for bad in ('MTCH1 done 1 1 1 done','MTCH1 done 1 1 1 boom 0','MTCH1 got x 1','MTCH1 got 1 0','MTCH1 error boom','MTCH1 done a 1 1 done 0'):
self.assertTrue(self.opener.Receive(bad))
self.assertEqual(self.opener.error,'kind');self.assertIsNone(self.opener.result)
self.assertFalse(self.opener.Receive('MTPU1 done 1 1 0'))
def test_timeout_marks_unsupported(self):
events=[];self.opener.listener=events.append
self.opener.Open(1,1);self.time+=3.1;self.assertFalse(self.opener.Open(1,1))
self.assertTrue(self.opener.unsupported);self.assertEqual(events,['unsupported'])
self.opener.Receive('MTCH1 done 50100 1 1 done 0');self.assertFalse(self.opener.unsupported) # a late reply re-enables
def test_summary_lines(self):
lines=self.ns['Summary'](dict(vnum=50100,opened=12,reason='full',gold=1200,gains=[(27001,12)]+[(30000,1)]*5))
self.assertEqual(lines[0],'批量开启 Box:开启 12 个,金币 +1200(背包空位不足,已停止)')
self.assertEqual(lines[1],'获得:Potion ×12,Ore ×1,Ore ×1,Ore ×1,Ore ×1');self.assertEqual(lines[2],'获得:Ore ×1')
self.assertEqual(self.ns['Summary'](dict(vnum=50100,opened=1,reason='done',gold=0,gains=[])),['批量开启 Box:开启 1 个'])
def test_ctrl_right_click_opens_window_and_hooks(self):
game=sys.modules['game'];inv=sys.modules['uiInventory'].InventoryWindow();gw=game.GameWindow()
self.bag={50:(50100,1),51:(50100,1),52:(27001,3),90:(50100,1)}
game.GameWindow.Open(gw);w=self.ns['g_window']
inv.UseItemSlot(5);self.assertEqual(self.used,[5]);self.assertFalse(w.IsShow()) # no Ctrl: original use
self.ctrl=True;inv.UseItemSlot(7);self.assertEqual(self.used,[5,7]) # Ctrl on a potion: original use
inv.UseItemSlot(5);self.assertEqual(self.used,[5,7]);self.assertTrue(w.IsShow())
self.assertEqual((w.cell,w.vnum),(50,50100));self.assertIn('3',w.owned.text);self.assertEqual(w.openButton.text,'开启 1 个')
w.allButton.event();self.assertEqual(w.openButton.text,'开启 3 个')
del self.bag[50];w.openButton.event();self.assertEqual(self.sent,['/mt_chest 1 open 51 2']) # chosen cell gone: next box, 2 left
self.assertFalse(w.openButton.enabled)
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTCH1 got 27001 2'),1)
self.bag={52:(27001,6)}
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTCH1 done 50100 2 2 done 200'),1)
self.assertEqual(self.chat,['批量开启 Box:开启 2 个,金币 +200','获得:Potion ×2'])
self.assertIn('2/2',w.status.text);self.assertEqual(w.openButton.text,'没有宝箱')
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
self.bag={60:(50100,1)};self.time+=1;w.openButton.event();self.time+=3.1;w.OnUpdate()
self.assertTrue(self.ns['g_opener'].unsupported);self.assertIn('单个开启',self.chat[-1])
inv.UseItemSlot(15);self.assertEqual(self.used,[5,7,15]) # unsupported server: Ctrl falls back to normal use
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window'])
def test_server_constants_match(self):
server=ROOT.parent/'metin2-server/src/server/game/src/port_chest.h'
if not server.exists():self.skipTest('server checkout not installed')
text=server.read_text(encoding='latin1')
self.assertIn('kPortChestMax=200',text);self.assertEqual(self.ns['MAX'],200)
self.assertIn('kPortChestGapMs=500',text);self.assertEqual(self.ns['SEND_GAP'],.5)
self.assertTrue(set(re.findall(r'return "([a-z]+)";',text))<=set(self.ns['ERRORS']))
self.assertTrue(set(re.findall(r'reason="([a-z]+)"',text))<=set(self.ns['REASONS']))
if __name__=='__main__':unittest.main()
+190
View File
@@ -0,0 +1,190 @@
import sys
import types
import unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
class Widget(object):
def __init__(self,*a,**k):
self.Hide() # like ui.Window, before the subclass sets anything
self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.enabled=True
def __getattr__(self,name):return lambda *a,**k:None
def Show(self):self.shown=True
def Hide(self):self.shown=False
def IsShow(self):return self.shown
def SetText(self,text):self.text=text
def SetPosition(self,x,y):self.pos=(x,y)
def GetGlobalPosition(self):return self.pos
def SetSize(self,w,h):self.size=(w,h)
def GetWidth(self):return self.size[0]
def GetHeight(self):return self.size[1]
class Action(Widget):
def __init__(self,parent,x,y,width,text,event):
Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True
def SetEnabled(self,enabled):self.enabled=enabled
# vnum -> (type, subtype, antiflags)
ITEMS={11299:(1,0,0),11499:(1,0,0x08|0x10|0x20),12049:(2,0,0x04|0x10|0x20|0x40),8000:(1,6,0),
41001:(28,0,0x01),45001:(28,1,0x02),27001:(3,0,0),11259:(2,1,0)}
class Targets(object):
"""renderTarget stub: what exists, and every call in order."""
def __init__(self):self.live=set();self.calls=[];self.models={};self.missing=set();self.motionless=set()
def __getattr__(self,name):
def call(*a):self.calls.append((name,)+a)
return call
def Create(self,i,w,h):self.calls.append(('Create',i,w,h));self.live.add(i);return 1
def Remove(self,i):self.calls.append(('Remove',i));self.live.discard(i);self.models.pop(i,None)
def Destroy(self):self.calls.append(('Destroy',));self.live.clear();self.models.clear()
def SelectModel(self,i,race):
self.calls.append(('SelectModel',i,race))
if race in self.missing:return 0
self.models[i]=race;return 1
def SetMotionIndex(self,i,m):self.calls.append(('SetMotionIndex',i,m));return 0 if m in self.motionless else 1
def named(self,name):return [c for c in self.calls if c[0]==name]
class PreviewTests(unittest.TestCase):
def setUp(self):
self.chat=[];self.keys=set();self.mouse=(0,0);self.race=0;self.selected=None
self.rt=Targets()
names=('app','chat','game','grp','item','player','renderTarget','ui','wndMgr','mt_biolog','uiInventory','uiShop','shop')
mods={n:types.ModuleType(n) for n in names}
mods['renderTarget']=self.rt
app=mods['app'];app.DIK_X=45;app.IsPressed=lambda k:k in self.keys
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
it=mods['item']
for k,v in dict(ITEM_TYPE_WEAPON=1,ITEM_TYPE_ARMOR=2,ITEM_TYPE_COSTUME=28,WEAPON_ARROW=6,ARMOR_BODY=0,
COSTUME_TYPE_BODY=0,COSTUME_TYPE_HAIR=1,ITEM_ANTIFLAG_FEMALE=0x01,ITEM_ANTIFLAG_MALE=0x02,
ITEM_ANTIFLAG_WARRIOR=0x04,ITEM_ANTIFLAG_ASSASSIN=0x08,ITEM_ANTIFLAG_SURA=0x10,
ITEM_ANTIFLAG_SHAMAN=0x20).items():setattr(it,k,v)
def select(v):self.selected=ITEMS.get(v,ITEMS[27001])
it.SelectItem=select;it.GetItemType=lambda:self.selected[0];it.GetItemSubType=lambda:self.selected[1]
it.IsAntiFlag=lambda f:bool(self.selected[2]&f)
mods['player'].GetRace=lambda:self.race
self.bag={3:11299,4:27001}
mods['player'].GetItemIndex=lambda cell:self.bag.get(cell,0)
mods['ui'].Window=Widget
mods['wndMgr'].GetMousePosition=lambda:self.mouse
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t)
self.inventoryClicks=[];self.shopClicks=[]
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(
SelectItemSlot=lambda s,slot:self.inventoryClicks.append(slot),
_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+0))
mods['uiShop'].ShopDialog=type('ShopDialog',(),dict(SelectItemSlot=lambda s,slot:self.shopClicks.append(slot),
_ShopDialog__GetRealIndex=lambda s,slot:40+slot))
mods['shop'].GetItemID=lambda i:{41:12049,42:27001}.get(i,0)
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_preview'};exec(script('ModelPreviewScript.inc'),self.ns)
self.game=mods['game'];self.gw=self.game.GameWindow();self.game.GameWindow.Open(self.gw)
self.w=self.ns['g_window']
def text(self,t):
w=Widget();w.text=t;return w
def tearDown(self):
for name,value in self.previous.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
def test_item_spec(self):
spec=self.ns['ItemSpec']
self.assertEqual(spec(11299),('weapon',[0,4,5,1,2,6,7,3]))
self.assertEqual(spec(11499),('weapon',[0,4])) # warrior only
self.assertEqual(spec(12049),('armor',[5,1])) # assassin only
self.assertEqual(spec(41001),('armor',[0,5,2,7])) # anti-female costume
self.assertEqual(spec(45001),('hair',[4,1,6,3]))
for vnum in (8000,27001,11259):self.assertIsNone(spec(vnum)) # arrow, potion, helmet
self.assertTrue(self.ns['Previewable']('mob',101));self.assertFalse(self.ns['Previewable']('item',27001))
def test_item_preview_races_and_reset(self):
self.race=2
self.assertTrue(self.ns['Preview']('item',11299));self.assertTrue(self.w.IsShow())
self.assertEqual(self.rt.live,{1});self.assertIn(('Create',1,300,330),self.rt.calls)
self.assertEqual(self.rt.models[1],2);self.assertEqual(self.rt.named('SetWeapon')[-1],('SetWeapon',1,11299))
self.assertEqual(self.w.raceName.text,'修罗(男)');self.assertEqual(self.rt.named('Zoom'),[('Zoom',1,1)]*2)
self.w.nextRace.event();self.assertEqual(self.rt.models[1],6);self.assertEqual(self.w.raceName.text,'修罗(女)')
self.w.prevRace.event();self.w.prevRace.event();self.assertEqual(self.rt.models[1],1)
# The player's own race is not allowed: the first allowed race is shown, and race buttons need two races.
self.ns['Preview']('item',41001);self.assertEqual(self.rt.models[1],2)
self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,41001))
self.ns['Preview']('item',12049);self.assertEqual(self.rt.models[1],5)
self.ns['Preview']('item',45001);self.assertEqual(self.rt.models[1],4)
self.assertEqual(self.rt.named('SetHair')[-1],('SetHair',1,45001))
self.assertTrue(self.w.nextRace.enabled)
self.ns['Preview']('item',11499);self.race=0;self.ns['Preview']('item',11499)
self.assertEqual(self.rt.models[1],0)
self.assertFalse(self.ns['Preview']('item',27001));self.assertEqual(self.chat,['该物品没有可预览的模型'])
self.assertEqual(self.rt.models[1],0) # the window keeps what it showed
def test_mob_motions_zoom_turn(self):
self.rt.motionless.add(34)
self.assertTrue(self.ns['Preview']('mob',101));self.assertEqual(self.rt.models[1],101)
self.assertFalse(self.w.prevRace.IsShow());self.assertTrue(all(b.IsShow() for b in self.w.motions))
self.w.motions[1].event();self.assertEqual(self.rt.named('SetMotionIndex')[-1],('SetMotionIndex',1,13))
self.w.motions[5].event();self.assertEqual(self.w.note.text,'该怪物没有此动作')
self.w.motions[0].event();self.assertEqual(self.w.note.text,'')
zoom_in,zoom_out,spin,reset=[b.event for b in self.w.controls]
for _ in range(20):zoom_in()
self.assertEqual(self.w.zoom,-8);self.assertEqual(len(self.rt.named('Zoom')),8)
for _ in range(30):zoom_out()
self.assertEqual(self.w.zoom,12);self.assertEqual(len(self.rt.named('Zoom')),28)
spin();self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True));self.assertEqual(self.w.controls[2].text,'停止旋转')
# Drag: absolute yaw/pitch from where the press started, pitch clamped.
view=self.w.view;self.mouse=(100,100);view.OnMouseLeftButtonDown()
self.mouse=(160,1100);view.OnUpdate()
self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.))
view.OnMouseLeftButtonUp();self.mouse=(0,0);view.OnUpdate()
self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.))
reset();self.assertEqual((self.w.yaw,self.w.pitch,self.w.zoom),(0.,0.,0))
self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True)) # spin survives the reset
self.rt.missing.add(999);self.ns['Preview']('mob',999);self.assertEqual(self.w.note.text,'该怪物没有模型')
zoom_in();self.assertEqual(self.w.zoom,0)
def test_view_draws_target(self):
self.ns['Preview']('mob',101);self.w.view.pos=(20,70);self.w.view.OnRender()
self.assertEqual(self.rt.named('RenderTexture')[-1],('RenderTexture',1,20,70,320,400))
def test_no_leak_on_reopen_and_close(self):
for _ in range(5):
self.ns['Preview']('item',11299);self.assertEqual(self.rt.live,{1})
self.w.closeButton.event();self.assertEqual(self.rt.live,set());self.assertFalse(self.w.IsShow())
self.ns['Preview']('mob',101);self.w.OnPressEscapeKey();self.assertEqual(self.rt.live,set())
self.ns['Preview']('mob',101)
self.game.GameWindow.Close(self.gw)
self.assertIsNone(self.ns['g_window']);self.assertEqual(self.rt.live,set());self.assertEqual(self.rt.calls[-1],('Destroy',))
self.assertFalse(self.ns['Preview']('mob',101))
def test_inventory_and_shop_entries(self):
inv=sys.modules['uiInventory'].InventoryWindow()
inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertFalse(self.w.IsShow())
self.keys.add(45)
inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertTrue(self.w.IsShow())
inv.SelectItemSlot(4);inv.SelectItemSlot(9);self.assertEqual(self.inventoryClicks,[3,4,9]) # not wearable, empty
dlg=sys.modules['uiShop'].ShopDialog()
dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[]);self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,12049))
dlg.SelectItemSlot(2);self.assertEqual(self.shopClicks,[2])
self.keys.clear();dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[2,1])
def test_wiki_links(self):
wiki=(ROOT/'src/platform/ScriptLib/WikiScript.inc').read_text()
self.assertIn("(u'模型预览',('preview','item',vnum))",wiki);self.assertIn("(u'模型预览',('preview','mob',vnum))",wiki)
self.assertIn("mt_preview.Preview(vnum[1],vnum[2])",wiki)
boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text()
self.assertLess(boot.index('install_script_module("mt_mobile_inventory"'),boot.index('install_script_module("mt_preview"'))
self.assertLess(boot.index('install_script_module("mt_biolog"'),boot.index('install_script_module("mt_preview"'))
if __name__=='__main__':unittest.main()
+30 -1
View File
@@ -118,11 +118,40 @@ class SwitchbotTests(unittest.TestCase):
text=server.read_text(encoding='latin1')
reasons=set(re.findall(r'return "([a-z_]+)"',text))|{'running','stopped','idle'}
errors=set(re.findall(r'error [a-z%]+ ([a-z_]+)"',text))|{'invalid','rate'}
self.assertTrue(reasons-{'invalid','running','item','equipped','no_attr','scroll','target','busy'}<=set(self.ns['REASONS']))
lock_errors={'slot','max','db'}
self.assertTrue(reasons-{'invalid','running','item','equipped','no_attr','scroll','target','busy'}-lock_errors<=set(self.ns['REASONS']))
self.assertTrue(lock_errors<=set(self.ns['ERRORS']))
self.assertTrue(errors<=set(self.ns['ERRORS']))
self.assertIn('kPortSwitchLimit=1000',text);self.assertEqual(max(self.ns['LIMITS']),1000)
self.assertEqual(self.ns['MAX_TARGETS'],5)
def test_lock_protocol_and_buttons(self):
self.bot.Lock(10,0);self.assertEqual(self.sent,[]) # no lock line yet: old server, no request
self.load(self.bot);self.bot.Receive('MTSB1 lock 10 11209 2')
self.assertEqual(self.bot.LockMask(10,11209),2);self.assertIsNone(self.bot.LockMask(10,11210))
self.bot.Lock(10,5);self.bot.Lock(10,0);self.advance()
self.assertEqual(self.sent[-1],'/mt_switchbot 1 lock 10 0')
self.bot.Receive('MTSB1 lock 10 11209 3');self.assertIsNone(self.bot.waiting);self.assertEqual(self.bot.LockMask(10,11209),3)
self.bot.Lock(10,1);self.advance();self.bot.Receive('MTSB1 error lock max');self.assertEqual(self.bot.error,'max')
for bad in ('MTSB1 lock 10 11209','MTSB1 lock 10 11209 32'):self.bot.Receive(bad)
self.assertEqual(self.bot.LockMask(10,11209),3)
# Window: one button per filled slot, hidden while picking, locked attributes marked.
game=sys.modules['game'];gw=game.GameWindow();gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
game.GameWindow.Open(gw);w=self.ns['g_window'];w.Open()
self.assertFalse(any(b.shown for b in w.lockButtons))
self.load(w.bot);w.bot.Receive('MTSB1 lock 10 11209 2')
self.assertEqual([b.shown for b in w.lockButtons],[True,True,False,False,False])
self.assertEqual([b.text for b in w.lockButtons[:2]],['锁1','【锁2】']);self.assertIn('[锁]',w.current.text.split(' / ')[1])
self.assertIn('保持不变',w.status.text)
self.time+=.5;w.OnUpdate();w.bot.Receive('MTSB1 state 0 0 0 0 0 0 0 idle') # answers the status request
sent=len(self.sent);w.lockButtons[0].event();self.time+=.5;w.OnUpdate()
self.assertEqual(self.sent[sent:],['/mt_switchbot 1 lock 10 0'])
w.TogglePick();self.assertFalse(any(b.shown for b in w.lockButtons));w.TogglePick()
w.bot.Receive('MTSB1 state 1 10 11209 0 100 71084 9 running');self.assertFalse(w.lockButtons[0].enabled)
sent=len(self.sent);w.lockButtons[0].event();self.time+=.5;w.OnUpdate()
self.assertNotIn('/mt_switchbot 1 lock 10 0',self.sent[sent:])
game.GameWindow.Close(gw)
def test_window_scan_pick_start_and_notify(self):
game=sys.modules['game'];window_type=game.GameWindow
gw=window_type();gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
+125
View File
@@ -0,0 +1,125 @@
import re
import sys
import types
import unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
class Widget(object):
def __init__(self,*a,**k):self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.color=0
def __getattr__(self,name):return lambda *a,**k:None
def Show(self):self.shown=True
def Hide(self):self.shown=False
def IsShow(self):return self.shown
def SetText(self,text):self.text=text
def SetPackedFontColor(self,color):self.color=color
def SetPosition(self,x,y):self.pos=(x,y)
def GetGlobalPosition(self):return self.pos
def SetSize(self,w,h):self.size=(w,h)
def GetWidth(self):return self.size[0]
def GetHeight(self):return self.size[1]
class Action(Widget):
def __init__(self,parent,x,y,width,text,event):
Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True
class Minimap(Widget):
def GetGlobalPosition(self):return (700,10)
class VipTests(unittest.TestCase):
def setUp(self):
self.time=100.;self.sent=[];self.chat=[]
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','net','ui','mt_biolog')}
mods['app'].GetTime=lambda:self.time
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['net'].SendChatPacket=self.sent.append
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,
BINARY_ServerCommand_Run=lambda s,line:7))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
mods['ui'].Window=Widget
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t,c)
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_vip'};exec(script('VipScript.inc'),self.ns)
self.status=self.ns['Status'](self.sent.append,lambda:self.time)
def text(self,t,c):
w=Widget();w.text=t;w.color=c;return w
def tearDown(self):
for name,value in self.previous.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
def test_query_parses_times_and_counts_down(self):
self.assertTrue(self.status.Query());self.assertFalse(self.status.Query())
self.assertEqual(self.sent,['/mt_vip 1 status'])
self.assertTrue(self.status.Receive('MTVIP1 times 604800 604800 0 604800 604800 604800 90'))
self.assertIsNone(self.status.waiting);self.assertEqual(self.status.Left(2),0)
self.time+=100;self.assertEqual(self.status.Left(0),604700);self.assertEqual(self.status.Left(6),0)
self.time+=.5;self.assertTrue(self.status.Query()) # past the send gap
def test_added_error_malformed_and_foreign(self):
events=[];self.status.listener=events.append
self.status.Receive('MTVIP1 added 38002 30');self.assertEqual(self.status.added,(38002,30))
self.status.Receive('MTVIP1 error full');self.assertEqual(self.status.error,'full')
for bad in ('MTVIP1 times 1 2 3','MTVIP1 times 1 2 3 4 5 6 x','MTVIP1 times 1 2 3 4 5 6 -7','MTVIP1 added 38001 0',
'MTVIP1 added 38001 401','MTVIP1 error boom','MTVIP1 boom'):
self.assertTrue(self.status.Receive(bad))
self.assertEqual(events,['added','error']);self.assertIsNone(self.status.times)
self.assertFalse(self.status.Receive('MTCH1 error kind'))
def test_timeout_marks_unsupported(self):
events=[];self.status.listener=events.append
self.status.Query();self.time+=3.1;self.status.Tick()
self.assertTrue(self.status.unsupported);self.assertEqual(events,['unsupported'])
self.status.Receive('MTVIP1 times 0 0 0 0 0 0 0');self.assertFalse(self.status.unsupported)
def test_remaining_text(self):
R=self.ns['Remaining']
self.assertEqual(R(0),'未开通');self.assertEqual(R(-5),'未开通')
self.assertEqual(R(7*86400+3*3600+59),'剩余 7 天 3 小时')
self.assertEqual(R(5*3600+120),'剩余 5 小时 2 分');self.assertEqual(R(30),'剩余 1 分')
def test_window_entry_and_chat(self):
game=sys.modules['game'];gw=game.GameWindow()
gw.interface=types.SimpleNamespace(wndMiniMap=Minimap())
game.GameWindow.Open(gw);w=self.ns['g_window'];entry=self.ns['g_entry']
self.assertEqual(entry.pos,(632,418))
entry.event();self.assertTrue(w.IsShow());self.assertEqual(self.sent,['/mt_vip 1 status'])
self.assertEqual(w.note.text,'正在查询……');self.assertEqual(w.values[0].text,'……')
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 0 0 0 0 0 0 0'),1)
self.assertEqual(w.note.text,'尚未开通 VIP');self.assertEqual(w.values[3].text,'未开通')
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 added 38001 7'),1)
self.assertEqual(self.chat,['VIP 会员已延长 7 天,全部特权立即生效'])
game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 604800 604800 604800 604800 604800 604800 604800')
self.assertEqual(w.values[6].text,'剩余 7 天 0 小时');self.assertEqual(w.note.text,'VIP 生效中')
game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 error full')
self.assertEqual(self.chat[-1],'VIP 剩余时间不能超过 400 天');self.assertIn('400',w.note.text)
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
entry.event();self.assertFalse(w.IsShow())
self.time+=2;entry.event();self.time+=3.1;w.OnUpdate()
self.assertEqual(w.note.text,'!服务器尚未启用 VIP 卡')
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window']);self.assertIsNone(self.ns['g_entry'])
def test_server_constants_match(self):
server=ROOT.parent/'metin2-server/src/server/game/src/port_premium.h'
if not server.exists():self.skipTest('server checkout not installed')
text=server.read_text(encoding='latin1')
self.assertIn('kPortVipMaxDays=400',text);self.assertEqual(self.ns['MAX_DAYS'],400)
self.assertIn('kPortVipGapMs=1000',text);self.assertEqual(self.ns['SEND_GAP'],1.)
cards={int(v):int(d) for v,d in re.findall(r'\{(\d+),(\d+)\}',text.split('kPortVipCards',1)[1].split(';',1)[0])}
self.assertEqual(cards,self.ns['CARDS'])
self.assertEqual(text.count('_expire"'),len(self.ns['SERVICES']))
self.assertTrue(set(re.findall(r'MTVIP1 error ([a-z]+)',text))<=set(self.ns['ERRORS']))
if __name__=='__main__':unittest.main()
+1
View File
@@ -46,6 +46,7 @@
| +6 镶嵌石(28700~28713,全部 14 种,宝石页) | 各 1 |
| 移动戒指(不限次数、无冷却,见 `../ring-warp`) | 1 |
| 追加物品属性秘籍、龙神的祝福书 | 各 100 万 |
| VIP 会员卡 7 天 / 30 天(7 项会员特权全部延长,见 `../vip-card`) | 500 万 / 1800 万 |
## 坐骑(mount)
@@ -43,6 +43,8 @@
71032 1 1000000 龙神的祝福书
70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp)
50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set)
38001 1 5000000 VIP会员卡 7天,7项会员特权全部延长(../../vip-card)
38002 1 18000000 VIP会员卡 30天(../../vip-card)
71052 1 1 超级属性转换秘籍
71051 1 1 超级属性追加秘籍
[宝石]
1 # [tab name] starts a shop page (shop_table_ex.txt, at most 3 pages of 39); then vnum count price name (duration: unique items count worn play minutes, 仓库扩张卷 real time)
43 71032 1 1000000 龙神的祝福书
44 70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp)
45 50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set)
46 38001 1 5000000 VIP会员卡 7天,7项会员特权全部延长(../../vip-card)
47 38002 1 18000000 VIP会员卡 30天(../../vip-card)
48 71052 1 1 超级属性转换秘籍
49 71051 1 1 超级属性追加秘籍
50 [宝石]
+1
View File
@@ -106,6 +106,7 @@ data)
sh "$HERE/ring-warp/install.sh"
sh "$HERE/support-skills/install.sh"
sh "$HERE/hunt-set/install.sh"
sh "$HERE/vip-card/install.sh"
sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/item-mall"
sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/mount"
start_server
+42
View File
@@ -0,0 +1,42 @@
# VIP 会员卡(203 服务端)
服务端原有 7 种会员特权(`account.account` 的 `*_expire` 列,登录时读入,左上角图标 affect 500~506):
| 序号 | 特权 | 列 |
|---|---|---|
| 0 | 经验加成 | `silver_expire` |
| 1 | 物品掉落翻倍 | `gold_expire` |
| 2 | 仓库扩展到 3 页 | `safebox_expire` |
| 3 | 金币直接进背包 | `autoloot_expire` |
| 4 | 钓鱼成功率提升 | `fish_mind_expire` |
| 5 | 结婚亲密度加快 | `marriage_fast_expire` |
| 6 | 金币掉落翻倍 | `money_drop_rate_expire` |
40250 里没有游戏内开通的途径。VIP 卡用 40250 没用到的两个物品:
| 物品 | 名称 | 时长 | 百店老板娘「道具」页价格 |
|---|---|---|---|
| 38001 | VIP会员卡(7天) | 7 天 | 500 万金币 |
| 38002 | VIP会员卡(30天) | 30 天 | 1800 万金币 |
价格在 `../npc-shops/item-mall/items.tsv`,改完重跑 npc-shops 的 install.sh 即可。
右键使用:7 项特权同时从「现在」和「当前到期时间」中较晚的那个往后延长,立即生效(图标、自动拾取、仓库页数)。
剩余时间加上卡的时长超过 400 天时拒绝,卡不扣。两次使用至少间隔 1 秒。客户端小地图旁的「VIP」按钮打开面板,
显示每项剩余时间。协议见 metin2-server `docs/FEATURE-VIP.md`。
## 经验加成 +100%
newcibn 的会员经验在 `char_battle.cpp` 里固定 ×1.2,之后再乘 `GetMobExpRate` 返回的事件标记
`mob_exp_buyer`(默认 100)。`/e mob_exp_buyer 167` 后 1.2 × 1.67 ≈ 2.0,即 +100%。事件标记存在
`player.quest`(pid 0),所有核心共享,部署时用 GM 角色设一次即可,重启后仍在。
## 文件
- `install.sh`:`item_proto.txt` 里 38001/38002 改为占 1 格、可堆叠、记日志、不能卖给 NPC(40250 原来是
size 0,两张卡会叠在同一格、无法移动),服务端改名(`item_names.txt` + `item_proto.locale_name`,GB2312)。
需要重启 db 和 game。备份在 `/root/backup-vip-card/<时间>/`。
- 游戏端代码在 metin2-server(`port_premium.h`,分支 feature/vip-card)。
客户端:名称 `data/zh_40250/item_names_zh_overlay.txt`;占格和堆叠 `item_shapes_server.tsv`(由
`locale_proto_patch` 写进客户端 item_proto,与服务端一致),由 `scripts/build_zh_locale.py` 打进 locale_zh。
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Installs the VIP cards (README.md): run on the server from this directory with a game built from
# metin2-server feature/vip-card (port_premium.h), then restart db + game. 40250 ships 38001/38002 as unused
# ITEM_NONE rows of size 0, which the inventory cannot hold (two of them share a cell, neither can move): this
# gives them one cell, stacking, an item log and no NPC sale, and names them on the server (item_names.txt and
# item_proto.locale_name; the client has its own names and the same size/flags, data/zh_40250).
set -eu
SERVER=${SERVER:-/usr/metin2/server}
PROTO="$SERVER/share/conf/item_proto.txt"
NAMES="$SERVER/share/conf/item_names.txt"
BACKUP=/root/backup-vip-card/$(date +%Y%m%d-%H%M%S)
export LC_ALL=C
mkdir -p "$BACKUP"
cp -p "$PROTO" "$NAMES" "$BACKUP/"
mysqldump -uroot --no-create-info --replace player item_proto --where="vnum in (38001,38002)" > "$BACKUP/item_proto-rows.sql"
echo "backup: $BACKUP"
# item_proto.txt: column 5 size, 6 anti flags, 7 flags.
awk -F'\t' -v OFS='\t' '$1 == 38001 || $1 == 38002 { $5 = 1; $6 = "ANTI_SELL"; $7 = "ITEM_STACKABLE | LOG"; n++ } { print }
END { if (n != 2) { print "item_proto.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \
"$PROTO" > "$BACKUP/item_proto.new"
cat "$BACKUP/item_proto.new" > "$PROTO"
week=$(printf 'VIP会员卡(7天)' | iconv -f UTF-8 -t GB2312)
month=$(printf 'VIP会员卡(30天)' | iconv -f UTF-8 -t GB2312)
awk -F'\t' -v OFS='\t' -v week="$week" -v month="$month" '$1 == 38001 { $2 = week; n++ } $1 == 38002 { $2 = month; n++ } { print }
END { if (n != 2) { print "item_names.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \
"$NAMES" > "$BACKUP/item_names.new"
cat "$BACKUP/item_names.new" > "$NAMES"
# ITEM_STACKABLE | ITEM_LOG = 4 | 8192, ITEM_ANTIFLAG_SELL = 256.
mysql -uroot player -e "UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$week' WHERE vnum = 38001;
UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$month' WHERE vnum = 38002"
echo "done; restart db and game"