Author SHA1 Message Date
shenleiandClaude Opus 5.5 8d01a7a754 AVA2 unlock P8: new rings client (mt_rings, ring slots, item import)
ITEM_TYPE_RINGS / c_Equipment_Rings1/2, mt_rings adds the two slots beside
the shoes, the accessory tooltip and the drill / jewel / switcher / adder drop
targets; make_items imports the four +0..+9 ring chains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:40:40 +09:00
shenleiandClaude Opus 5.5 b9af78ef64 AVA2 unlock P7: talismans (护符) and their attribute items
- ARMOR_PENDANT / APPLY_ENCHANT_* 123-128 / c_Equipment_Pendant (server WEAR_PENDANT 27) constants
- mt_pendant: equipment slot under the earring, drop targets for 71302/53996 (reroll) and 71301/53997
  (add, max 5), MTPENDANT messages; enchant apply texts in mt_applies
- switchbot ignores talismans and talisman rerolls
- make_items keeps the six +0..+200 chains (1206 items) and the four attribute items
- tools/server/pendant: refine sets 440-499 (AVA2 rows) + install.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:26:12 +09:00
shenleiandClaude Opus 5.5 a1b63c0afb AVA2 unlock P6: costume enchants (client)
Keep 54001-54078 (body/hair) and 54124-54174 (mount skin) enchants as
USE_CHANGE_ATTRIBUTE; mt_costume_attr marks matching costumes as drop
targets, shows the attribute in the tooltip and renders MTCATTR; the
switchbot no longer lists enchants as scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:07:07 +09:00
shenleiandClaude Opus 5.5 1412806ee9 AVA2 unlock P5: mount costume / skin (COSTUME_MOUNT 2, COSTUME_MOUNT_SKIN 4, slots 25/26, mt_mount, npc_mount races)
Keeps 71502/71505/71508/71511 and skins 71234/71254 as costumes, stages the
npc_mount models of races 32012/32028, writes mobs-server.tsv and lets
install_items.py install mob rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:58:16 +09:00
shenleiandClaude Opus 5.5 4d7306792e AVA2 unlock P4: sash (mt_sash windows/slot/tooltips, sash module, CHR_EQUIPPART_SASH, item_scale)
Client side of metin2-server FEATURE-AVA2-UNLOCK P4: HEADER_CG_SASH 211 /
HEADER_GC_SASH 215, a fifth awPart WORD in the character packets (old
servers break), the sash part attached by scale per job/sex, the combine
and absorb windows in script mt_sash, and quest tools/server/sash on 20016.
The ava2items pack keeps the four grade-4 sashes plus their effects,
acce window images and item_scale.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:45:02 +09:00
shenleiandClaude Opus 5.5 0d2a9ebe36 AVA2 unlock P3: dragon armor 21210-21279 (+0..+9) with armor shape 32
Keep ARMOR_BODY rows, import the whole refine chain, ship AVA2's <race>_7_1
models and register shape 32 after each race msm load (mt_shapes): the race
msm are read by bare name from the root pack and cannot be overridden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:13:41 +09:00
shenleiandClaude Opus 5.5 178fcf48fe AVA2 unlock P1/P2: treasure boxes/keys and stone/Boss stones usable
Keep ITEM_TREASURE_BOX/KEY and METIN_NORMAL rows; APPLY_ATTBONUS_STONE 139 /
APPLY_ATTBONUS_BOSS 140 in ItemData.h, item module and tooltip (mt_applies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:05:20 +09:00
shenlei f266072f67 Merge feature/ava2-items 2026-10-16 10:10:50 +09:00
shenleiandClaude Opus 5.5 90f213c4cc Import the AVA2 drop items the server lacks as ordinary 40250 items
tools/server/ava2-items stages the 478 items referenced by the AVA2 drop
tables that 203 does not have: server item_proto/item_names rows, a client
item_proto/item_list/itemdesc patch over the biolog tables, and the AVA2
icons, ground models (BitKnit gr2 via sdk/gr2) and their textures, packed
as ava2items ahead of biolog. Materials, quest items, chests, the guardian
weapons and shoes keep their meaning; items of AVA2-only systems (pets,
pendants, new rings, mounts, sashes, costume enchants, talisman items, new
skill books) become plain ITEM_NONE items until those systems are ported.
Names are translated in names_zh.tsv.

item_proto_patch is the full-record successor of biolog_proto_patch, with
--dump for checking conversions against the shipped table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:10:47 +09:00
shenleiandClaude Opus 5.5 d11d46c469 Fix the zh locale build after the item shapes overlay
A stray comma turned the proto patch argument list into a tuple, so the
build failed with "bad operand type for unary +" since 5dcac9cb. The AVA2
biology step also accepts the reference unpacked under AVA2/TEST instead of
only the FILES+SRC.tgz archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:31:52 +09:00
shenleiandClaude Opus 5.5 8164c02709 Add the battle pass window (mt_battlepass)
战令 window opened by the 战令 button under the minimap or F6: season and
time left, missions with progress and per-mission claim, final reward,
done notices in chat. Talks to the server over /mt_bp 1 / MTBP1 lines and
reports an unsupported server after 3 s without an answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:11:43 +09:00
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
93 changed files with 5806 additions and 66 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 -2
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,4 +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 条不变,重新登录后仍在),未部署正式环境,未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.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。
+1 -1
View File
@@ -71,4 +71,4 @@
- Android 真机测试。
- macOS 构建需要 `-DMT_40250_SOURCE=<含 EterPack/EterPack.cpp 的源码目录>` 来生成 pack 密钥,否则 system.py 读不出来(RunMain Error)。本机没有 40250 源码,这次用的是 AVA2 的 `TEST/3. Source BIN/Client`,两者密钥相同。
- 服务端还没有部署到 203 正式环境。
- 属性锁定已完成(见上),尚未部署到正式环境。
- 属性锁定已完成(见上),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.
+48 -7
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 []),
+ ([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"] = {
@@ -393,7 +397,10 @@ def build_biolog(output: Path, pack_tool: Path):
root = REPO / "tools/server/biolog"
ava = REPO.parent / "AVA2"
archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz"
if not archive.is_file():
extracted = ava / "TEST/2. Backup SF+SRC and SQL/home/srv1/db/item_proto.txt"
if not archive.is_file() and extracted.is_file():
ava = ava / "TEST" # the reference unpacked under AVA2/TEST
elif not archive.is_file():
raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference")
proto_tool = tool("biolog_proto_patch")
if not proto_tool.is_file():
@@ -401,11 +408,14 @@ def build_biolog(output: Path, pack_tool: Path):
stage = output.parent / "biolog"
stage.mkdir(parents=True, exist_ok=True)
proto = stage / "ava2-item-proto.txt"
with tarfile.open(archive, "r:gz") as source:
member = source.extractfile("home/srv1/db/item_proto.txt")
if member is None:
raise ValueError("AVA2 item table missing")
proto.write_bytes(member.read())
if not archive.is_file():
shutil.copyfile(extracted, proto)
else:
with tarfile.open(archive, "r:gz") as source:
member = source.extractfile("home/srv1/db/item_proto.txt")
if member is None:
raise ValueError("AVA2 item table missing")
proto.write_bytes(member.read())
spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@@ -421,6 +431,36 @@ def build_biolog(output: Path, pack_tool: Path):
return {"stages": 13, "item_templates": 29, "pack": "biolog", "first_priority": True}
def build_ava2_items(output: Path, pack_tool: Path):
"""AVA2 drop items missing on the server, layered over the biolog item table (see tools/server/ava2-items)."""
import importlib.util
root = REPO / "tools/server/ava2-items"
ava = REPO.parent / "AVA2/TEST"
reader = REPO.parent / "metin2-server/src/server/db/src/ProtoReader.cpp"
if not (ava / "1. Client DEV/pack_maker").is_dir() or not reader.is_file():
raise ValueError("AVA2 items need ../AVA2/TEST (pack_maker + server db) and ../metin2-server")
proto_tool = tool("item_proto_patch")
if not proto_tool.is_file():
raise ValueError("build item_proto_patch before generating the Chinese client")
stage = output.parent / "ava2-items"
spec = importlib.util.spec_from_file_location("ava2_items", root / "make_items.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# biolog's pack carries the current locale/zh item tables; this pack must replace them, so it goes first.
report = module.prepare(ava, reader, output.parent / "biolog/assets/locale/zh", stage,
proto_tool.resolve(), pack_tool.resolve(), wrap_description)
for extension in ("eix", "epk"):
shutil.copy2(stage / ("ava2items." + extension), output / "pack" / ("ava2items." + extension))
index = output / "pack/Index"
lines = index.read_bytes().replace(b"\r\n", b"\n").splitlines()
if b"ava2items" in lines:
del lines[lines.index(b"ava2items") - 1:lines.index(b"ava2items") + 1]
at = lines.index(b"biolog") - 1
lines[at:at] = [b"*", b"ava2items"]
index.write_bytes(b"\r\n".join(lines) + b"\r\n")
return {"items": len(report["kept"]) + len(report["locked"]), "kept": len(report["kept"]),
"locked": len(report["locked"]), "assets": report["assets"], "pack": "ava2items"}
def build_wiki(output: Path, pack_tool: Path):
"""A versioned public server catalogue; original script and proto packs stay intact."""
@@ -462,6 +502,7 @@ def main():
args.proto_tool, args.name_tables, args.pack_tool)
report = json.loads(report_path.read_text())
report["biolog"] = build_biolog(args.output, args.pack_tool)
report["ava2_items"] = build_ava2_items(args.output, args.pack_tool)
report["wiki"] = build_wiki(args.output, args.pack_tool)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
print(f"Built {args.output / 'pack/locale_zh.eix'} (.epk), registered in pack/Index")
+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);
+28
View File
@@ -0,0 +1,28 @@
// PORT: item bonuses added after 40250 (AVA2 numbering, server common/length.h). The packed uitooltip.py
// only knows 40250's applies; add the new ones to its AFFECT_DICT so item and socket tooltips show them.
static const char c_szAppliesScript[] = R"PY(# -*- coding: utf-8 -*-
import item
import player
import uiToolTip
L = player.AutoHuntLocalize
TEXTS = (
("APPLY_ATTBONUS_STONE", "对陨石伤害 +%d%%"),
("APPLY_ATTBONUS_BOSS", "对 Boss 伤害 +%d%%"),
("APPLY_ENCHANT_ELEC", "对雷属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_FIRE", "对火属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_ICE", "对冰属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_WIND", "对风属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_EARTH", "对土属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_DARK", "对暗属性怪物伤害 +%d%%"),
)
def Text(text):
return lambda value: L(text % value)
for name, text in TEXTS:
if hasattr(item, name):
uiToolTip.ItemToolTip.AFFECT_DICT[getattr(item, name)] = Text(text)
)PY";
@@ -0,0 +1,34 @@
// PORT: armor shapes added after 40250 (AVA2's dragon armor 21210+, item value3 = 32). The race .msm files are
// read by bare name, which CEterPackManager always serves from the root pack, so a patch pack cannot replace
// them; register the extra shapes after each LoadLocalRaceData instead, as the AVA2 msm groups would. Models
// ship in the ava2items pack (metin2-client tools/server/ava2-items, ARMOR_SHAPES / RACE_PATHS).
static const char c_szArmorShapesScript[] = R"PY(# -*- coding: utf-8 -*-
import chrmgr
SHAPES = {32: "%s_7_1"}
# ShapeData PathName of each race msm (male, female).
PATHS = {"warrior": ("pc", "pc2"), "assassin": ("pc2", "pc"), "sura": ("pc", "pc2"), "shaman": ("pc2", "pc")}
_load = chrmgr.LoadLocalRaceData
def Register(name):
stem = name.lower().split(".")[0]
race, _, sex = stem.partition("_")
if race not in PATHS or sex not in ("m", "w"):
return
path = "d:/ymir work/%s/%s/" % (PATHS[race][sex == "w"], race)
for shape, model in SHAPES.items():
model = path + model % race
chrmgr.SetShapeModel(shape, model + ".gr2")
chrmgr.AppendShapeSkin(shape, 0, model + ".dds", model + ".dds")
def LoadLocalRaceData(name):
result = _load(name)
Register(name)
return result
chrmgr.LoadLocalRaceData = LoadLocalRaceData
)PY";
+364
View File
@@ -0,0 +1,364 @@
// PORT: battle pass (战令), from AVA2's uibattlepassex.py. The server runs monthly seasons of up to ten missions
// counted from real events (kills, fishing, refines, levels, NPC trades, play time); each mission has a reward and
// a final reward follows once all are done. This window asks with /mt_bp, shows progress live while open and
// claims rewards. Entry points: F6 and a "战令" button by the minimap. Protocol: metin2-server
// docs/FEATURE-BATTLEPASS.md.
static const char c_szBattlePassScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import game
import grp
import item
import net
import nonplayer
import ui
import weakref
from mt_biolog import Action, Text, L
g_window=None
g_entry=None
SEND_GAP=.4
TIMEOUT=3.
PAGE=5
ROW=56
WIDTH=620
MAX_SLOT=10
# type: (name without sub, name with a sub vnum: kind of the vnum)
TYPES={1:('击杀怪物','击杀 ','mob'),2:('击杀 Boss','击杀 Boss ','mob'),3:('摧毁陨石','摧毁 ','mob'),4:('累计在线',None,None),
5:('钓鱼','钓到 ','item'),6:('成功强化装备','强化出 ','item'),7:('提升等级',None,None),8:('在 NPC 商店消费金币',None,None),
9:(None,'向 NPC 出售 ','item')}
ERRORS={'loading':'数据加载中,请稍后再试','slot':'没有这个任务','claimed':'奖励已经领取过了','incomplete':'任务尚未完成',
'space':'背包空间不足,请整理后再领取','config':'奖励配置有误,请联系管理员','db':'服务器繁忙,请稍后再试',
'rate':'操作过快,请稍后再试','invalid':'参数无效'}
def Number(text,low=0,high=10**12):
if not text.isdigit() or len(text)>13:raise ValueError(text)
value=int(text)
if not low<=value<=high:raise ValueError(text)
return value
def Rewards(words):
if len(words)%2:raise ValueError(words)
return [(Number(words[i],1,2**32-1),Number(words[i+1],1,10000)) for i in range(0,len(words),2)]
def Grouped(value):
text=str(int(value));parts=[]
while len(text)>3:parts.insert(0,text[-3:]);text=text[:-3]
return ','.join([text]+parts)
def Amount(kind,value,target):
# Play time is counted in seconds and shown in hours, or in minutes for a target under an hour.
if kind==4:
unit=3600. if target>=3600 else 60.
amount=value/unit
return ('%d'%amount) if amount==int(amount) else ('%.1f'%amount)
return Grouped(value)
def Unit(kind,target):
if kind!=4:return ''
return ' 小时' if target>=3600 else ' 分钟'
def SeasonName(season):
year,month=divmod(season,100)
if 2000<=year<=2999 and 1<=month<=12:return '%d 年 %d 月'%(year,month)
return '第 %d 期'%season
def Left(seconds):
seconds=int(seconds)
if seconds<=0:return '已结束'
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600)
if days:return '剩余 %d 天 %d 小时'%(days,hours)
return '剩余 %d 小时 %d 分'%(hours,max(1,rest//60))
class Pass(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.Clear()
def Clear(self):
self.season=None;self.missions={};self.final=None;self.reading=None;self.none=False;self.error=None;self.claiming=None
def Ready(self):
return self.waiting is None or self.now()>=self.waiting
def Post(self,text,expect=True):
now=self.now()
if now<self.nextSend:return False
self.send(text);self.nextSend=now+SEND_GAP
if expect:self.waiting=now+TIMEOUT
return True
def Query(self):
self.Tick()
if self.waiting is not None:return False
return self.Post('/mt_bp 1 info')
def Claim(self,slot):
if self.claiming is not None or not self.season:return False
if self.Post('/mt_bp 1 claim %d'%slot):self.claiming=slot;self.error=None;return True
return False
def Close(self):
self.send('/mt_bp 1 close')
def Tick(self):
if self.waiting is not None and self.now()>=self.waiting:
self.waiting=None;self.claiming=None
if self.season is None:self.unsupported=True
self.Notify('timeout')
def SecondsLeft(self):
if not self.season:return 0
_,_,end,now,at=self.season
return end-(now+(self.now()-at))
def Notify(self,kind,value=None):
if self.listener:self.listener(kind,value)
def Receive(self,line):
if not line.startswith('MTBP1 '):return False
words=line.split()
try:self.Parse(words[1],words[2:])
except (ValueError,IndexError):self.reading=None
return True
def Parse(self,kind,args):
if kind=='season' and len(args)==5:
seasonId,start,end,now=[Number(w,0,2**32-1) for w in args[:4]];count=Number(args[4],1,MAX_SLOT)
self.reading=dict(season=(seasonId,start,end,now,self.now()),count=count,missions={},final=None)
elif kind=='mission' and self.reading is not None and len(args)>=6:
slot=Number(args[0],1,MAX_SLOT);kind=Number(args[1],1,9)
self.reading['missions'][slot]=dict(slot=slot,type=kind,sub=Number(args[2],0,2**32-1),target=Number(args[3],1),
value=Number(args[4]),claimed=Number(args[5],0,1)==1,rewards=Rewards(args[6:]))
elif kind=='final' and self.reading is not None and len(args)>=4:
self.reading['final']=dict(claimed=Number(args[0],0,1)==1,all=Number(args[1],0,1)==1,rewards=Rewards(args[2:]))
elif kind=='end' and not args and self.reading is not None:
r=self.reading;self.reading=None
if len(r['missions'])!=r['count'] or r['final'] is None:return
self.season=r['season'];self.missions=r['missions'];self.final=r['final'];self.none=False
self.Answered('info')
elif kind=='none' and not args:
self.Clear();self.none=True;self.Answered('none')
elif kind=='progress' and len(args)==2:
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
if m:m['value']=min(m['target'],Number(args[1]));self.Notify('progress',slot)
elif kind=='done' and len(args)==1:
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
if m:m['value']=m['target']
self.Notify('done',slot)
elif kind=='claimed' and len(args)==1:
slot=Number(args[0],0,MAX_SLOT)
if slot:
if slot in self.missions:self.missions[slot]['claimed']=True
elif self.final:self.final['claimed']=True
self.claiming=None;self.Notify('claimed',slot) # a full info follows
elif kind=='error' and len(args)==1 and args[0] in ERRORS:
self.error=args[0];self.claiming=None;self.Answered('error')
def Answered(self,kind):
self.waiting=None;self.unsupported=False;self.Notify(kind)
def AllDone(self):
return bool(self.missions) and all(m['value']>=m['target'] for m in self.missions.values())
def ItemName(vnum):
try:
item.SelectItem(vnum);name=item.GetItemName()
except Exception:name=''
return name if name else L('物品 %d'%vnum)
def MobName(vnum):
try:name=nonplayer.GetMonsterName(vnum)
except Exception:name=''
return name if name else L('怪物 %d'%vnum)
def MissionName(m):
# Locale text: the utf-8 parts go through L, game names are already in the locale's code page.
plain,prefix,kind=TYPES.get(m['type'],('任务',None,None))
if m['sub'] and prefix:return L(prefix)+(MobName(m['sub']) if kind=='mob' else ItemName(m['sub']))
return L(plain or '任务')
def RewardText(rewards):
return L(',').join([ItemName(vnum)+(' x%d'%count) for vnum,count in rewards])
class BattlePassWindow(ui.Window):
def __init__(self,state):
ui.Window.__init__(self);self.SetWindowName('BattlePassWindow');self.AddFlag('float');self.AddFlag('movable')
self.state=state;self.page=0
self.SetSize(WIDTH,74+PAGE*ROW+ROW+20+56)
self.heading=Text(self,18,18,'战令',0xffffd68b)
self.season=Text(self,80,18,'',0xffc3b7a1)
self.closeButton=Action(self,WIDTH-76,6,64,'关闭',self.Hide)
self.rows=[]
for i in range(PAGE):
y=64+i*ROW
self.rows.append(dict(name=Text(self,18,y+4,''),value=Text(self,330,y+4,''),
reward=Text(self,18,y+26,'',0xffb3aa98),button=Action(self,WIDTH-118,y,100,'领取',lambda i=i:self.ClaimRow(i))))
y=64+PAGE*ROW+8
self.finalName=Text(self,18,y+4,'最终奖励',0xffffd68b)
self.finalState=Text(self,330,y+4,'')
self.finalReward=Text(self,18,y+26,'',0xffb3aa98)
self.finalButton=Action(self,WIDTH-118,y,100,'领取',lambda:self.state.Claim(0) and self.Refresh())
y=self.GetHeight()-56
self.prev=Action(self,18,y,80,'上一页',lambda:self.Turn(-1))
self.pageText=Text(self,140,y+24,'');self.pageText.SetHorizontalAlignCenter()
self.next=Action(self,182,y,80,'下一页',lambda:self.Turn(1))
self.note=Text(self,280,y+16,'',0xffc3b7a1)
self.second=-1
self.Hide()
def Open(self):
self.state.error=None;self.state.Query();self.Refresh();self.SetCenterPosition();ui.Window.Show(self);self.SetTop()
def Toggle(self):
if self.IsShow():self.Hide()
else:self.Open()
def Hide(self):
state=self.__dict__.get('state') # ui.Window.__init__ hides the window before state is set
if self.IsShow() and state and state.season:state.Close()
ui.Window.Hide(self)
def Slots(self):
return sorted(self.state.missions)
def Pages(self):
return max(1,(len(self.Slots())+PAGE-1)//PAGE)
def Turn(self,delta):
self.page=max(0,min(self.Pages()-1,self.page+delta));self.Refresh()
def ClaimRow(self,i):
slots=self.Slots()[self.page*PAGE:]
if i<len(slots) and self.state.Claim(slots[i]):self.Refresh()
def Refresh(self):
s=self.state;self.page=min(self.page,self.Pages()-1)
slots=self.Slots()[self.page*PAGE:(self.page+1)*PAGE]
for i,row in enumerate(self.rows):
widgets=(row['name'],row['value'],row['reward'],row['button'])
if i>=len(slots):
for w in widgets:w.Hide()
continue
for w in widgets:w.Show()
m=s.missions[slots[i]];done=m['value']>=m['target']
row['name'].SetText(MissionName(m))
row['value'].SetText(Amount(m['type'],m['value'],m['target'])+' / '+Amount(m['type'],m['target'],m['target'])+L(Unit(m['type'],m['target'])))
row['value'].SetPackedFontColor(0xff9fe08b if done else 0xffe8dec8)
row['reward'].SetText(L('奖励:')+RewardText(m['rewards']) if m['rewards'] else L('无奖励'))
if m['claimed']:row['button'].SetText('已领取')
elif s.claiming==m['slot']:row['button'].SetText('领取中')
else:row['button'].SetText('领取' if done else '未完成')
row['button'].SetEnabled(done and not m['claimed'] and bool(m['rewards']) and s.claiming is None)
f=s.final
for w in (self.finalName,self.finalState,self.finalReward,self.finalButton):
if f:w.Show()
else:w.Hide()
if f:
ready=s.AllDone()
self.finalState.SetText(L('已领取' if f['claimed'] else '全部任务完成' if ready else '完成全部任务后可领取'))
self.finalReward.SetText(L('奖励:')+RewardText(f['rewards']))
self.finalButton.SetText('已领取' if f['claimed'] else '领取中' if s.claiming==0 else '领取')
self.finalButton.SetEnabled(ready and not f['claimed'] and s.claiming is None)
many=self.Pages()>1
for w in (self.prev,self.next,self.pageText):
if many and s.season:w.Show()
else:w.Hide()
self.prev.SetEnabled(self.page>0);self.next.SetEnabled(self.page<self.Pages()-1)
self.pageText.SetText('%d / %d'%(self.page+1,self.Pages()))
if s.season:self.season.SetText(L(SeasonName(s.season[0])+' '+Left(s.SecondsLeft())))
else:self.season.SetText('')
if s.unsupported:note='!服务器尚未启用战令'
elif s.error:note='!'+ERRORS[s.error]
elif s.none:note='当前没有进行中的战令赛季'
elif not s.season:note='正在查询……'
elif s.claiming is not None:note='正在领取……'
else:note='击杀、钓鱼、强化等都会自动计入进度'
self.note.SetText(L(note))
def OnUpdate(self):
self.state.Tick()
second=int(app.GetTime())
if second!=self.second:self.second=second;self.Refresh()
def OnRender(self):
x,y=self.GetGlobalPosition();w=self.GetWidth()
grp.SetColor(0xf51b1916);grp.RenderBar(x,y,w,self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,self.GetHeight())
grp.SetColor(0xff4a3d2a)
for i in range(PAGE+1):grp.RenderLine(x+12,y+60+i*ROW,w-24,0)
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=self.finalButton.event=self.prev.event=self.next.event=None
for row in self.rows:row['button'].event=None
self.state=None
class Entry(Action):
def __init__(self,window):
Action.__init__(self,None,0,0,64,'战令',Toggle)
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('BattlePassEntry');self.OnUpdate()
def OnUpdate(self):
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+460)
def OnPass(kind,value):
s=g_pass
if kind=='done':
m=s.missions.get(value)
name=MissionName(m) if m else L('一项任务')
chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令任务完成:')+name+L(',按 F6 领取奖励'))
elif kind=='claimed':chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令奖励已放入背包' if value else '战令最终奖励已放入背包'))
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
if g_window and g_window.IsShow():g_window.Refresh()
g_pass=Pass(net.SendChatPacket,app.GetTime,OnPass)
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_pass.waiting=None;g_pass.unsupported=False;g_pass.Clear()
g_window=BattlePassWindow(g_pass);g_entry=Entry(self)
keys=getattr(self,'onPressKeyDict',None)
if keys is not None and app.DIK_F6 not in keys:keys[app.DIK_F6]=lambda:Toggle()
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_pass.Receive(line):return 1
return _command(self,line)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+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,83 @@
// PORT: AVA2 costume enchants (ENABLE_SPECIAL_COSTUME_ATTR), as module mt_costume_attr. Items 54001-54267 are
// USE_CHANGE_ATTRIBUTE rows with value0 = apply, value1 = value, value2 = target mask (1 << bonus flag); the server
// (metin2-server port_costume_attr.cpp) adds that attribute to a body / hair / mount-skin costume. The 40250
// inventory already sends every USE_CHANGE_ATTRIBUTE drop to the server; this script only marks the matching
// costumes as usable targets, shows the attribute in the enchant's tooltip and renders the "MTCATTR <code>" server
// messages that replace AVA2's locale strings 1056 / 1057 / 1061 / 1062 / 1063.
static const char c_szCostumeAttrScript[] = R"PY(# -*- coding: utf-8 -*-
import chat
import game
import item
import player
import uiInventory
import uiToolTip
L=player.AutoHuntLocalize
VNUMS=(54001,54267)
MAX_ATTR=3
# AVA2 CItem::GetBonusFlag of the costume kinds the server maps -> (mask bit, target name).
TARGETS={item.COSTUME_TYPE_BODY:(1,'时装'),item.COSTUME_TYPE_HAIR:(2,'发型'),item.COSTUME_TYPE_MOUNT_SKIN:(4,'坐骑外观')}
MESSAGES={'target':'该强化石不能用于这件物品。','none':'该强化石没有可附加的属性。','equipped':'请先卸下时装再附加属性。',
'full':'这件时装的附加属性已满(最多 3 条)。','has':'这件时装已有该属性。','done':'附加属性成功。'}
def Enchant(vnum):
"""(apply, value, mask) of a costume enchant, else None (selects vnum)."""
if not VNUMS[0]<=vnum<=VNUMS[1]:return None
item.SelectItem(vnum)
if item.GetItemType()!=item.ITEM_TYPE_USE or item.GetUseType(vnum)!="USE_CHANGE_ATTRIBUTE" or not item.GetValue(2):return None
return (item.GetValue(0),item.GetValue(1),item.GetValue(2))
def CanEnchant(enchant,dstSlot):
dstVnum=player.GetItemIndex(dstSlot)
if not dstVnum or player.IsEquipmentSlot(dstSlot):return False
item.SelectItem(dstVnum)
if item.GetItemType()!=item.ITEM_TYPE_COSTUME:return False
target=TARGETS.get(item.GetItemSubType())
if not target or not enchant[2]&(1<<target[0]):return False
attrs=[player.GetItemAttribute(dstSlot,i) for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM)]
used=[a for a in attrs if a[0]]
return len(used)<MAX_ATTR and enchant[0] not in [a[0] for a in used]
_canUse=uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem
def CanUseSrcItemToDstItem(self,srcItemVNum,srcSlotPos,dstSlotPos):
enchant=Enchant(srcItemVNum)
if enchant is None:return _canUse(self,srcItemVNum,srcSlotPos,dstSlotPos)
return srcSlotPos!=dstSlotPos and CanEnchant(enchant,dstSlotPos)
uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem=CanUseSrcItemToDstItem
Tip=uiToolTip.ItemToolTip
_addItemData=Tip.AddItemData
def AddItemData(self,itemVnum,metinSlot,attrSlot=0,flags=0,unbindTime=0):
result=_addItemData(self,itemVnum,metinSlot,attrSlot,flags,unbindTime)
enchant=Enchant(itemVnum)
if enchant:
self.AppendSpace(5)
affect=self._ItemToolTip__GetAffectString(enchant[0],enchant[1])
if affect:self.AppendTextLine(affect,self.POSITIVE_COLOR)
names=[name for bit,name in sorted(TARGETS.values()) if enchant[2]&(1<<bit)]
if names:self.AppendTextLine(L('适用:'+'、'.join(names)),self.CONDITION_COLOR)
self.ResizeToolTip()
return result
Tip.AddItemData=AddItemData
_command=game.GameWindow.BINARY_ServerCommand_Run
def Command(self,line):
if line.startswith('MTCATTR '):
words=line.split()
text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.BINARY_ServerCommand_Run=Command
)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";
+79
View File
@@ -0,0 +1,79 @@
// PORT: AVA2 mount costume + mount skin (ENABLE_MOUNT_COSTUME_SYSTEM / ENABLE_MOUNT_SKIN), as module mt_mount.
// The server swaps the horse race for the worn costume's value0 (metin2-server port_mount.cpp); this script
// registers the AVA2 npclist rows of the skin races (npclist.txt is read from the root pack only, so a patch
// pack cannot add them; RaceManager's __IsNewMountRace adds npc_mount to their search path), adds the mount
// and skin slots to the costume window under the sash slot, and renders the "MTMOUNT <code>" server
// messages that replace AVA2's locale strings 334 / 1097 / 1098. Models ship in the ava2items pack
// (tools/server/ava2-items, MOUNT_MOBS).
static const char c_szMountScript[] = R"PY(# -*- coding: utf-8 -*-
import chat
import chrmgr
import game
import item
import player
import ui
import uiInventory
L=player.AutoHuntLocalize
# AVA2 root/npclist.txt rows of the races MOUNT_MOBS ships.
RACES=((32012,"christmas_2017_sleddog_02"),(32028,"christmas_2020_alpaca"))
SRC_NAMES=(("christmas_2017_sleddog_02","christmas_2017_sleddog"),)
MESSAGES={'riding':'骑乘中无法更换坐骑时装。','need_mount':'需要先装备坐骑时装。','has_skin':'请先卸下坐骑外观。',
'need_horse':'需要先拥有马匹(马匹等级 1 以上)才能召唤坐骑。'}
# Costume window: left column under the sash slot (CostumeSlot coordinates; body is at 61,45).
SLOTS=((item.COSTUME_SLOT_MOUNT,(13,45)),(item.COSTUME_SLOT_MOUNT_SKIN,(13,82)))
for race,name in RACES:chrmgr.RegisterRaceName(race,name)
for name,src in SRC_NAMES:chrmgr.RegisterRaceSrcName(name,src)
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchCostumeWindow(window):
base=FindChild(window,"Costume_Base");slot=FindChild(window,"CostumeSlot")
if not base or not slot:return
frames=[];slots=[]
for index,(x,y) in SLOTS:
frames.append({"name":"MountSlotBase%d"%index,"type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"})
slots.append({"index":index,"x":x,"y":y,"width":32,"height":32})
base["children"]=tuple(frames)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+tuple(slots)
_execfile=ui.execfile
def ExecFile(fileName,dict):
_execfile(fileName,dict)
name=fileName.replace("\\","/").split("/")[-1].lower()
if name=="costumewindow.py" and "window" in dict:PatchCostumeWindow(dict["window"])
ui.execfile=ExecFile
_refreshCostume=uiInventory.CostumeWindow.RefreshCostumeSlot
def RefreshCostumeSlot(self):
_refreshCostume(self)
for index,_ in SLOTS:self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0)
self.wndEquip.RefreshSlot()
uiInventory.CostumeWindow.RefreshCostumeSlot=RefreshCostumeSlot
_command=game.GameWindow.BINARY_ServerCommand_Run
def Command(self,line):
if line.startswith('MTMOUNT '):
words=line.split()
text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+100
View File
@@ -0,0 +1,100 @@
// PORT: AVA2 talisman (ITEM_TALISMAN_EQUIPMENT + ELEMENT_NEW_BONUSES), as module mt_pendant. Talismans are
// ITEM_ARMOR / ARMOR_PENDANT worn in c_Equipment_Pendant (server WEAR_PENDANT, metin2-server port_pendant.cpp).
// This script adds that slot to the inventory's equipment panel (the free spot under the earring on
// equipment_base), marks unequipped talismans as drop targets of the talisman attribute items (71302 / 53996
// reroll, 71301 / 53997 add up to 5) and nothing else, and renders the "MTPENDANT <code>" server messages that
// replace AVA2's locale strings 1056 / 1057 / 1067.
static const char c_szPendantScript[] = R"PY(# -*- coding: utf-8 -*-
import chat
import game
import item
import player
import ui
import uiInventory
L=player.AutoHuntLocalize
CHANGE=(71302,53996)
ADD=(71301,53997)
MAX_ATTR=5
MESSAGES={'target':'该道具只能用于护符。','equipped':'请先卸下护符再使用。','empty':'这件护符没有可重洗的属性。',
'changed':'护符属性已重新随机。','full':'这件护符的属性已满(最多 5 条)。','added':'护符追加属性成功。'}
# EquipmentSlot coordinates of the slot (the earring is at 114,84).
SLOT=(item.EQUIPMENT_PENDANT,(114,118))
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchInventoryWindow(window):
base=FindChild(window,"Equipment_Base");slot=FindChild(window,"EquipmentSlot")
if not base or not slot:return
index,(x,y)=SLOT
frame={"name":"PendantSlotBase","type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"}
base["children"]=(frame,)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+({"index":index,"x":x,"y":y,"width":32,"height":32},)
_execfile=ui.execfile
def ExecFile(fileName,dict):
_execfile(fileName,dict)
name=fileName.replace("\\","/").split("/")[-1].lower()
if name=="inventorywindow.py" and "window" in dict:PatchInventoryWindow(dict["window"])
ui.execfile=ExecFile
_refreshEquip=uiInventory.InventoryWindow.RefreshEquipSlotWindow
def RefreshEquipSlotWindow(self):
_refreshEquip(self)
index=SLOT[0]
self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0)
self.wndEquip.RefreshSlot()
uiInventory.InventoryWindow.RefreshEquipSlotWindow=RefreshEquipSlotWindow
def IsTalisman(vnum):
if not vnum:return False
item.SelectItem(vnum)
return item.GetItemType()==item.ITEM_TYPE_ARMOR and item.GetItemSubType()==item.ARMOR_PENDANT
def AttrCount(slot):
return len([1 for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM) if player.GetItemAttribute(slot,i)[0]])
_canUse=uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem
def CanUseSrcItemToDstItem(self,srcItemVNum,srcSlotPos,dstSlotPos):
scroll=srcItemVNum in CHANGE or srcItemVNum in ADD
talisman=IsTalisman(player.GetItemIndex(dstSlotPos))
if not scroll:
# the server refuses every other attribute / socket item on a talisman
if talisman and item.GetUseType(srcItemVNum) in self.USE_TYPE_TUPLE:return False
return _canUse(self,srcItemVNum,srcSlotPos,dstSlotPos)
if srcSlotPos==dstSlotPos or not talisman or player.IsEquipmentSlot(dstSlotPos):return False
count=AttrCount(dstSlotPos)
return count>0 if srcItemVNum in CHANGE else count<MAX_ATTR
uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem=CanUseSrcItemToDstItem
_command=game.GameWindow.BINARY_ServerCommand_Run
def Command(self,line):
if line.startswith('MTPENDANT '):
words=line.split()
text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+28
View File
@@ -37,6 +37,7 @@
#include "UserInterface/PythonQuest.h"
#include "UserInterface/PythonMessenger.h"
#include "UserInterface/PythonSafeBox.h"
#include "UserInterface/PythonSash.h"
#include "UserInterface/PythonGuild.h"
#include "UserInterface/MarkManager.h"
#include "UserInterface/GuildMarkDownloader.h"
@@ -44,6 +45,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"
@@ -152,11 +154,13 @@ struct GameSingletons
CPythonQuest pyQuest;
CPythonMessenger pyManager;
CPythonSafeBox pySafeBox;
CPythonSash pySash; // PORT: AVA2 sash
CPythonGuild pyGuild;
CGuildMarkManager kGuildMarkManager;
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;
@@ -909,8 +913,10 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
initBackground();
initMessenger();
initsafebox();
initSash();
initguild();
initServerStateChecker();
initRenderTarget(); // PORT: AVA2 model preview
init_2v0_gameplay_stubs();
ClearLogBoxMessage();
@@ -1001,9 +1007,20 @@ 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"
#include "BattlePassScript.inc"
#include "AppliesScript.inc"
#include "ArmorShapesScript.inc"
#include "SashScript.inc"
#include "MountScript.inc"
#include "CostumeAttrScript.inc"
#include "PendantScript.inc"
#include "RingsScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript)
{
@@ -1079,9 +1096,20 @@ 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);
install_script_module("mt_battlepass", c_szBattlePassScript);
install_script_module("mt_applies", c_szAppliesScript);
install_script_module("mt_shapes", c_szArmorShapesScript);
install_script_module("mt_sash", c_szSashScript);
install_script_module("mt_mount", c_szMountScript);
install_script_module("mt_costume_attr", c_szCostumeAttrScript);
install_script_module("mt_pendant", c_szPendantScript);
install_script_module("mt_rings", c_szRingsScript);
if (g_touch_input)
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
}
+139
View File
@@ -0,0 +1,139 @@
// PORT: AVA2 new rings (ENABLE_NEW_RINGS), as module mt_rings. Rings are ITEM_TYPE_RINGS (36) worn in
// c_Equipment_Rings1/2 (server WEAR_RINGS_1/2, metin2-server port_rings.cpp). They are socket accessories with
// their own attribute pool, reached through the 40250 switchers / adders. This script adds the two slots to the
// inventory's equipment panel (the free spots beside the shoes on equipment_base, where AVA2 has them too), gives
// rings the accessory tooltip of AVA2 uitooltip.py (limits, applies, attributes, jewel sockets) and marks them as
// drop targets of the drill / jewel / switcher / adder items the server accepts.
static const char c_szRingsScript[] = R"PY(# -*- coding: utf-8 -*-
import item
import player
import uiInventory
import uiToolTip
import ui
# EquipmentSlot coordinates (the shoes are at 39,145).
SLOTS=((item.EQUIPMENT_RINGS1,(2,145)),(item.EQUIPMENT_RINGS2,(75,145)))
# AVA2 CItem::CanPutInto: ring family (vnum / 10 * 10) -> jewel.
JEWEL={19220:50634,19500:50635,19520:50636,19540:50637,19560:50638}
ACCESSORY_SOCKET_MAX_SIZE=3
def IsRing(vnum):
if not vnum:return False
item.SelectItem(vnum)
return item.GetItemType()==item.ITEM_TYPE_RINGS
def MaterialVnum(vnum):
return JEWEL.get(vnum/10*10,0)
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchInventoryWindow(window):
base=FindChild(window,"Equipment_Base");slot=FindChild(window,"EquipmentSlot")
if not base or not slot:return
frames=[];cells=[]
for i,(index,(x,y)) in enumerate(SLOTS):
frames.append({"name":"RingSlotBase%d"%i,"type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"})
cells.append({"index":index,"x":x,"y":y,"width":32,"height":32})
base["children"]=tuple(frames)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+tuple(cells)
_execfile=ui.execfile
def ExecFile(fileName,dict):
_execfile(fileName,dict)
name=fileName.replace("\\","/").split("/")[-1].lower()
if name=="inventorywindow.py" and "window" in dict:PatchInventoryWindow(dict["window"])
ui.execfile=ExecFile
_refreshEquip=uiInventory.InventoryWindow.RefreshEquipSlotWindow
def RefreshEquipSlotWindow(self):
_refreshEquip(self)
for index,_ in SLOTS:self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0)
self.wndEquip.RefreshSlot()
uiInventory.InventoryWindow.RefreshEquipSlotWindow=RefreshEquipSlotWindow
def AttrCount(slot):
return len([1 for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM) if player.GetItemAttribute(slot,i)[0]])
# 40250's __CanPutAccessorySocket / __CanAddAccessorySocket / __CanChangeItemAttrList / __CanAddItemAttr only take
# weapons, armor and the armor accessories; everything else (refine scrolls, ...) keeps the 40250 checks.
RING_USE_TYPES=("USE_ADD_ACCESSORY_SOCKET","USE_PUT_INTO_ACCESSORY_SOCKET","USE_CHANGE_ATTRIBUTE","USE_ADD_ATTRIBUTE","USE_ADD_ATTRIBUTE2")
def CanUseOnRing(srcItemVNum,dstSlotPos,useType):
dstItemVNum=player.GetItemIndex(dstSlotPos)
# the server refuses these items on a worn item ("Fix" in CHARACTER::UseItemEx)
if player.IsEquipmentSlot(dstSlotPos):return False
cur=player.GetItemMetinSocket(dstSlotPos,0);end=player.GetItemMetinSocket(dstSlotPos,1)
if useType=="USE_ADD_ACCESSORY_SOCKET":return end<ACCESSORY_SOCKET_MAX_SIZE
if useType=="USE_PUT_INTO_ACCESSORY_SOCKET":return srcItemVNum==MaterialVnum(dstItemVNum) and cur<end
# 71151 / 76023 / 71152 / 76024 are for weapons / armor
if srcItemVNum in (71151,76023,71152,76024):return False
count=AttrCount(dstSlotPos)
if useType=="USE_CHANGE_ATTRIBUTE":return count>0
if useType=="USE_ADD_ATTRIBUTE":return count<4
if useType=="USE_ADD_ATTRIBUTE2":return count==4
return False
_canUse=uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem
def CanUseSrcItemToDstItem(self,srcItemVNum,srcSlotPos,dstSlotPos):
useType=item.GetUseType(srcItemVNum)
if srcSlotPos!=dstSlotPos and useType in RING_USE_TYPES and IsRing(player.GetItemIndex(dstSlotPos)):
return CanUseOnRing(srcItemVNum,dstSlotPos,useType)
return _canUse(self,srcItemVNum,srcSlotPos,dstSlotPos)
uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem=CanUseSrcItemToDstItem
# --- tooltip (AVA2 uitooltip.py ITEM_TYPE_RINGS branch, without rarity) ---------------------------------------
# A ring falls into the generic branch of ItemToolTip.AddItemData, which only appends the limits; the rest of the
# accessory tooltip follows them.
Tip=uiToolTip.ItemToolTip
_addItemData=Tip.AddItemData
_appendLimit=Tip._ItemToolTip__AppendLimitInformation
def AddItemData(self,itemVnum,metinSlot,attrSlot=0,flags=0,unbindTime=0):
self.mtRing=(itemVnum,metinSlot,attrSlot) if IsRing(itemVnum) else None
try:
return _addItemData(self,itemVnum,metinSlot,attrSlot,flags,unbindTime)
finally:
self.mtRing=None
def AppendLimitInformation(self):
_appendLimit(self)
state=getattr(self,'mtRing',None)
if not state:return
self.mtRing=None
itemVnum,metinSlot,attrSlot=state
item.SelectItem(itemVnum)
self._ItemToolTip__AppendAffectInformation()
self._ItemToolTip__AppendAttributeInformation(attrSlot)
item.SelectItem(itemVnum)
self.AppendWearableInformation()
if metinSlot:
item.SelectItem(itemVnum)
self._ItemToolTip__AppendAccessoryMetinSlotInfo(metinSlot,MaterialVnum(itemVnum))
item.SelectItem(itemVnum)
Tip.AddItemData=AddItemData
Tip._ItemToolTip__AppendLimitInformation=AppendLimitInformation
)PY";
+612
View File
@@ -0,0 +1,612 @@
// PORT: AVA2 sash (披风) windows and tooltips, as module mt_sash. The C++ side is the AVA2 sash module
// (PythonSash.cpp, CG/GC_SASH packets, PART_SASH model + item_scale.txt); this script is AVA2 root/uisash.py
// (CombineWindow/AbsorbWindow verbatim, their uiscript dicts served through ui.execfile), the glue AVA2 put
// into game.py/interfacemodule.py/uiinventory.py (ActSash/AlertSash, right click adds to the open window,
// attached slots lit, costume window sash slot) and uitooltip.py's absorption lines (CalcSashValue,
// SetSashResultItem/SetSashResultAbsItem). Server messages arrive as "MTSASH <code>" commands
// (metin2-server port_sash.cpp). Assets ship in the ava2items pack (tools/server/ava2-items, SASH_*).
static const char c_szSashScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import chrmgr
import constInfo
import game
import item
import localeInfo
import mouseModule
import player
import sash
import ui
import uiInventory
import uiToolTip
L=player.AutoHuntLocalize
MESSAGES={'already_open':'披风窗口已经打开','absorb_open':'请先关闭披风吸收窗口','combine_open':'请先关闭披风合成窗口',
'locked':'物品已锁定,无法放入','grade':'只能放入相同品级的披风','max_abs':'该披风的吸收率已达上限',
'absorb_type':'只能吸收武器或铠甲','absorbed':'该披风已经吸收过物品','gold':'金币不足',
'success':'成功!','fail':'失败,作为材料的披风已消失'}
ABS_CHANGED='合成完成,吸收率变为 %d%%'
ALERT_COMBINE='合成失败时,作为材料的第二件披风将会消失。'
ALERT_ABSORB='吸收后,被吸收的物品将会消失。'
ABSORB_CHANCE='吸收率:%d%%'
ABSORB_CHANCE2='吸收率:%d%% ~ %d%%'
COMBINE_SCRIPT='''# -*- coding: utf-8 -*-
import player
import uiScriptLocale
L=player.AutoHuntLocalize
window = {
"name" : "Sash_CombineWindow",
"x" : (SCREEN_WIDTH - 215)/2, "y" : (SCREEN_HEIGHT - 270)/2,
"style" : ("movable", "float",),
"width" : 215, "height" : 270,
"children" : (
{
"name" : "board", "type" : "board", "style" : ("attach",),
"x" : 0, "y" : 0, "width" : 215, "height" : 270,
"children" : (
{
"name" : "TitleBar", "type" : "titlebar", "style" : ("attach",),
"x" : 6, "y" : 6, "width" : 200, "color" : "yellow",
"children" : (
{ "name" : "TitleName", "type" : "text", "x" : 95, "y" : 3, "text" : L("披风合成"), "text_horizontal_align" : "center" },
),
},
{
"name" : "Sash_Combine", "type" : "image", "x" : 9, "y" : 35,
"image" : uiScriptLocale.LOCALE_UISCRIPT_PATH + "acce/acce_combine.tga",
"children" : (
{
"name" : "SashSlot", "type" : "slot", "x" : 3, "y" : 3, "width" : 200, "height" : 150,
"slot" : (
{"index":0, "x":78, "y":7, "width":32, "height":32},
{"index":1, "x":78, "y":60, "width":32, "height":32},
{"index":2, "x":78, "y":115, "width":32, "height":32},
),
},
{ "name" : "Main", "type" : "text", "text" : L("主披风"), "text_horizontal_align" : "center", "x" : 85 + 12, "y" : 7 + 36 },
{ "name" : "serve", "type" : "text", "text" : L("材料披风"), "text_horizontal_align" : "center", "x" : 85 + 12, "y" : 60 + 38 },
{ "name" : "Result", "type" : "text", "text" : L("合成结果"), "text_horizontal_align" : "center", "x" : 85 + 12, "y" : 115 + 40 },
),
},
{ "name" : "NeedMoney", "type" : "text", "text" : "", "text_horizontal_align" : "center", "x" : 105, "y" : 215 },
{
"name" : "AcceptButton", "type" : "button", "x" : 40, "y" : 235, "text" : uiScriptLocale.OK,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
{
"name" : "CancelButton", "type" : "button", "x" : 114, "y" : 235, "text" : uiScriptLocale.CANCEL,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
),
},
),
}
'''
ABSORB_SCRIPT='''# -*- coding: utf-8 -*-
import player
import uiScriptLocale
L=player.AutoHuntLocalize
window = {
"name" : "Sash_AbsorbtionWindow",
"x" : (SCREEN_WIDTH - 205)/2, "y" : (SCREEN_HEIGHT - 270)/2,
"style" : ("movable", "float",),
"width" : 205, "height" : 270,
"children" : (
{
"name" : "board", "type" : "board", "style" : ("attach",),
"x" : 0, "y" : 0, "width" : 205, "height" : 270,
"children" : (
{
"name" : "TitleBar", "type" : "titlebar", "style" : ("attach",),
"x" : 6, "y" : 6, "width" : 190, "color" : "yellow",
"children" : (
{ "name" : "TitleName", "type" : "text", "x" : 95, "y" : 3, "text" : L("披风吸收"), "text_horizontal_align" : "center" },
),
},
{
"name" : "Sash_Combine", "type" : "image", "x" : 9, "y" : 35,
"image" : uiScriptLocale.LOCALE_UISCRIPT_PATH + "acce/acce_absorb.tga",
"children" : (
{
"name" : "SashSlot", "type" : "slot", "x" : 3, "y" : 3, "width" : 190, "height" : 200,
"slot" : (
{"index":0, "x":26, "y":41, "width":31, "height":31},
{"index":1, "x":125, "y":8, "width":31, "height":96},
{"index":2, "x":75, "y":126, "width":31, "height":31},
),
},
),
},
{
"name" : "AcceptButton", "type" : "button", "x" : 40, "y" : 235, "text" : uiScriptLocale.OK,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
{
"name" : "CancelButton", "type" : "button", "x" : 114, "y" : 235, "text" : uiScriptLocale.CANCEL,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
),
},
),
}
'''
SCRIPTS={'sash_combinewindow.py':COMBINE_SCRIPT,'sash_absorbwindow.py':ABSORB_SCRIPT}
# Costume window: the sash slot left of the hair slot (CostumeSlot coordinates).
COSTUME_SASH_POS=(13,8)
g_windows=None
g_effects=False
def CalcSashValue(value,abs):
# AVA2 uitooltip.CalcSashValue; the server scales applies the same way (port_sash.cpp).
if not value:return 0
valueCalc=round(value*abs)/100
valueCalc+=0.5
valueCalc=int(valueCalc)+1 if valueCalc>0 else int(valueCalc)
return 1 if (valueCalc<=0 and value>0) else valueCalc
def IsSash(itemVnum):
item.SelectItem(itemVnum)
return item.GetItemType()==item.ITEM_TYPE_COSTUME and item.GetItemSubType()==item.COSTUME_TYPE_SASH
def IsSashWindowOpen():
return bool(g_windows) and (g_windows[0].IsOpened() or g_windows[1].IsOpened())
class CombineWindow(ui.ScriptWindow):
def __init__(self):
ui.ScriptWindow.__init__(self)
self.isLoaded=0;self.PositionOut=0;self.PositionStartX=0;self.PositionStartY=0
self.tooltipItem=None
def __del__(self):
ui.ScriptWindow.__del__(self)
def Destroy(self):
self.ClearDictionary()
self.titleBar=None;self.btnAccept=None;self.btnCancel=None;self.sashSlot=None;self.needMoney=None;self.Result=None
self.tooltipItem=None;self.PositionOut=0;self.PositionStartX=0;self.PositionStartY=0
def LoadWindow(self):
if self.isLoaded:return
self.isLoaded=1
try:
pyScrLoader=ui.PythonScriptLoader()
pyScrLoader.LoadScriptFile(self,"uiscript/sash_combinewindow.py")
except:
import exception
exception.Abort("Sash_CombineWindow.LoadDialog.LoadScript")
try:
self.board=self.GetChild("board")
self.titleBar=self.GetChild("TitleBar")
self.btnAccept=self.GetChild("AcceptButton")
self.btnCancel=self.GetChild("CancelButton")
self.needMoney=self.GetChild("NeedMoney")
self.Result=self.GetChild("Result")
self.sashSlot=self.GetChild("SashSlot")
except:
import exception
exception.Abort("Sash_CombineWindow.LoadDialog.BindObject")
self.sashSlot.SetSelectEmptySlotEvent(ui.__mem_func__(self.OnSelectEmptySlot))
self.sashSlot.SetUnselectItemSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetUseSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetOverInItemEvent(ui.__mem_func__(self.OnOverInItem))
self.sashSlot.SetOverOutItemEvent(ui.__mem_func__(self.OnOverOutItem))
self.titleBar.SetCloseEvent(ui.__mem_func__(self.OnClose))
self.btnCancel.SetEvent(ui.__mem_func__(self.OnClose))
self.btnAccept.SetEvent(ui.__mem_func__(self.OnAccept))
def SetItemToolTip(self,itemTooltip):
self.tooltipItem=itemTooltip
def IsOpened(self):
return bool(self.IsShow() and self.isLoaded)
def SetPrice(self):
self.needMoney.SetText(L('合成费用:')+localeInfo.NumberToMoneyString(sash.GetPrice()))
def Open(self):
self.PositionOut=0
(self.PositionStartX,self.PositionStartY,z)=player.GetMainCharacterPosition()
self.SetPrice()
for i in xrange(sash.WINDOW_MAX_MATERIALS+1):
self.sashSlot.ClearSlot(i)
self.Show()
def Close(self):
if self.tooltipItem:self.tooltipItem.HideToolTip()
self.Hide()
def OnClose(self):
sash.SendCloseRequest()
def OnPressEscapeKey(self):
self.OnClose()
return True
def OnAccept(self):
sash.SendRefineRequest()
def OnUpdate(self):
LIMIT_RANGE=sash.LIMIT_RANGE
(x,y,z)=player.GetMainCharacterPosition()
if abs(x-self.PositionStartX)>=LIMIT_RANGE or abs(y-self.PositionStartY)>=LIMIT_RANGE:
if not self.PositionOut:
self.PositionOut+=1
self.OnClose()
def OnSelectEmptySlot(self,selectedSlotPos):
isAttached=mouseModule.mouseController.isAttached()
if not isAttached or selectedSlotPos==sash.WINDOW_MAX_MATERIALS:return
attachedSlotType=mouseModule.mouseController.GetAttachedType()
attachedSlotPos=mouseModule.mouseController.GetAttachedSlotNumber()
attachedInvenType=player.SlotTypeToInvenType(attachedSlotType)
mouseModule.mouseController.DeattachObject()
if attachedSlotType==player.SLOT_TYPE_INVENTORY and attachedInvenType==player.INVENTORY:
sash.Add(attachedInvenType,attachedSlotPos,selectedSlotPos)
def OnSelectItemSlot(self,selectedSlotPos):
if selectedSlotPos==sash.WINDOW_MAX_MATERIALS:return
mouseModule.mouseController.DeattachObject()
sash.Remove(selectedSlotPos)
def OnOverInItem(self,selectedSlotPos):
if self.tooltipItem:
if selectedSlotPos==sash.WINDOW_MAX_MATERIALS:
(isHere,iCell)=sash.GetAttachedItem(0)
if isHere:self.tooltipItem.SetSashResultItem(iCell)
else:
(isHere,iCell)=sash.GetAttachedItem(selectedSlotPos)
if isHere:self.tooltipItem.SetInventoryItem(iCell)
def OnOverOutItem(self):
if self.tooltipItem:self.tooltipItem.HideToolTip()
def Refresh(self,iAct):
self.SetPrice()
self.sashSlot.ClearSlot(sash.WINDOW_MAX_MATERIALS)
for i in xrange(sash.WINDOW_MAX_MATERIALS):
self.sashSlot.ClearSlot(i)
(isHere,iCell)=sash.GetAttachedItem(i)
if isHere:
self.sashSlot.SetItemSlot(i,player.GetItemIndex(iCell),0)
if i==int(sash.WINDOW_MAX_MATERIALS-1):
(itemVnum,MinAbs,MaxAbs)=sash.GetResultItem()
if not itemVnum:break
self.sashSlot.SetItemSlot(i+1,itemVnum,0)
break
self.sashSlot.RefreshSlot()
class AbsorbWindow(CombineWindow):
# AVA2 AbsorbWindow: CombineWindow without the price line, a two-item result tooltip.
def LoadWindow(self):
if self.isLoaded:return
self.isLoaded=1
try:
pyScrLoader=ui.PythonScriptLoader()
pyScrLoader.LoadScriptFile(self,"uiscript/sash_absorbwindow.py")
except:
import exception
exception.Abort("Sash_AbsorbtionWindow.LoadDialog.LoadScript")
try:
self.titleBar=self.GetChild("TitleBar")
self.btnAccept=self.GetChild("AcceptButton")
self.btnCancel=self.GetChild("CancelButton")
self.sashSlot=self.GetChild("SashSlot")
except:
import exception
exception.Abort("Sash_AbsorbtionWindow.LoadDialog.BindObject")
self.sashSlot.SetSelectEmptySlotEvent(ui.__mem_func__(self.OnSelectEmptySlot))
self.sashSlot.SetUnselectItemSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetUseSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetOverInItemEvent(ui.__mem_func__(self.OnOverInItem))
self.sashSlot.SetOverOutItemEvent(ui.__mem_func__(self.OnOverOutItem))
self.titleBar.SetCloseEvent(ui.__mem_func__(self.OnClose))
self.btnCancel.SetEvent(ui.__mem_func__(self.OnClose))
self.btnAccept.SetEvent(ui.__mem_func__(self.OnAccept))
def SetPrice(self):
pass
def OnOverInItem(self,selectedSlotPos):
if self.tooltipItem:
if selectedSlotPos==sash.WINDOW_MAX_MATERIALS:
(isHere1,iCell1)=sash.GetAttachedItem(0)
(isHere2,iCell2)=sash.GetAttachedItem(1)
if isHere1 and isHere2:self.tooltipItem.SetSashResultAbsItem(iCell1,iCell2)
else:
(isHere,iCell)=sash.GetAttachedItem(selectedSlotPos)
if isHere:self.tooltipItem.SetInventoryItem(iCell)
# --- tooltip (AVA2 uitooltip.py ENABLE_SASH_SYSTEM parts) -------------------------------------------------
Tip=uiToolTip.ItemToolTip
_addItemData=Tip.AddItemData
_appendAffect=Tip._ItemToolTip__AppendAffectInformation
_appendAttribute=Tip._ItemToolTip__AppendAttributeInformation
def AppendAbsorbedItem(self,itemAbsorbedVnum,absChance):
# Attack/defence and applies of the absorbed item, scaled by the absorption rate.
item.SelectItem(itemAbsorbedVnum)
itemType=item.GetItemType()
if itemType==item.ITEM_TYPE_WEAPON:
attack=[item.GetValue(3),item.GetValue(4),item.GetValue(5)]
magic=[item.GetValue(1),item.GetValue(2),item.GetValue(5)]
lines=[]
minPower,maxPower,addPower=[CalcSashValue(v,absChance) for v in attack]
if maxPower>minPower:lines.append(localeInfo.TOOLTIP_ITEM_ATT_POWER%(minPower+addPower,maxPower+addPower))
else:lines.append(localeInfo.TOOLTIP_ITEM_ATT_POWER_ONE_ARG%(minPower+addPower))
minMagic,maxMagic,addPower=[CalcSashValue(v,absChance) for v in magic]
if magic[0]>0 or magic[1]>0:
if maxMagic>minMagic:magicLine=localeInfo.TOOLTIP_ITEM_MAGIC_ATT_POWER%(minMagic+addPower,maxMagic+addPower)
else:magicLine=localeInfo.TOOLTIP_ITEM_MAGIC_ATT_POWER_ONE_ARG%(minMagic+addPower)
if item.GetItemSubType()==item.WEAPON_FAN:lines.insert(0,magicLine)
else:lines.append(magicLine)
for line in lines:self.AppendTextLine(line,self.POSITIVE_COLOR)
elif itemType==item.ITEM_TYPE_ARMOR:
defGrade=CalcSashValue(item.GetValue(1),absChance)
defBonus=CalcSashValue(item.GetValue(5)*2,absChance)
if defGrade>0:
self.AppendSpace(5)
self.AppendTextLine(localeInfo.TOOLTIP_ITEM_DEF_GRADE%(defGrade+defBonus),self.GetChangeTextLineColor(defGrade))
magicDefencePower=CalcSashValue(item.GetValue(0),absChance)
if magicDefencePower>0:
self.AppendTextLine(localeInfo.TOOLTIP_ITEM_MAGIC_DEF_POWER%magicDefencePower,self.GetChangeTextLineColor(magicDefencePower))
for i in xrange(item.ITEM_APPLY_MAX_NUM):
item.SelectItem(itemAbsorbedVnum)
(affectType,affectValue)=item.GetAffect(i)
affectValue=CalcSashValue(affectValue,absChance)
affectString=self._ItemToolTip__GetAffectString(affectType,affectValue)
if affectString and affectValue>0:
self.AppendTextLine(affectString,self.GetChangeTextLineColor(affectValue))
def AppendScaledAttributes(self,attrSlot,absChance):
if not attrSlot:return
for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM):
type,value=attrSlot[i][0],attrSlot[i][1]
if not value:continue
value=CalcSashValue(value,absChance)
affectString=self._ItemToolTip__GetAffectString(type,value)
if affectString and value>0:
self.AppendTextLine(affectString,self._ItemToolTip__GetAttributeColor(i,value))
def AddItemData(self,itemVnum,metinSlot,attrSlot=0,flags=0,unbindTime=0):
self.mtSash=None
if metinSlot and IsSash(itemVnum):
self.mtSash=(itemVnum,int(metinSlot[sash.ABSORPTION_SOCKET]),int(metinSlot[sash.ABSORBED_SOCKET]))
try:
return _addItemData(self,itemVnum,metinSlot,attrSlot,flags,unbindTime)
finally:
self.mtSash=None
def AppendAffectInformation(self):
_appendAffect(self)
state=getattr(self,'mtSash',None)
if not state:return
itemVnum,absChance,itemAbsorbedVnum=state
self.AppendTextLine(L(ABSORB_CHANCE%absChance),self.CONDITION_COLOR)
if itemAbsorbedVnum:AppendAbsorbedItem(self,itemAbsorbedVnum,absChance)
item.SelectItem(itemVnum)
def AppendAttributeInformation(self,attrSlot):
state=getattr(self,'mtSash',None)
if not state:return _appendAttribute(self,attrSlot)
AppendScaledAttributes(self,attrSlot,state[1])
item.SelectItem(state[0])
def SetSashResultItem(self,slotIndex,window_type=player.INVENTORY):
(itemVnum,MinAbs,MaxAbs)=sash.GetResultItem()
if not itemVnum:return
self.ClearToolTip()
metinSlot=[player.GetItemMetinSocket(window_type,slotIndex,i) for i in xrange(player.METIN_SOCKET_MAX_NUM)]
attrSlot=[player.GetItemAttribute(window_type,slotIndex,i) for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM)]
if not IsSash(itemVnum):return
itemDesc=item.GetItemDescription()
self._ItemToolTip__AdjustMaxWidth(attrSlot,itemDesc)
self._ItemToolTip__SetItemTitle(itemVnum,metinSlot,attrSlot)
self.AppendDescription(itemDesc,26)
self.AppendDescription(item.GetItemSummary(),26,self.CONDITION_COLOR)
self._ItemToolTip__AppendLimitInformation()
if MinAbs==MaxAbs:self.AppendTextLine(L(ABSORB_CHANCE%MinAbs),self.CONDITION_COLOR)
else:self.AppendTextLine(L(ABSORB_CHANCE2%(MinAbs,MaxAbs)),self.CONDITION_COLOR)
itemAbsorbedVnum=int(metinSlot[sash.ABSORBED_SOCKET])
if itemAbsorbedVnum:AppendAbsorbedItem(self,itemAbsorbedVnum,MaxAbs)
AppendScaledAttributes(self,attrSlot,MaxAbs)
item.SelectItem(itemVnum)
self.AppendWearableInformation()
self.ShowToolTip()
def SetSashResultAbsItem(self,slotIndex1,slotIndex2,window_type=player.INVENTORY):
itemVnumSash=player.GetItemIndex(window_type,slotIndex1)
itemVnumTarget=player.GetItemIndex(window_type,slotIndex2)
if not itemVnumSash or not itemVnumTarget:return
self.ClearToolTip()
if not IsSash(itemVnumSash):return
metinSlot=[player.GetItemMetinSocket(window_type,slotIndex1,i) for i in xrange(player.METIN_SOCKET_MAX_NUM)]
attrSlot=[player.GetItemAttribute(window_type,slotIndex2,i) for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM)]
absChance=int(metinSlot[sash.ABSORPTION_SOCKET])
itemDesc=item.GetItemDescription()
self._ItemToolTip__AdjustMaxWidth(attrSlot,itemDesc)
self._ItemToolTip__SetItemTitle(itemVnumSash,metinSlot,attrSlot)
self.AppendDescription(itemDesc,26)
self.AppendDescription(item.GetItemSummary(),26,self.CONDITION_COLOR)
item.SelectItem(itemVnumSash)
self._ItemToolTip__AppendLimitInformation()
self.AppendTextLine(L(ABSORB_CHANCE%absChance),self.CONDITION_COLOR)
AppendAbsorbedItem(self,itemVnumTarget,absChance)
AppendScaledAttributes(self,attrSlot,absChance)
item.SelectItem(itemVnumSash)
self.AppendWearableInformation()
self.ShowToolTip()
Tip.AddItemData=AddItemData
Tip._ItemToolTip__AppendAffectInformation=AppendAffectInformation
Tip._ItemToolTip__AppendAttributeInformation=AppendAttributeInformation
Tip.SetSashResultItem=SetSashResultItem
Tip.SetSashResultAbsItem=SetSashResultAbsItem
# --- uiscripts: the two sash windows and the costume window's sash slot ------------------------------------
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchCostumeWindow(window):
base=FindChild(window,"Costume_Base");slot=FindChild(window,"CostumeSlot")
if not base or not slot:return
x,y=COSTUME_SASH_POS
frame={"name":"SashSlotBase","type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"}
base["children"]=(frame,)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+({"index":item.COSTUME_SLOT_SASH,"x":x,"y":y,"width":32,"height":32},)
_execfile=ui.execfile
def ExecFile(fileName,dict):
name=fileName.replace("\\","/").split("/")[-1].lower()
if name in SCRIPTS:
exec(SCRIPTS[name],dict)
return
_execfile(fileName,dict)
if name=="costumewindow.py" and "window" in dict:PatchCostumeWindow(dict["window"])
ui.execfile=ExecFile
_refreshCostume=uiInventory.CostumeWindow.RefreshCostumeSlot
def RefreshCostumeSlot(self):
_refreshCostume(self)
self.wndEquip.SetItemSlot(item.COSTUME_SLOT_SASH,player.GetItemIndex(item.COSTUME_SLOT_SASH),0)
self.wndEquip.RefreshSlot()
uiInventory.CostumeWindow.RefreshCostumeSlot=RefreshCostumeSlot
# --- inventory: right click adds to the open sash window, attached items are lit ----------------------------
_useItemSlot=uiInventory.InventoryWindow.UseItemSlot
_refreshBag=uiInventory.InventoryWindow.RefreshBagSlotWindow
def UseItemSlot(self,slotIndex):
if IsSashWindowOpen() and app.GetCursor()!=app.SELL:
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slotIndex)
if not player.IsEquipmentSlot(cell):
sash.Add(player.INVENTORY,cell,255)
return
return _useItemSlot(self,slotIndex)
def AutoPotionActive(cell):
return constInfo.IS_AUTO_POTION(player.GetItemIndex(cell)) and player.GetItemMetinSocket(cell,0)!=0
def RefreshBagSlotWindow(self):
_refreshBag(self)
attached=set()
if IsSashWindowOpen():
for j in xrange(sash.WINDOW_MAX_MATERIALS):
(isHere,iCell)=sash.GetAttachedItem(j)
if isHere:attached.add(iCell)
lit=getattr(self,'mtSashLit',set())
now=set()
for i in xrange(player.INVENTORY_PAGE_SIZE):
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(i)
if cell in attached:
self.wndItem.ActivateSlot(i);now.add(i)
elif i in lit and not AutoPotionActive(cell):
self.wndItem.DeactivateSlot(i)
self.mtSashLit=now
self.wndItem.RefreshSlot()
uiInventory.InventoryWindow.UseItemSlot=UseItemSlot
uiInventory.InventoryWindow.RefreshBagSlotWindow=RefreshBagSlotWindow
# --- game window: ActSash/AlertSash (called by the GC_SASH packet), MTSASH messages, effects -----------------
def RegisterEffects():
global g_effects
if g_effects:return
g_effects=True
chrmgr.RegisterEffect(chrmgr.EFFECT_SASH_SUCCEDED,"","d:/ymir work/effect/etc/buff/buff_item6.mse")
chrmgr.RegisterEffect(chrmgr.EFFECT_SASH_EQUIP,"","d:/ymir work/effect/etc/buff/buff_item7.mse")
chrmgr.RegisterEffect(chrmgr.EFFECT_REFINED+chrmgr.EFFECT_SASH,"Bip01","d:/ymir work/pc/common/effect/armor/acc_01.mse")
_open=game.GameWindow.Open
_close=game.GameWindow.Close
_command=game.GameWindow.BINARY_ServerCommand_Run
def Open(self):
global g_windows
_open(self)
RegisterEffects()
g_windows=(CombineWindow(),AbsorbWindow())
for window in g_windows:
window.LoadWindow();window.Hide()
if self.interface:window.SetItemToolTip(self.interface.tooltipItem)
def Close(self):
global g_windows
if g_windows:
for window in g_windows:window.Close();window.Destroy()
g_windows=None
_close(self)
def ActSash(self,iAct,bWindow):
if not g_windows or not self.interface:return
window=g_windows[0 if bWindow else 1]
inventory=self.interface.wndInventory
if iAct==1:
if not window.IsOpened():window.Open()
if inventory and not inventory.IsShow():inventory.Show()
elif iAct==2:
if window.IsOpened():window.Close()
elif iAct==3 or iAct==4:
if window.IsOpened():window.Refresh(iAct)
if inventory:inventory.RefreshBagSlotWindow()
def AlertSash(self,bWindow):
self.PopupMessage(L(ALERT_COMBINE if bWindow else ALERT_ABSORB))
def Command(self,line):
if line.startswith('MTSASH '):
words=line.split()
if len(words)==3 and words[1]=='abs' and words[2].isdigit():text=ABS_CHANGED%int(words[2])
else:text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.ActSash=ActSash
game.GameWindow.AlertSash=AlertSash
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+4 -2
View File
@@ -188,9 +188,11 @@ class SwitchbotWindow(ui.Window):
vnum=player.GetItemIndex(player.INVENTORY,cell)
if not vnum:continue
item.SelectItem(vnum);kind=item.GetItemType()
if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE:
# 54001-54267 with a value2 mask are AVA2 costume enchants (mt_costume_attr), 71302 / 53996 talisman
# rerolls (mt_pendant), not switchers; talismans have their own attribute pool.
if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE and not (54001<=vnum<=54267 and item.GetValue(2)) and vnum not in (71302,53996):
scrolls[vnum]=scrolls.get(vnum,0)+player.GetItemCount(player.INVENTORY,cell)
elif kind in (item.ITEM_TYPE_WEAPON,item.ITEM_TYPE_ARMOR) and any(player.GetItemAttribute(player.INVENTORY,cell,i)[0] for i in range(5)):
elif kind in (item.ITEM_TYPE_WEAPON,item.ITEM_TYPE_ARMOR) and not (kind==item.ITEM_TYPE_ARMOR and item.GetItemSubType()==item.ARMOR_PENDANT) and any(player.GetItemAttribute(player.INVENTORY,cell,i)[0] for i in range(5)):
items.append((cell,vnum))
selected=self.Selected();scroll=self.Scroll();state=self.bot.state
if state and state['active'] and (state['cell'],state['vnum']) in items:
+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);
@@ -755,6 +760,12 @@ bool LoadLocaleData(const char* localePath)
Tracenf("LoadLocaleData - LoadItemDesc(%s) Error", szItemDesc);
}
// PORT: AVA2 sash part scale (ENABLE_SASH_SYSTEM)
char szItemScale[256];
snprintf(szItemScale, sizeof(szItemScale), "%s/item_scale.txt", localePath);
if (!rkItemMgr.LoadItemScale(szItemScale))
Tracenf("LoadLocaleData: error while loading %s.", szItemScale);
if (!rkNPCMgr.LoadNonPlayerData(szMobProto))
{
TraceError("LoadLocaleData - LoadMobProto(%s) Error", szMobProto);
+6
View File
@@ -326,12 +326,18 @@ 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)
add_test(NAME port.ava2_items
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tools/server/ava2-items/tests/test_items.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)
target_link_libraries(locale_proto_patch PRIVATE port_platform)
add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp)
target_link_libraries(biolog_proto_patch PRIVATE port_platform)
add_executable(item_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/item_proto_patch.cpp)
target_link_libraries(item_proto_patch PRIVATE port_platform)
# Locale packs in the original format (scripts/build_zh_locale.py packs locale/zh with it).
add_executable(eterpack_pack ${PROJECT_SOURCE_DIR}/tests/port/eterpack_pack.cpp)
target_link_libraries(eterpack_pack PRIVATE port_platform)
+44 -10
View File
@@ -685,21 +685,50 @@ void CGraphicThingInstance::DeformAll()
{
m_bUpdated = true;
CGrannyLODController::FDeformAll deform;
deform.mc_pWorldMatrix = &m_worldMatrix;
std::for_each(m_LODControllerVector.begin(), m_LODControllerVector.end(), deform);
D3DXMATRIX matScaled;
for (int i = 0; i < (int)m_LODControllerVector.size(); ++i)
{
CGrannyLODController* pkLOD = m_LODControllerVector[i];
if (pkLOD->isModelInstance())
pkLOD->DeformAll(__GetPartWorldMatrix(i, matScaled));
}
}
void CGraphicThingInstance::SetPartScale(int iPart, float fX, float fY, float fZ)
{
D3DXMATRIX matScale;
D3DXMatrixScaling(&matScale, fX, fY, fZ);
m_partScaleMap[iPart] = matScale;
}
void CGraphicThingInstance::ClearPartScale(int iPart)
{
m_partScaleMap.erase(iPart);
}
const D3DXMATRIX * CGraphicThingInstance::__GetPartWorldMatrix(int iPart, D3DXMATRIX & rScaled) const
{
if (m_partScaleMap.empty())
return &m_worldMatrix;
std::map<int, D3DXMATRIX>::const_iterator it = m_partScaleMap.find(iPart);
if (it == m_partScaleMap.end())
return &m_worldMatrix;
D3DXMatrixMultiply(&rScaled, &it->second, &m_worldMatrix);
return &rScaled;
}
void CGraphicThingInstance::DeformNoSkin()
{
m_bUpdated = true;
std::vector<CGrannyLODController*>::iterator i;
for (i=m_LODControllerVector.begin(); i!=m_LODControllerVector.end(); ++i)
D3DXMATRIX matScaled;
for (int i = 0; i < (int)m_LODControllerVector.size(); ++i)
{
CGrannyLODController* pkLOD=*i;
CGrannyLODController* pkLOD=m_LODControllerVector[i];
if (pkLOD->isModelInstance())
pkLOD->DeformNoSkin(&m_worldMatrix);
pkLOD->DeformNoSkin(__GetPartWorldMatrix(i, matScaled));
}
}
@@ -707,9 +736,13 @@ void CGraphicThingInstance::OnDeform()
{
m_bUpdated = true;
CGrannyLODController::FDeform deform;
deform.mc_pWorldMatrix = &m_worldMatrix;
std::for_each(m_LODControllerVector.begin(), m_LODControllerVector.end(), deform);
D3DXMATRIX matScaled;
for (int i = 0; i < (int)m_LODControllerVector.size(); ++i)
{
CGrannyLODController* pkLOD = m_LODControllerVector[i];
if (pkLOD->isModelInstance())
pkLOD->Deform(__GetPartWorldMatrix(i, matScaled));
}
}
void CGraphicThingInstance::__SetLocalTime(float fLocalTime)
@@ -938,6 +971,7 @@ void CGraphicThingInstance::OnClear()
{
stl_wipe(m_LODControllerVector);
stl_wipe_second(m_roMotionThingMap);
m_partScaleMap.clear();
for (DWORD d = 0; d < m_modelThingSetVector.size(); ++d)
m_modelThingSetVector[d].Clear();
+8
View File
@@ -34,6 +34,11 @@ class CGraphicThingInstance : public CGraphicObjectInstance
void UpdateLODLevel();
void UpdateTime();
// PORT: AVA2 ENABLE_OBJ_SCALLING for the sash part: the part is deformed with Scale * world,
// i.e. scaled about the actor origin in actor space (item_scale.txt).
void SetPartScale(int iPart, float fX, float fY, float fZ);
void ClearPartScale(int iPart);
void DeformAll(); // 모든 LOD 디폼
bool LessRenderOrder(CGraphicThingInstance* pkThingInst);
@@ -139,6 +144,9 @@ class CGraphicThingInstance : public CGraphicObjectInstance
std::vector<CGrannyLODController *> m_LODControllerVector;
std::vector<TModelThingSet> m_modelThingSetVector;
std::map<DWORD, CGraphicThing::TRef *> m_roMotionThingMap;
std::map<int, D3DXMATRIX> m_partScaleMap; // PORT: AVA2 sash scale
const D3DXMATRIX * __GetPartWorldMatrix(int iPart, D3DXMATRIX & rScaled) const;
protected:
virtual void OnUpdateCollisionData(const CStaticCollisionDataVector * pscdVector);
+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{};
};
+1
View File
@@ -243,6 +243,7 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject
bool SetRace(DWORD eRace);
void SetHair(DWORD eHair);
void AttachSash(CItemData * pItemData, float fSpecular = 0.0f); // PORT: AVA2 sash
void SetVirtualID(DWORD dwVID);
void SetShape(DWORD eShape, float fSpecular=0.0f);
+41
View File
@@ -624,3 +624,44 @@ void CActorInstance::__ClearAttachingEffect()
}
m_AttachingEffectList.clear();
}
// PORT: AVA2 ActorInstanceAttach.cpp AttachSash (ENABLE_SASH_SYSTEM). The sash is a model part on
// "Bip01 Spine2" that plays its own first motion (the cloth sway).
void CActorInstance::AttachSash(CItemData * pItemData, float fSpecular)
{
if (!pItemData)
{
RegisterModelThing(CRaceData::PART_SASH, NULL);
SetModelInstance(CRaceData::PART_SASH, CRaceData::PART_SASH, 0);
RefreshActorInstance();
return;
}
RegisterModelThing(CRaceData::PART_SASH, pItemData->GetModelThing());
SetModelInstance(CRaceData::PART_SASH, CRaceData::PART_SASH, 0);
AttachModelInstance(CRaceData::PART_MAIN, "Bip01 Spine2", CRaceData::PART_SASH);
if (fSpecular > 0.0f)
{
SMaterialData kMaterialData;
kMaterialData.pImage = NULL;
kMaterialData.isSpecularEnable = TRUE;
kMaterialData.fSpecularPower = fSpecular;
kMaterialData.bSphereMapIndex = 1;
SetMaterialData(CRaceData::PART_SASH, NULL, kMaterialData);
}
if (CRaceData::PART_SASH >= m_LODControllerVector.size())
return;
if (CGrannyLODController* pLODController = m_LODControllerVector[CRaceData::PART_SASH])
{
if (CGrannyModelInstance* pSashModelInstance = pLODController->GetModelInstance())
{
CGraphicThing* pItemGraphicThing = pItemData->GetModelThing();
if (pItemGraphicThing && pItemGraphicThing->GetMotionCount() > 0)
if (CGrannyMotion* pItemMotion = pItemGraphicThing->GetMotionPointer(0))
pSashModelInstance->SetMotionPointer(pItemMotion);
}
}
}
+38
View File
@@ -215,6 +215,43 @@ void CItemData::SetItemTableData(TItemTable * pItemTable)
memcpy(&m_ItemTable, pItemTable, sizeof(TItemTable));
}
// PORT: AVA2 ItemData.cpp SetItemScale/GetItemScale. The position columns are dropped: AVA2 feeds
// them to SetScalePosition, which its deform never reads. JOB_WOLFMAN rows are skipped (no wolfman).
void CItemData::SetItemScale(const std::string & strJob, const std::string & strSex, const std::string & strScaleX, const std::string & strScaleY, const std::string & strScaleZ)
{
DWORD dwPos;
if (strJob == "JOB_WARRIOR")
dwPos = NRaceData::JOB_WARRIOR;
else if (strJob == "JOB_ASSASSIN")
dwPos = NRaceData::JOB_ASSASSIN;
else if (strJob == "JOB_SURA")
dwPos = NRaceData::JOB_SURA;
else if (strJob == "JOB_SHAMAN")
dwPos = NRaceData::JOB_SHAMAN;
else
return;
dwPos += 1;
if (strSex == "F")
dwPos += 5;
m_aScaleInfo[dwPos].bSet = true;
m_aScaleInfo[dwPos].fScaleX = float(atof(strScaleX.c_str()) / 100.0f);
m_aScaleInfo[dwPos].fScaleY = float(atof(strScaleY.c_str()) / 100.0f);
m_aScaleInfo[dwPos].fScaleZ = float(atof(strScaleZ.c_str()) / 100.0f);
}
bool CItemData::GetItemScale(DWORD dwPos, float & fScaleX, float & fScaleY, float & fScaleZ)
{
if (dwPos >= sizeof(m_aScaleInfo) / sizeof(m_aScaleInfo[0]) || !m_aScaleInfo[dwPos].bSet)
return false;
fScaleX = m_aScaleInfo[dwPos].fScaleX;
fScaleY = m_aScaleInfo[dwPos].fScaleY;
fScaleZ = m_aScaleInfo[dwPos].fScaleZ;
return true;
}
const CItemData::TItemTable* CItemData::GetTable() const
{
return &m_ItemTable;
@@ -452,6 +489,7 @@ void CItemData::Clear()
m_pLODModelThingVector.clear();
memset(&m_ItemTable, 0, sizeof(m_ItemTable));
memset(m_aScaleInfo, 0, sizeof(m_aScaleInfo));
}
CItemData::CItemData()
+25
View File
@@ -56,6 +56,8 @@ class CItemData
ITEM_TYPE_SECONDARY_COIN, //32 명도전.
ITEM_TYPE_RING, //33 반지 (유니크 슬롯이 아닌 순수 반지 슬롯)
ITEM_TYPE_BELT, //34 벨트
ITEM_TYPE_SHINING, //35 PORT: AVA2 shining (not ported, keeps the AVA2 numbering)
ITEM_TYPE_RINGS, //36 PORT: AVA2 new rings, worn in c_Equipment_Rings1/2
ITEM_TYPE_MAX_NUM,
};
@@ -95,6 +97,7 @@ class CItemData
ARMOR_FOOTS,
ARMOR_NECK,
ARMOR_EAR,
ARMOR_PENDANT, // PORT: AVA2 talisman, worn in c_Equipment_Pendant
ARMOR_NUM_TYPES
};
@@ -102,6 +105,9 @@ class CItemData
{
COSTUME_BODY, //0 갑옷(main look)
COSTUME_HAIR, //1 헤어(탈착가능)
COSTUME_MOUNT = 2, // PORT: AVA2 mount costume (value0 = horse race)
COSTUME_SASH = 3, // PORT: AVA2 sash
COSTUME_MOUNT_SKIN = 4, // PORT: AVA2 mount skin (needs a worn COSTUME_MOUNT)
COSTUME_NUM_TYPES,
};
@@ -350,6 +356,16 @@ class CItemData
APPLY_ANTI_CRITICAL_PCT, //90 크리티컬 저항
APPLY_ANTI_PENETRATE_PCT, //91 관통타격 저항
// PORT AVA2 bonus numbering, matching the server (common/length.h): 92-122 and 129-138 are not ported yet.
APPLY_ENCHANT_ELEC = 123, // talisman: attack +x% on monsters of that element
APPLY_ENCHANT_FIRE = 124,
APPLY_ENCHANT_ICE = 125,
APPLY_ENCHANT_WIND = 126,
APPLY_ENCHANT_EARTH = 127,
APPLY_ENCHANT_DARK = 128,
APPLY_ATTBONUS_STONE = 139,
APPLY_ATTBONUS_BOSS = 140,
MAX_APPLY_NUM, //
};
@@ -491,6 +507,9 @@ class CItemData
//BOOL LoadItemData(const char * c_szFileName);
void SetDefaultItemData(const char * c_szIconFileName, const char * c_szModelFileName = NULL);
void SetItemTableData(TItemTable * pItemTable);
// PORT: AVA2 item_scale.txt (sash part scale per job/sex). Slot = job + 1, + 5 when female.
void SetItemScale(const std::string & strJob, const std::string & strSex, const std::string & strScaleX, const std::string & strScaleY, const std::string & strScaleZ);
bool GetItemScale(DWORD dwPos, float & fScaleX, float & fScaleY, float & fScaleZ);
protected:
void __LoadFiles();
@@ -514,6 +533,12 @@ class CItemData
NRaceData::TAttachingDataVector m_AttachingDataVector;
DWORD m_dwVnum;
TItemTable m_ItemTable;
struct SScaleInfo
{
bool bSet;
float fScaleX, fScaleY, fScaleZ;
};
SScaleInfo m_aScaleInfo[11];
public:
static void DestroySystem();
+36
View File
@@ -402,3 +402,39 @@ CItemManager::~CItemManager()
{
Destroy();
}
// PORT: AVA2 ItemManager.cpp LoadItemScale (ENABLE_SASH_SYSTEM). Each row covers vnum .. vnum + 4;
// unlike AVA2 (MakeItemData) only vnums already in the item table are touched.
bool CItemManager::LoadItemScale(const char * c_szFileName)
{
const VOID* pvData;
CMappedFile kFile;
if (!CEterPackManager::Instance().Get(kFile, c_szFileName, &pvData))
return false;
CMemoryTextFileLoader kTextFileLoader;
kTextFileLoader.Bind(kFile.Size(), pvData);
CTokenVector kTokenVector;
for (DWORD i = 0; i < kTextFileLoader.GetLineCount(); ++i)
{
if (!kTextFileLoader.SplitLineByTab(i, &kTokenVector))
continue;
if (kTokenVector.size() < ITEMSCALE_NUM)
{
TraceError("LoadItemScale: invalid line %d (%s).", i, c_szFileName);
continue;
}
for (int j = 0; j < 5; ++j)
{
TItemMap::iterator f = m_ItemMap.find(atoi(kTokenVector[ITEMSCALE_VNUM].c_str()) + j);
if (m_ItemMap.end() == f)
continue;
f->second->SetItemScale(kTokenVector[ITEMSCALE_JOB], kTokenVector[ITEMSCALE_SEX], kTokenVector[ITEMSCALE_SCALE_X], kTokenVector[ITEMSCALE_SCALE_Y], kTokenVector[ITEMSCALE_SCALE_Z]);
}
}
return true;
}
+15
View File
@@ -13,6 +13,20 @@ class CItemManager : public CSingleton<CItemManager>
ITEMDESC_COL_SUMM,
ITEMDESC_COL_NUM,
};
// PORT: AVA2 item_scale.txt columns
enum EItemScaleColumn
{
ITEMSCALE_VNUM,
ITEMSCALE_JOB,
ITEMSCALE_SEX,
ITEMSCALE_SCALE_X,
ITEMSCALE_SCALE_Y,
ITEMSCALE_SCALE_Z,
ITEMSCALE_POSITION_X,
ITEMSCALE_POSITION_Y,
ITEMSCALE_POSITION_Z,
ITEMSCALE_NUM,
};
public:
typedef std::map<DWORD, CItemData*> TItemMap;
@@ -31,6 +45,7 @@ class CItemManager : public CSingleton<CItemManager>
/////
bool LoadItemDesc(const char* c_szFileName);
bool LoadItemScale(const char* c_szFileName); // PORT: AVA2 sash
bool LoadItemList(const char* c_szFileName);
bool LoadItemTable(const char* c_szFileName);
CItemData * MakeItemData(DWORD dwIndex);
+1
View File
@@ -23,6 +23,7 @@ class CRaceData
PART_HEAD,
PART_WEAPON_LEFT,
PART_HAIR,
PART_SASH, // PORT: AVA2 sash ("Bip01 Spine2", item vnum - 85000)
PART_MAX_NUM,
};
+11
View File
@@ -22,6 +22,15 @@ bool __IsNPCRace(unsigned race)
return false;
}
// PORT: AVA2 __IsNewMountRace — mount costume / skin races (christmas_2017_sleddog, ...) live in npc_mount.
bool __IsNewMountRace(unsigned race)
{
if (race >= 32000 && race <= 32100)
return true;
return false;
}
void __GetRaceResourcePathes(unsigned race, std::vector <std::string>& vec_stPathes)
{
if (__IsGuildRace(race))
@@ -37,6 +46,8 @@ void __GetRaceResourcePathes(unsigned race, std::vector <std::string>& vec_stPat
if (race >= 30000)
{
vec_stPathes.push_back ("d:/ymir work/npc2/");
if (__IsNewMountRace(race)) // PORT: AVA2
vec_stPathes.push_back ("d:/ymir work/npc_mount/");
vec_stPathes.push_back ("d:/ymir work/npc/");
vec_stPathes.push_back ("d:/ymir work/monster/");
vec_stPathes.push_back ("d:/ymir work/monster2/");
+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;
};
+6
View File
@@ -104,7 +104,13 @@ enum EDragonSoulStepTypes
const DWORD c_Costume_Slot_Hair = c_Costume_Slot_Start + 1;
const DWORD c_Costume_Slot_Count = 2;
const DWORD c_Costume_Slot_End = c_Costume_Slot_Start + c_Costume_Slot_Count;
const DWORD c_Costume_Slot_Sash = c_Equipment_Start + 24; // PORT: AVA2 sash, server WEAR_COSTUME_SASH (after WEAR_BELT)
const DWORD c_Costume_Slot_Mount = c_Equipment_Start + 25; // PORT: AVA2 mount costume, server WEAR_COSTUME_MOUNT
const DWORD c_Costume_Slot_MountSkin = c_Equipment_Start + 26; // PORT: AVA2 mount skin, server WEAR_COSTUME_MOUNT_SKIN
#endif
const DWORD c_Equipment_Pendant = c_Equipment_Start + 27; // PORT: AVA2 talisman, server WEAR_PENDANT
const DWORD c_Equipment_Rings1 = c_Equipment_Start + 28; // PORT: AVA2 new rings, server WEAR_RINGS_1/2
const DWORD c_Equipment_Rings2 = c_Equipment_Start + 29;
// [주의] 숫자(32) 하드코딩 주의. 현재 서버에서 용혼석 슬롯은 32부터임.
+78
View File
@@ -756,6 +756,7 @@ bool CInstanceBase::Create(const SCreateData& c_rkCreateData)
{
SetHair(c_rkCreateData.m_dwHair);
SetWeapon(c_rkCreateData.m_dwWeapon);
SetSash(c_rkCreateData.m_dwSash); // PORT: AVA2 sash
}
__Create_SetName(c_rkCreateData);
@@ -2592,6 +2593,80 @@ void CInstanceBase::SetHair(DWORD eHair)
m_GraphicThingInstance.SetHair(eHair);
}
// PORT: AVA2 InstanceBase.cpp SetSash/ChangeSash/ClearSashEffect (ENABLE_SASH_SYSTEM + ENABLE_OBJ_SCALLING).
// Differences: part 0 detaches the model (AVA2 looked up item 85000 and returned early, leaving the old
// sash on), and only the item_scale.txt scale is applied (its position columns never reached the deform).
void CInstanceBase::SetSash(DWORD dwSash)
{
if (!IsPC() && GetRace() > 8)
return;
if (IsPC() && IsPoly())
return;
m_awPart[CRaceData::PART_SASH] = dwSash;
ClearSashEffect();
m_GraphicThingInstance.ClearPartScale(CRaceData::PART_SASH);
if (!dwSash)
{
m_GraphicThingInstance.AttachSash(NULL);
return;
}
dwSash += 85000;
float fSpecular = 65.0f;
if (dwSash > 86000)
{
fSpecular += 35;
m_dwSashEffect = EFFECT_REFINED + EFFECT_SASH;
__EffectContainer_AttachEffect(m_dwSashEffect);
}
fSpecular /= 100.0f;
CItemData * pItemData;
if (!CItemManager::Instance().GetItemDataPointer(dwSash, &pItemData))
{
m_GraphicThingInstance.AttachSash(NULL);
return;
}
m_GraphicThingInstance.AttachSash(pItemData, fSpecular);
DWORD dwRace = GetRace(), dwPos = RaceToJob(dwRace), dwSex = RaceToSex(dwRace);
dwPos += 1;
if (dwSex == 0)
dwPos += 5;
float fScaleX, fScaleY, fScaleZ;
if (pItemData->GetItemScale(dwPos, fScaleX, fScaleY, fScaleZ))
m_GraphicThingInstance.SetPartScale(CRaceData::PART_SASH, fScaleX, fScaleY, fScaleZ);
}
void CInstanceBase::ChangeSash(DWORD dwSash)
{
if (!IsPC() && GetRace() > 7)
return;
if (GetPart(CRaceData::PART_SASH) == dwSash)
return;
SetSash(dwSash);
}
void CInstanceBase::ClearSashEffect()
{
if (!m_dwSashEffect)
return;
__EffectContainer_DetachEffect(m_dwSashEffect);
m_dwSashEffect = 0;
}
void CInstanceBase::ChangeHair(DWORD eHair)
{
if (!HAIR_COLOR_ENABLE)
@@ -2815,6 +2890,7 @@ bool CInstanceBase::ChangeArmor(DWORD dwArmor)
DWORD dwVID = GetVirtualID();
DWORD dwRace = GetRace();
DWORD eHair = GetPart(CRaceData::PART_HAIR);
DWORD dwSash = GetPart(CRaceData::PART_SASH); // PORT: AVA2 sash
DWORD eWeapon = GetPart(CRaceData::PART_WEAPON);
float fRot = GetRotation();
float fAdvRot = GetAdvancingRotation();
@@ -2835,6 +2911,7 @@ bool CInstanceBase::ChangeArmor(DWORD dwArmor)
SetArmor(dwArmor);
SetHair(eHair);
SetSash(dwSash); // PORT: AVA2 sash
SetWeapon(eWeapon);
SetRotation(fRot);
@@ -3102,6 +3179,7 @@ void CInstanceBase::__Initialize()
m_swordRefineEffectRight = 0;
m_swordRefineEffectLeft = 0;
m_armorRefineEffect = 0;
m_dwSashEffect = 0; // PORT: AVA2 sash
m_sAlignment = 0;
m_byPKMode = 0;
+8
View File
@@ -26,6 +26,7 @@ class CInstanceBase
DWORD m_dwWeapon;
DWORD m_dwHair;
DWORD m_dwMountVnum;
DWORD m_dwSash; // PORT: AVA2 sash part (item vnum - 85000)
short m_sAlignment;
BYTE m_byPKMode;
@@ -293,6 +294,7 @@ class CInstanceBase
EFFECT_BODYARMOR_SPECIAL, // 갑옷 4-2-1
EFFECT_BODYARMOR_SPECIAL2, // 갑옷 4-2-2
EFFECT_SASH, // PORT: AVA2 refined sash glow (acc_01.mse)
EFFECT_REFINED_NUM,
};
@@ -370,6 +372,8 @@ class CInstanceBase
EFFECT_HALLOWEEN_CANDY_EQUIP, // 할로윈 사탕 착용 순간에 발동하는 이펙트
EFFECT_HAPPINESS_RING_EQUIP, // 행복의 반지 착용 순간에 발동하는 이펙트
EFFECT_LOVE_PENDANT_EQUIP, // 행복의 반지 착용 순간에 발동하는 이펙트
EFFECT_SASH_SUCCEDED, // PORT: AVA2 sash
EFFECT_SASH_EQUIP,
EFFECT_TEMP,
EFFECT_NUM,
};
@@ -500,6 +504,8 @@ class CInstanceBase
bool ChangeArmor(DWORD dwArmor);
void ChangeWeapon(DWORD eWeapon);
void ChangeHair(DWORD eHair);
void SetSash(DWORD dwSash); // PORT: AVA2 sash
void ChangeSash(DWORD dwSash);
void ChangeGuild(DWORD dwGuildID);
DWORD GetWeaponType();
@@ -909,6 +915,7 @@ class CInstanceBase
UINT __GetRefinedEffect(CItemData* pItem);
void __ClearWeaponRefineEffect();
void __ClearArmorRefineEffect();
void ClearSashEffect(); // PORT: AVA2 sash
protected:
void __AttachSelectEffect();
@@ -977,6 +984,7 @@ class CInstanceBase
DWORD m_swordRefineEffectRight;
DWORD m_swordRefineEffectLeft;
DWORD m_armorRefineEffect;
DWORD m_dwSashEffect; // PORT: AVA2 sash
struct SMoveAfterFunc
{
@@ -77,6 +77,7 @@ void SNetworkActorData::__copy__(const SNetworkActorData& src)
m_dwArmor = src.m_dwArmor;
m_dwWeapon = src.m_dwWeapon;
m_dwHair = src.m_dwHair;
m_dwSash = src.m_dwSash; // PORT: AVA2 sash
m_dwOwnerVID = src.m_dwOwnerVID;
@@ -104,6 +105,7 @@ SNetworkActorData::SNetworkActorData()
m_dwArmor=0;
m_dwWeapon=0;
m_dwHair=0;
m_dwSash=0; // PORT: AVA2 sash
m_dwEmpireID=0;
m_dwOwnerVID=0;
@@ -356,6 +358,7 @@ CInstanceBase* CNetworkActorManager::__AppendCharacterManagerActor(SNetworkActor
kCreateData.m_dwArmor=rkNetActorData.m_dwArmor;
kCreateData.m_dwWeapon=rkNetActorData.m_dwWeapon;
kCreateData.m_dwHair=rkNetActorData.m_dwHair;
kCreateData.m_dwSash=rkNetActorData.m_dwSash; // PORT: AVA2 sash
kCreateData.m_isMain=__IsMainActorVID(dwVID);
CInstanceBase* pOldInstance = rkChrMgr.GetInstancePtr(dwVID);
@@ -473,6 +476,7 @@ void CNetworkActorManager::UpdateActor(const SNetworkUpdateActorData& c_rkNetUpd
pkInstFind->ChangeArmor(c_rkNetUpdateActorData.m_dwArmor);
pkInstFind->ChangeWeapon(c_rkNetUpdateActorData.m_dwWeapon);
pkInstFind->ChangeHair(c_rkNetUpdateActorData.m_dwHair);
pkInstFind->ChangeSash(c_rkNetUpdateActorData.m_dwSash); // PORT: AVA2 sash
pkInstFind->ChangeGuild(c_rkNetUpdateActorData.m_dwGuildID);
pkInstFind->SetAffectFlagContainer(c_rkNetUpdateActorData.m_kAffectFlags);
pkInstFind->SetMoveSpeed(c_rkNetUpdateActorData.m_dwMovSpd);
@@ -502,6 +506,7 @@ void CNetworkActorManager::UpdateActor(const SNetworkUpdateActorData& c_rkNetUpd
rkNetActorData.m_dwArmor=c_rkNetUpdateActorData.m_dwArmor;
rkNetActorData.m_dwWeapon=c_rkNetUpdateActorData.m_dwWeapon;
rkNetActorData.m_dwHair=c_rkNetUpdateActorData.m_dwHair;
rkNetActorData.m_dwSash=c_rkNetUpdateActorData.m_dwSash; // PORT: AVA2 sash
rkNetActorData.m_sAlignment=c_rkNetUpdateActorData.m_sAlignment;
rkNetActorData.m_byPKMode=c_rkNetUpdateActorData.m_byPKMode;
}
@@ -31,6 +31,7 @@ struct SNetworkActorData
DWORD m_dwArmor;
DWORD m_dwWeapon;
DWORD m_dwHair;
DWORD m_dwSash; // PORT: AVA2 sash
DWORD m_dwOwnerVID;
@@ -85,6 +86,7 @@ struct SNetworkUpdateActorData
DWORD m_dwArmor;
DWORD m_dwWeapon;
DWORD m_dwHair;
DWORD m_dwSash; // PORT: AVA2 sash
DWORD m_dwMovSpd;
DWORD m_dwAtkSpd;
short m_sAlignment;
@@ -100,6 +102,7 @@ struct SNetworkUpdateActorData
m_dwArmor=0;
m_dwWeapon=0;
m_dwHair=0;
m_dwSash=0;
m_dwMovSpd=0;
m_dwAtkSpd=0;
m_sAlignment=0;
+53
View File
@@ -154,6 +154,7 @@ enum
HEADER_CG_DRAGON_SOUL_REFINE = 205,
HEADER_CG_STATE_CHECKER = 206,
HEADER_CG_SASH = 211, // PORT: AVA2 sash (metin2-server FEATURE-AVA2-UNLOCK P4)
#ifdef __AUCTION__
HEADER_CG_AUCTION_CMD = 205,
@@ -358,6 +359,7 @@ enum
HEADER_GC_SPECIFIC_EFFECT = 208,
HEADER_GC_DRAGON_SOUL_REFINE = 209,
HEADER_GC_RESPOND_CHANNELSTATUS = 210,
HEADER_GC_SASH = 215, // PORT: AVA2 sash
HEADER_GC_KEY_AGREEMENT_COMPLETED = 0xfa, // _IMPROVED_PACKET_ENCRYPTION_
HEADER_GC_KEY_AGREEMENT = 0xfb, // _IMPROVED_PACKET_ENCRYPTION_
@@ -1285,6 +1287,7 @@ enum ECharacterEquipmentPart
CHR_EQUIPPART_WEAPON,
CHR_EQUIPPART_HEAD,
CHR_EQUIPPART_HAIR,
CHR_EQUIPPART_SASH, // PORT: AVA2 sash, item vnum - 85000
CHR_EQUIPPART_NUM,
};
@@ -2401,6 +2404,8 @@ enum SPECIAL_EFFECT
SE_EQUIP_HALLOWEEN_CANDY, // 할로윈 사탕을 착용(-_-;)한 순간에 발동하는 이펙트
SE_EQUIP_HAPPINESS_RING, // 크리스마스 행복의 반지를 착용하는 순간에 발동하는 이펙트
SE_EQUIP_LOVE_PENDANT, // 발렌타인 사랑의 팬던트(71145) 착용할 때 이펙트 (발동이펙트임, 지속이펙트 아님)
SE_EFFECT_SASH_SUCCEDED, // PORT: AVA2 sash combine success
SE_EFFECT_SASH_EQUIP,
};
typedef struct SPacketGCSpecialEffect
@@ -2812,4 +2817,52 @@ typedef struct SChannelStatus
BYTE bStatus;
} TChannelStatus;
// PORT: AVA2 sash window. HEADER_CG_SASH and HEADER_GC_SASH share the layout; the server sends the
// combine (bWindow true) and absorb windows' state, messages come as "MTSASH <code>" server commands.
enum ESashInfo
{
SASH_ABSORPTION_SOCKET = 0,
SASH_ABSORBED_SOCKET = 1,
SASH_WINDOW_MAX_MATERIALS = 2,
};
enum
{
SASH_SUBHEADER_GC_OPEN = 0,
SASH_SUBHEADER_GC_CLOSE,
SASH_SUBHEADER_GC_ADDED,
SASH_SUBHEADER_GC_REMOVED,
SASH_SUBHEADER_CG_REFINED,
SASH_SUBHEADER_CG_CLOSE = 0,
SASH_SUBHEADER_CG_ADD,
SASH_SUBHEADER_CG_REMOVE,
SASH_SUBHEADER_CG_REFINE,
};
typedef struct SPacketSash
{
BYTE header;
BYTE subheader;
bool bWindow;
DWORD dwPrice;
BYTE bPos;
TItemPos tPos;
DWORD dwItemVnum;
DWORD dwMinAbs;
DWORD dwMaxAbs;
} TPacketSash;
typedef struct SSashMaterial
{
BYTE bHere;
WORD wCell;
} TSashMaterial;
typedef struct SSashResult
{
DWORD dwItemVnum;
DWORD dwMinAbs;
DWORD dwMaxAbs;
} TSashResult;
#pragma pack(pop)
@@ -840,5 +840,8 @@ void initchrmgr()
PyModule_AddIntConstant(poModule, "EFFECT_HALLOWEEN_CANDY_EQUIP", CInstanceBase::EFFECT_HALLOWEEN_CANDY_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_HAPPINESS_RING_EQUIP", CInstanceBase::EFFECT_HAPPINESS_RING_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_LOVE_PENDANT_EQUIP", CInstanceBase::EFFECT_LOVE_PENDANT_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_SASH_SUCCEDED", CInstanceBase::EFFECT_SASH_SUCCEDED); // PORT: AVA2 sash
PyModule_AddIntConstant(poModule, "EFFECT_SASH_EQUIP", CInstanceBase::EFFECT_SASH_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_SASH", CInstanceBase::EFFECT_SASH); // refined index, EFFECT_REFINED + EFFECT_SASH
}
@@ -84,6 +84,7 @@ PyObject * chrCreateInstance(PyObject* poSelf, PyObject* poArgs)
kCreateData.m_dwArmor=8;
kCreateData.m_dwWeapon=0;
kCreateData.m_dwHair=0;
kCreateData.m_dwSash=0; // PORT: AVA2 sash
kCreateData.m_isMain=false;
PyObject* poHorse=PyDict_GetItemString(poDict, "horse");
@@ -533,6 +534,21 @@ PyObject * chrRefresh(PyObject* poSelf, PyObject* poArgs)
return Py_BuildNone();
}
// PORT: AVA2 PythonCharacterModule.cpp chrSetSash (ENABLE_SASH_SYSTEM)
PyObject * chrSetSash(PyObject* poSelf, PyObject* poArgs)
{
int dwSash;
if (!PyTuple_GetInteger(poArgs, 0, &dwSash))
return Py_BuildException();
CInstanceBase * pkInst = CPythonCharacterManager::Instance().GetSelectedInstancePtr();
if (!pkInst)
return Py_BuildNone();
pkInst->SetSash(dwSash);
return Py_BuildNone();
}
PyObject * chrMountHorse(PyObject* poSelf, PyObject* poArgs)
{
CInstanceBase * pkInst = CPythonCharacterManager::Instance().GetSelectedInstancePtr();
@@ -1211,6 +1227,7 @@ PyObject * chrtestSetRideMan(PyObject* poSelf, PyObject* poArgs)
kCreateData.m_dwRace = 0;
kCreateData.m_dwArmor = 0;
kCreateData.m_dwHair = 100;
kCreateData.m_dwSash = 0; // PORT: AVA2 sash
kCreateData.m_dwMovSpd = 100;
kCreateData.m_dwAtkSpd = 100;
kCreateData.m_dwMountVnum = imount;
@@ -1227,6 +1244,7 @@ void initchr()
{
{ "DismountHorse", chrDismountHorse, METH_VARARGS },
{ "MountHorse", chrMountHorse, METH_VARARGS },
{ "SetSash", chrSetSash, METH_VARARGS }, // PORT: AVA2 sash
{ "Destroy", chrDestroy, METH_VARARGS },
{ "Update", chrUpdate, METH_VARARGS },
@@ -1438,6 +1456,7 @@ void initchr()
PyModule_AddIntConstant(poModule, "PART_WEAPON", CRaceData::PART_WEAPON);
PyModule_AddIntConstant(poModule, "PART_HEAD", CRaceData::PART_HEAD);
PyModule_AddIntConstant(poModule, "PART_WEAPON_LEFT", CRaceData::PART_WEAPON_LEFT);
PyModule_AddIntConstant(poModule, "PART_SASH", CRaceData::PART_SASH); // PORT: AVA2 sash
/////
@@ -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);
@@ -639,6 +640,12 @@ void initItem()
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_BODY", c_Costume_Slot_Body);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_HAIR", c_Costume_Slot_Hair);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_END", c_Costume_Slot_End);
PyModule_AddIntConstant(poModule, "COSTUME_TYPE_SASH", CItemData::COSTUME_SASH); // PORT: AVA2 sash
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_SASH", c_Costume_Slot_Sash);
PyModule_AddIntConstant(poModule, "COSTUME_TYPE_MOUNT", CItemData::COSTUME_MOUNT); // PORT: AVA2 mount costume
PyModule_AddIntConstant(poModule, "COSTUME_TYPE_MOUNT_SKIN", CItemData::COSTUME_MOUNT_SKIN);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_MOUNT", c_Costume_Slot_Mount);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_MOUNT_SKIN", c_Costume_Slot_MountSkin);
#endif
#ifdef ENABLE_NEW_EQUIPMENT_SYSTEM
@@ -753,6 +760,11 @@ void initItem()
PyModule_AddIntConstant(poModule, "ARMOR_FOOTS", CItemData::ARMOR_FOOTS);
PyModule_AddIntConstant(poModule, "ARMOR_NECK", CItemData::ARMOR_NECK);
PyModule_AddIntConstant(poModule, "ARMOR_EAR", CItemData::ARMOR_EAR);
PyModule_AddIntConstant(poModule, "ARMOR_PENDANT", CItemData::ARMOR_PENDANT); // PORT: AVA2 talisman
PyModule_AddIntConstant(poModule, "EQUIPMENT_PENDANT", c_Equipment_Pendant);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_RINGS", CItemData::ITEM_TYPE_RINGS); // PORT: AVA2 new rings
PyModule_AddIntConstant(poModule, "EQUIPMENT_RINGS1", c_Equipment_Rings1);
PyModule_AddIntConstant(poModule, "EQUIPMENT_RINGS2", c_Equipment_Rings2);
PyModule_AddIntConstant(poModule, "ITEM_APPLY_MAX_NUM", CItemData::ITEM_APPLY_MAX_NUM);
PyModule_AddIntConstant(poModule, "ITEM_SOCKET_MAX_NUM", CItemData::ITEM_SOCKET_MAX_NUM);
@@ -851,4 +863,12 @@ void initItem()
PyModule_AddIntConstant(poModule, "APPLY_RESIST_DARK", CItemData::APPLY_RESIST_DARK );
PyModule_AddIntConstant(poModule, "APPLY_ANTI_CRITICAL_PCT", CItemData::APPLY_ANTI_CRITICAL_PCT );
PyModule_AddIntConstant(poModule, "APPLY_ANTI_PENETRATE_PCT", CItemData::APPLY_ANTI_PENETRATE_PCT );
PyModule_AddIntConstant(poModule, "APPLY_ATTBONUS_STONE", CItemData::APPLY_ATTBONUS_STONE );
PyModule_AddIntConstant(poModule, "APPLY_ATTBONUS_BOSS", CItemData::APPLY_ATTBONUS_BOSS );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_ELEC", CItemData::APPLY_ENCHANT_ELEC );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_FIRE", CItemData::APPLY_ENCHANT_FIRE );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_ICE", CItemData::APPLY_ENCHANT_ICE );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_WIND", CItemData::APPLY_ENCHANT_WIND );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_EARTH", CItemData::APPLY_ENCHANT_EARTH );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_DARK", CItemData::APPLY_ENCHANT_DARK );
}
@@ -184,6 +184,7 @@ class CMainPacketHeaderMap : public CNetworkPacketHeaderMap
Set(HEADER_GC_HYBRIDCRYPT_SDB, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketGCHybridSDB), DYNAMIC_SIZE_PACKET));
Set(HEADER_GC_SPECIFIC_EFFECT, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketGCSpecificEffect), STATIC_SIZE_PACKET));
Set(HEADER_GC_DRAGON_SOUL_REFINE, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketGCDragonSoulRefine), STATIC_SIZE_PACKET));
Set(HEADER_GC_SASH, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketSash), STATIC_SIZE_PACKET)); // PORT: AVA2 sash
}
};
@@ -267,6 +267,12 @@ class CPythonNetworkStream : public CNetworkStream, public CSingleton<CPythonNet
// 용홍석 강화
bool SendDragonSoulRefinePacket(BYTE bRefineType, TItemPos* pos);
// PORT: AVA2 sash
bool SendSashClosePacket();
bool SendSashAddPacket(TItemPos tPos, BYTE bPos);
bool SendSashRemovePacket(BYTE bPos);
bool SendSashRefinePacket();
// Handshake
bool RecvHandshakePacket();
bool RecvHandshakeOKPacket();
@@ -545,6 +551,7 @@ class CPythonNetworkStream : public CNetworkStream, public CSingleton<CPythonNet
// 용혼석 관련
bool RecvDragonSoulRefine();
bool RecvSashPacket(bool bReturn = false); // PORT: AVA2 sash
// MiniMap Info
bool RecvNPCList();
@@ -630,6 +630,10 @@ void CPythonNetworkStream::GamePhase()
ret = RecvDragonSoulRefine();
break;
case HEADER_GC_SASH: // PORT: AVA2 sash
ret = RecvSashPacket();
break;
default:
ret = RecvDefaultPacket(header);
break;
@@ -113,6 +113,7 @@ bool CPythonNetworkStream::RecvCharacterAppendPacket()
kNetActorData.m_dwArmor=0;/*chrAddPacket.awPart[CHR_EQUIPPART_ARMOR]*/;
kNetActorData.m_dwWeapon=0;/*chrAddPacket.awPart[CHR_EQUIPPART_WEAPON]*/;
kNetActorData.m_dwHair=0;/*chrAddPacket.awPart[CHR_EQUIPPART_HAIR]*/;
kNetActorData.m_dwSash=0; // PORT: AVA2 sash
kNetActorData.m_dwMountVnum=0;/*chrAddPacket.dwMountVnum*/;
kNetActorData.m_dwLevel = 0; // 몬스터 레벨 표시 안함
@@ -160,6 +161,7 @@ bool CPythonNetworkStream::RecvCharacterAdditionalInfo()
kNetActorData.m_dwArmor=chrInfoPacket.awPart[CHR_EQUIPPART_ARMOR];
kNetActorData.m_dwWeapon=chrInfoPacket.awPart[CHR_EQUIPPART_WEAPON];
kNetActorData.m_dwHair=chrInfoPacket.awPart[CHR_EQUIPPART_HAIR];
kNetActorData.m_dwSash=chrInfoPacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetActorData.m_dwMountVnum=chrInfoPacket.dwMountVnum;
__RecvCharacterAppendPacket(&kNetActorData);
@@ -193,6 +195,7 @@ bool CPythonNetworkStream::RecvCharacterAppendPacketNew()
kNetActorData.m_dwArmor=chrAddPacket.awPart[CHR_EQUIPPART_ARMOR];
kNetActorData.m_dwWeapon=chrAddPacket.awPart[CHR_EQUIPPART_WEAPON];
kNetActorData.m_dwHair=chrAddPacket.awPart[CHR_EQUIPPART_HAIR];
kNetActorData.m_dwSash=chrAddPacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetActorData.m_dwStateFlags=chrAddPacket.bStateFlag;
kNetActorData.m_dwVID=chrAddPacket.dwVID;
kNetActorData.m_dwMountVnum=chrAddPacket.dwMountVnum;
@@ -221,6 +224,7 @@ bool CPythonNetworkStream::RecvCharacterUpdatePacket()
kNetUpdateActorData.m_dwArmor=chrUpdatePacket.awPart[CHR_EQUIPPART_ARMOR];
kNetUpdateActorData.m_dwWeapon=chrUpdatePacket.awPart[CHR_EQUIPPART_WEAPON];
kNetUpdateActorData.m_dwHair=chrUpdatePacket.awPart[CHR_EQUIPPART_HAIR];
kNetUpdateActorData.m_dwSash=chrUpdatePacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetUpdateActorData.m_dwVID=chrUpdatePacket.dwVID;
kNetUpdateActorData.m_kAffectFlags.CopyData(0, sizeof(chrUpdatePacket.dwAffectFlag[0]), &chrUpdatePacket.dwAffectFlag[0]);
kNetUpdateActorData.m_kAffectFlags.CopyData(32, sizeof(chrUpdatePacket.dwAffectFlag[1]), &chrUpdatePacket.dwAffectFlag[1]);
@@ -246,6 +250,7 @@ bool CPythonNetworkStream::RecvCharacterUpdatePacketNew()
kNetUpdateActorData.m_dwArmor=chrUpdatePacket.awPart[CHR_EQUIPPART_ARMOR];
kNetUpdateActorData.m_dwWeapon=chrUpdatePacket.awPart[CHR_EQUIPPART_WEAPON];
kNetUpdateActorData.m_dwHair=chrUpdatePacket.awPart[CHR_EQUIPPART_HAIR];
kNetUpdateActorData.m_dwSash=chrUpdatePacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetUpdateActorData.m_dwVID=chrUpdatePacket.dwVID;
kNetUpdateActorData.m_kAffectFlags.CopyData(0, sizeof(chrUpdatePacket.dwAffectFlag[0]), &chrUpdatePacket.dwAffectFlag[0]);
kNetUpdateActorData.m_kAffectFlags.CopyData(32, sizeof(chrUpdatePacket.dwAffectFlag[1]), &chrUpdatePacket.dwAffectFlag[1]);
@@ -858,6 +858,13 @@ bool CPythonNetworkStream::RecvSpecialEffect()
case SE_EQUIP_LOVE_PENDANT:
effect = CInstanceBase::EFFECT_LOVE_PENDANT_EQUIP;
break;
// PORT: AVA2 sash
case SE_EFFECT_SASH_SUCCEDED:
effect = CInstanceBase::EFFECT_SASH_SUCCEDED;
break;
case SE_EFFECT_SASH_EQUIP:
effect = CInstanceBase::EFFECT_SASH_EQUIP;
break;
default:
@@ -0,0 +1,159 @@
#include "StdAfx.h"
#include "PythonNetworkStream.h"
#include "PythonSash.h"
#include "Packet.h"
// PORT: AVA2 PythonNetworkStreamPhaseGame.cpp sash packets (ENABLE_SASH_SYSTEM) verbatim.
bool CPythonNetworkStream::RecvSashPacket(bool bReturn)
{
TPacketSash sPacket;
if (!Recv(sizeof(sPacket), &sPacket))
return bReturn;
bReturn = true;
switch (sPacket.subheader)
{
case SASH_SUBHEADER_GC_OPEN:
{
CPythonSash::Instance().Clear();
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 1, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_GC_CLOSE:
{
CPythonSash::Instance().Clear();
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 2, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_GC_ADDED:
{
CPythonSash::Instance().AddMaterial(sPacket.dwPrice, sPacket.bPos, sPacket.tPos);
if (sPacket.bPos == 1)
{
CPythonSash::Instance().AddResult(sPacket.dwItemVnum, sPacket.dwMinAbs, sPacket.dwMaxAbs);
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "AlertSash", Py_BuildValue("(b)", sPacket.bWindow));
}
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 3, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_GC_REMOVED:
{
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, sPacket.bPos);
if (sPacket.bPos == 0)
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 1);
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 4, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_CG_REFINED:
{
if (sPacket.dwMaxAbs == 0)
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 1);
else
{
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 0);
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 1);
}
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 4, sPacket.bWindow));
}
break;
default:
TraceError("CPythonNetworkStream::RecvSashPacket: unknown subheader %d\n.", sPacket.subheader);
break;
}
return bReturn;
}
bool CPythonNetworkStream::SendSashClosePacket()
{
if (!__CanActMainInstance())
return true;
TItemPos tPos;
tPos.window_type = INVENTORY;
tPos.cell = 0;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_CLOSE;
sPacket.dwPrice = 0;
sPacket.bPos = 0;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
bool CPythonNetworkStream::SendSashAddPacket(TItemPos tPos, BYTE bPos)
{
if (!__CanActMainInstance())
return true;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_ADD;
sPacket.dwPrice = 0;
sPacket.bPos = bPos;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
bool CPythonNetworkStream::SendSashRemovePacket(BYTE bPos)
{
if (!__CanActMainInstance())
return true;
TItemPos tPos;
tPos.window_type = INVENTORY;
tPos.cell = 0;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_REMOVE;
sPacket.dwPrice = 0;
sPacket.bPos = bPos;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
bool CPythonNetworkStream::SendSashRefinePacket()
{
if (!__CanActMainInstance())
return true;
TItemPos tPos;
tPos.window_type = INVENTORY;
tPos.cell = 0;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_REFINE;
sPacket.dwPrice = 0;
sPacket.bPos = 0;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
+28
View File
@@ -422,6 +422,34 @@ void CPythonPlayer::__UpdateBattleStatus()
m_playerStatus.SetPoint(POINT_MAX_MAGIC_WEP, m_dwWeaponMaxMagicPower+m_dwWeaponAddPower);
m_playerStatus.SetPoint(POINT_MIN_ATK, __GetTotalAtk(m_dwWeaponMinPower, m_dwWeaponAddPower));
m_playerStatus.SetPoint(POINT_MAX_ATK, __GetTotalAtk(m_dwWeaponMaxPower, m_dwWeaponAddPower));
// PORT: AVA2 PythonPlayer.cpp (ENABLE_SASH_SYSTEM): an equipped sash that absorbed a weapon adds
// (value3|4 + value5) and magic (value1|2 + value5) x absorption% to the shown attack, as the
// server adds it in Item_GetDamage / SetPolyVarForAttack.
const TItemData * pSashData = GetItemData(TItemPos(INVENTORY, c_Costume_Slot_Sash));
if (pSashData)
{
long lAbsorbedVnum = pSashData->alSockets[SASH_ABSORBED_SOCKET];
CItemData * pAbsorbed;
if (lAbsorbedVnum != 0 && CItemManager::Instance().GetItemDataPointer(lAbsorbedVnum, &pAbsorbed))
{
if (pAbsorbed->GetType() == CItemData::ITEM_TYPE_WEAPON)
{
double dAbs = static_cast<double>(pSashData->alSockets[SASH_ABSORPTION_SOCKET]) / 100.0;
long lMinWep = static_cast<long>((pAbsorbed->GetValue(3) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
long lMaxWep = static_cast<long>((pAbsorbed->GetValue(4) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
long lMinMag = static_cast<long>((pAbsorbed->GetValue(1) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
long lMaxMag = static_cast<long>((pAbsorbed->GetValue(2) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
m_playerStatus.SetPoint(POINT_MIN_WEP, m_playerStatus.GetPoint(POINT_MIN_WEP) + lMinWep);
m_playerStatus.SetPoint(POINT_MAX_WEP, m_playerStatus.GetPoint(POINT_MAX_WEP) + lMaxWep);
m_playerStatus.SetPoint(POINT_MIN_MAGIC_WEP, m_playerStatus.GetPoint(POINT_MIN_MAGIC_WEP) + lMinMag);
m_playerStatus.SetPoint(POINT_MAX_MAGIC_WEP, m_playerStatus.GetPoint(POINT_MAX_MAGIC_WEP) + lMaxMag);
m_playerStatus.SetPoint(POINT_MIN_ATK, m_playerStatus.GetPoint(POINT_MIN_ATK) + lMinWep);
m_playerStatus.SetPoint(POINT_MAX_ATK, m_playerStatus.GetPoint(POINT_MAX_ATK) + lMaxWep);
}
}
}
}
void CPythonPlayer::SetStatus(DWORD dwType, long lValue)
@@ -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);
}
+189
View File
@@ -0,0 +1,189 @@
#include "StdAfx.h"
// PORT: AVA2 PythonSash.cpp (ENABLE_SASH_SYSTEM) verbatim; the sash module is driven by mt_sash.
#include "PythonSash.h"
#include "PythonNetworkStream.h"
CPythonSash::CPythonSash()
{
Clear();
}
CPythonSash::~CPythonSash()
{
Clear();
}
void CPythonSash::Clear()
{
dwPrice = 0;
ZeroMemory(&m_vSashResult, sizeof(m_vSashResult));
m_vSashMaterials.clear();
m_vSashMaterials.resize(SASH_WINDOW_MAX_MATERIALS);
for (BYTE bPos = 0; bPos < m_vSashMaterials.size(); ++bPos)
{
TSashMaterial tMaterial;
tMaterial.bHere = 0;
tMaterial.wCell = 0;
m_vSashMaterials[bPos] = tMaterial;
}
}
void CPythonSash::AddMaterial(DWORD dwRefPrice, BYTE bPos, TItemPos tPos)
{
if (bPos >= SASH_WINDOW_MAX_MATERIALS)
return;
if (bPos == 0)
dwPrice = dwRefPrice;
TSashMaterial tMaterial;
tMaterial.bHere = 1;
tMaterial.wCell = tPos.cell;
m_vSashMaterials[bPos] = tMaterial;
}
void CPythonSash::AddResult(DWORD dwItemVnum, DWORD dwMinAbs, DWORD dwMaxAbs)
{
TSashResult tResult;
tResult.dwItemVnum = dwItemVnum;
tResult.dwMinAbs = dwMinAbs;
tResult.dwMaxAbs = dwMaxAbs;
m_vSashResult = tResult;
}
void CPythonSash::RemoveMaterial(DWORD dwRefPrice, BYTE bPos)
{
if (bPos >= SASH_WINDOW_MAX_MATERIALS)
return;
if (bPos == 1)
dwPrice = dwRefPrice;
TSashMaterial tMaterial;
tMaterial.bHere = 0;
tMaterial.wCell = 0;
m_vSashMaterials[bPos] = tMaterial;
}
bool CPythonSash::GetAttachedItem(BYTE bPos, BYTE & bHere, WORD & wCell)
{
if (bPos >= SASH_WINDOW_MAX_MATERIALS)
return false;
bHere = m_vSashMaterials[bPos].bHere;
wCell = m_vSashMaterials[bPos].wCell;
return true;
}
void CPythonSash::GetResultItem(DWORD & dwItemVnum, DWORD & dwMinAbs, DWORD & dwMaxAbs)
{
dwItemVnum = m_vSashResult.dwItemVnum;
dwMinAbs = m_vSashResult.dwMinAbs;
dwMaxAbs = m_vSashResult.dwMaxAbs;
}
PyObject * SendSashCloseRequest(PyObject * poSelf, PyObject * poArgs)
{
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashClosePacket();
return Py_BuildNone();
}
PyObject * SendSashAdd(PyObject * poSelf, PyObject * poArgs)
{
BYTE bPos;
TItemPos tPos;
if (!PyTuple_GetInteger(poArgs, 0, &tPos.window_type))
return Py_BuildException();
else if (!PyTuple_GetInteger(poArgs, 1, &tPos.cell))
return Py_BuildException();
else if (!PyTuple_GetInteger(poArgs, 2, &bPos))
return Py_BuildException();
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashAddPacket(tPos, bPos);
return Py_BuildNone();
}
PyObject * SendSashRemove(PyObject * poSelf, PyObject * poArgs)
{
BYTE bPos;
if (!PyTuple_GetInteger(poArgs, 0, &bPos))
return Py_BuildException();
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashRemovePacket(bPos);
return Py_BuildNone();
}
PyObject * GetSashPrice(PyObject * poSelf, PyObject * poArgs)
{
return Py_BuildValue("i", CPythonSash::Instance().GetPrice());
}
PyObject * GetSashAttachedItem(PyObject * poSelf, PyObject * poArgs)
{
BYTE bPos;
if (!PyTuple_GetInteger(poArgs, 0, &bPos))
return Py_BuildException();
BYTE bHere;
WORD wCell;
bool bGet = CPythonSash::Instance().GetAttachedItem(bPos, bHere, wCell);
if (!bGet)
{
bHere = 0;
wCell = 0;
}
return Py_BuildValue("ii", bHere, wCell);
}
PyObject * GetSashResultItem(PyObject * poSelf, PyObject * poArgs)
{
DWORD dwItemVnum, dwMinAbs, dwMaxAbs;
CPythonSash::Instance().GetResultItem(dwItemVnum, dwMinAbs, dwMaxAbs);
return Py_BuildValue("iii", dwItemVnum, dwMinAbs, dwMaxAbs);
}
PyObject * SendSashRefineRequest(PyObject * poSelf, PyObject * poArgs)
{
BYTE bHere;
WORD wCell;
bool bGet = CPythonSash::Instance().GetAttachedItem(1, bHere, wCell);
if (bGet)
{
if (bHere)
{
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashRefinePacket();
}
}
return Py_BuildNone();
}
void initSash()
{
static PyMethodDef functions[] = {
{"SendCloseRequest", SendSashCloseRequest, METH_VARARGS},
{"Add", SendSashAdd, METH_VARARGS},
{"Remove", SendSashRemove, METH_VARARGS},
{"GetPrice", GetSashPrice, METH_VARARGS},
{"GetAttachedItem", GetSashAttachedItem, METH_VARARGS},
{"GetResultItem", GetSashResultItem, METH_VARARGS},
{"SendRefineRequest", SendSashRefineRequest, METH_VARARGS},
{NULL, NULL, NULL},
};
PyObject* pModule = Py_InitModule("sash", functions);
PyModule_AddIntConstant(pModule, "ABSORPTION_SOCKET", SASH_ABSORPTION_SOCKET);
PyModule_AddIntConstant(pModule, "ABSORBED_SOCKET", SASH_ABSORBED_SOCKET);
PyModule_AddIntConstant(pModule, "WINDOW_MAX_MATERIALS", SASH_WINDOW_MAX_MATERIALS);
PyModule_AddIntConstant(pModule, "CLEAN_ATTR_VALUE_FIELD", 0);
PyModule_AddIntConstant(pModule, "LIMIT_RANGE", 1000);
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// PORT: AVA2 sash window state (ENABLE_SASH_SYSTEM), fed by HEADER_GC_SASH.
#include "Packet.h"
class CPythonSash : public CSingleton<CPythonSash>
{
public:
DWORD dwPrice;
typedef std::vector<TSashMaterial> TSashMaterials;
public:
CPythonSash();
virtual ~CPythonSash();
void Clear();
void AddMaterial(DWORD dwRefPrice, BYTE bPos, TItemPos tPos);
void AddResult(DWORD dwItemVnum, DWORD dwMinAbs, DWORD dwMaxAbs);
void RemoveMaterial(DWORD dwRefPrice, BYTE bPos);
DWORD GetPrice() {return dwPrice;}
bool GetAttachedItem(BYTE bPos, BYTE & bHere, WORD & wCell);
void GetResultItem(DWORD & dwItemVnum, DWORD & dwMinAbs, DWORD & dwMaxAbs);
protected:
TSashResult m_vSashResult;
TSashMaterials m_vSashMaterials;
};
+2
View File
@@ -68,8 +68,10 @@ void initskill();
void initBackground();
void initMessenger();
void initsafebox();
void initSash(); // PORT: AVA2 sash
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);
+4 -4
View File
@@ -43,7 +43,7 @@ inline constexpr int ID_MAX_NUM = 30; // Packet.h -> login[31]
inline constexpr int PASS_MAX_NUM = 16; // -> passwd[17]
inline constexpr int PRIVATE_CODE_LENGTH = 8;
inline constexpr int POINT_MAX_NUM = 255; // common/length.h:58
inline constexpr int CHR_EQUIPPART_NUM = 4; // ARMOR/WEAPON/HEAD/HAIR
inline constexpr int CHR_EQUIPPART_NUM = 5; // ARMOR/WEAPON/HEAD/HAIR + AVA2 SASH (metin2-server P4)
inline constexpr int LOGIN_STATUS_MAX_LEN = 8; // -> szStatus[9]
inline constexpr int KEY_AGREEMENT_MAX_DATA_LEN = 256;
inline constexpr int ITEM_SOCKET_MAX_NUM = 3; // common/item_length.h
@@ -645,7 +645,7 @@ struct GCCharacterAdd2 { // packet_add_char2 — name + parts + guild
uint32_t mount_vnum;
};
static_assert(sizeof(GCCharacterAdd2) ==
1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 4 + 12 + 1 + 2 + 8 + 1 + 1 + 1 + 8 + 1 + 4 + 2 + 1 + 4); // 120
1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 4 + 12 + 1 + 2 + 2 * CHR_EQUIPPART_NUM + 1 + 1 + 1 + 8 + 1 + 4 + 2 + 1 + 4); // 122
struct GCCharacterDel { uint8_t header; uint32_t vid; };
static_assert(sizeof(GCCharacterDel) == 5);
@@ -873,7 +873,7 @@ struct GCCharacterUpdate { // packet_update_char (header 19)
uint8_t pk_mode;
uint32_t mount_vnum;
};
static_assert(sizeof(GCCharacterUpdate) == 1 + 4 + 8 + 1 + 1 + 1 + 8 + 4 + 2 + 1 + 4); // 35
static_assert(sizeof(GCCharacterUpdate) == 1 + 4 + 2 * CHR_EQUIPPART_NUM + 1 + 1 + 1 + 8 + 4 + 2 + 1 + 4); // 37
struct GCStun { uint8_t header; uint32_t vid; }; // header 13
struct GCDead { uint8_t header; uint32_t vid; }; // header 14
@@ -1036,7 +1036,7 @@ struct GCCharAddInfo {
uint8_t pk_mode;
uint32_t mount_vnum;
};
static_assert(sizeof(GCCharAddInfo) == 1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 8 + 1 + 4 + 4 + 2 + 1 + 4); // 54
static_assert(sizeof(GCCharAddInfo) == 1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 2 * CHR_EQUIPPART_NUM + 1 + 4 + 4 + 2 + 1 + 4); // 56
// --- GC skills / quickslots ---
struct PlayerSkill3 { // TPlayerSkill (common/tables.h) — time_t is 32-bit on the -m32 server
+80
View File
@@ -0,0 +1,80 @@
// PORT: adds or replaces whole item_proto records (all 156-byte fields); untouched records stay byte-identical.
// Row: vnum type sub size anti flags wear immune buy sell refined refine_set magic_pct
// limit0 value0 limit1 value1 apply0 value0 apply1 value1 apply2 value2 v0..v5 specular gain_socket \t name
#include "GameLib/StdAfx.h"
#include "GameLib/ItemData.h"
#include "EterBase/lzo.h"
#include <array>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <map>
#include <sstream>
#include <stdexcept>
#include <vector>
using Item=CItemData::TItemTable;
static constexpr std::array<DWORD,4> item_key{{173217,72619434,408587239,27973291}};
static std::vector<BYTE> read_file(const char* path){
std::ifstream stream(path,std::ios::binary);
if(!stream)throw std::runtime_error("cannot read proto");
return {std::istreambuf_iterator<char>(stream),std::istreambuf_iterator<char>()};
}
static DWORD word(const std::vector<BYTE>& data,size_t offset){
if(offset+4>data.size())throw std::runtime_error("truncated proto");
DWORD value;std::memcpy(&value,data.data()+offset,4);return value;
}
static void put_word(std::vector<BYTE>& data,size_t offset,DWORD value){std::memcpy(data.data()+offset,&value,4);}
static void dump(const Item& r){
std::printf("%u %u %u %u %u %u %u %u %u %u %u %u %u",r.dwVnum,r.bType,r.bSubType,r.bSize,r.dwAntiFlags,r.dwFlags,r.dwWearFlags,r.dwImmuneFlag,
r.dwIBuyItemPrice,r.dwISellItemPrice,r.dwRefinedVnum,r.wRefineSet,r.bAlterToMagicItemPct);
for(const auto& limit:r.aLimits)std::printf(" %u %d",limit.bType,int(limit.lValue));
for(const auto& apply:r.aApplies)std::printf(" %u %d",apply.bType,int(apply.lValue));
for(auto value:r.alValues)std::printf(" %d",int(value));
std::printf(" %u %u\t%.*s\n",r.bSpecular,r.bGainSocketPct,int(strnlen(r.szLocaleName,sizeof(r.szLocaleName))),r.szLocaleName);
}
int main(int argc,char** argv) {
const bool dumping=argc==3&&!std::strcmp(argv[1],"--dump");
if(argc!=4&&!dumping){std::fprintf(stderr,"usage: item_proto_patch in_proto rows.tsv out_proto | --dump proto\n");return 2;}
if(dumping)argv[1]=argv[2];
try {
static CLZO lzo; auto key=item_key;
auto input=read_file(argv[1]);
if(input.size()<20 || std::memcmp(input.data(),"MIPX",4) || word(input,4)!=1 || word(input,8)!=sizeof(Item))throw std::runtime_error("item proto stride");
CLZObject decoded;
if(!CLZO::Instance().Decompress(decoded,input.data()+20,key.data()))throw std::runtime_error("item proto decode");
if(decoded.GetSize()!=size_t(word(input,12))*sizeof(Item))throw std::runtime_error("proto count/size mismatch");
std::map<DWORD,Item> rows;
for(DWORD i=0;i<word(input,12);++i){Item r;std::memcpy(&r,decoded.GetBuffer()+i*sizeof(Item),sizeof(Item));rows[r.dwVnum]=r;}
if(dumping){for(const auto& pair:rows)dump(pair.second);return 0;}
std::ifstream file(argv[2],std::ios::binary);std::string line;size_t added=0,replaced=0;
while(std::getline(file,line)) {
if(line.empty())continue;
auto tab=line.find('\t',0);std::string numbers=line,name;
// the name is the last tab-separated column
auto last=line.rfind('\t');if(last==std::string::npos)throw std::runtime_error("item row without name");
numbers=line.substr(0,last);name=line.substr(last+1);(void)tab;
std::istringstream stream(numbers);std::array<long long,31> f{};
for(auto& value:f)if(!(stream>>value))throw std::runtime_error("item row: "+line.substr(0,40));
long long extra;if(stream>>extra)throw std::runtime_error("item row has extra columns");
if(name.size()>CItemData::ITEM_NAME_MAX_LEN)throw std::runtime_error("item name too long");
Item r{};int i=0;
r.dwVnum=f[i++];r.bType=f[i++];r.bSubType=f[i++];r.bSize=f[i++];r.dwAntiFlags=f[i++];r.dwFlags=f[i++];
r.dwWearFlags=f[i++];r.dwImmuneFlag=f[i++];r.dwIBuyItemPrice=f[i++];r.dwISellItemPrice=f[i++];
r.dwRefinedVnum=f[i++];r.wRefineSet=f[i++];r.bAlterToMagicItemPct=f[i++];
for(auto& limit:r.aLimits){limit.bType=f[i++];limit.lValue=f[i++];}
for(auto& apply:r.aApplies){apply.bType=f[i++];apply.lValue=f[i++];}
for(auto& value:r.alValues)value=f[i++];
r.bSpecular=f[i++];r.bGainSocketPct=f[i++];
std::memcpy(r.szName,name.data(),name.size());std::memcpy(r.szLocaleName,name.data(),name.size());
(rows.count(r.dwVnum)?replaced:added)++;rows[r.dwVnum]=r;
}
std::vector<BYTE> plain;for(const auto& pair:rows){const BYTE* bytes=reinterpret_cast<const BYTE*>(&pair.second);plain.insert(plain.end(),bytes,bytes+sizeof(Item));}
CLZObject encoded;if(!CLZO::Instance().CompressEncryptedMemory(encoded,plain.data(),plain.size(),key.data()))throw std::runtime_error("encode");
std::vector<BYTE> result(input.begin(),input.begin()+20);put_word(result,12,rows.size());put_word(result,16,encoded.GetSize());
result.insert(result.end(),encoded.GetBuffer(),encoded.GetBuffer()+encoded.GetSize());
CLZObject verified;if(!CLZO::Instance().Decompress(verified,result.data()+20,key.data()) || verified.GetSize()!=plain.size() || std::memcmp(verified.GetBuffer(),plain.data(),plain.size()))throw std::runtime_error("round-trip");
std::ofstream out(argv[3],std::ios::binary|std::ios::trunc);
if(!out.write(reinterpret_cast<const char*>(result.data()),result.size()))throw std::runtime_error("cannot write proto");
std::printf("items added=%zu replaced=%zu total=%zu; verified round-trip\n",added,replaced,rows.size());return 0;
}catch(const std::exception& e){std::fprintf(stderr,"%s\n",e.what());return 1;}
}
+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;
+156
View File
@@ -0,0 +1,156 @@
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()
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
NAMES={70043:'夺宝手套',71153:'经验药水',27992:'白珍珠',27987:'珍珠蚌'}
INFO=['MTBP1 season 202610 1790805600 1793487600 1790900000 6',
'MTBP1 mission 1 1 0 20000 150 0 70043 1 71153 5',
'MTBP1 mission 2 2 0 30 30 0 25041 2',
'MTBP1 mission 3 3 0 300 300 1 50513 3',
'MTBP1 mission 4 4 0 172800 5400 0 71094 2',
'MTBP1 mission 5 5 27987 100 3 0 27987 5',
'MTBP1 mission 9 9 27992 10 0 0',
'MTBP1 final 0 0 27992 2 27993 1',
'MTBP1 end']
class BattlePassTests(unittest.TestCase):
def setUp(self):
self.chat=[];self.sent=[];self.clock=100.
names=('app','chat','game','grp','item','net','nonplayer','ui','mt_biolog')
mods={n:types.ModuleType(n) for n in names}
app=mods['app'];app.GetTime=lambda:self.clock;app.DIK_F6=64
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,BINARY_ServerCommand_Run=lambda s,l:'old'))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=grp.RenderLine=lambda *a:None
self.selected=0
def select(v):self.selected=v
mods['item'].SelectItem=select;mods['item'].GetItemName=lambda:NAMES.get(self.selected,'')
mods['net'].SendChatPacket=self.sent.append
mods['nonplayer'].GetMonsterName=lambda v:{691:'狼王'}.get(v,'')
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)
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_battlepass'};exec(script('BattlePassScript.inc'),self.ns)
self.game=mods['game'];self.gw=self.game.GameWindow()
self.gw.onPressKeyDict={};self.gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
self.game.GameWindow.Open(self.gw)
self.w=self.ns['g_window'];self.p=self.ns['g_pass']
def text(self,t):
w=Widget();w.text=t;w.shown=True;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 feed(self,*lines):
for line in lines:self.assertEqual(self.game.GameWindow.BINARY_ServerCommand_Run(self.gw,line),1)
def open(self):
self.gw.onPressKeyDict[64]();self.assertTrue(self.w.IsShow());self.assertEqual(self.sent[-1],'/mt_bp 1 info')
self.feed(*INFO)
def test_other_commands_pass_through(self):
self.assertEqual(self.game.GameWindow.BINARY_ServerCommand_Run(self.gw,'MTVIP1 times 0 0 0 0 0 0 0'),'old')
self.assertIn(64,self.gw.onPressKeyDict)
def test_info_and_rows(self):
self.open();rows=self.w.rows
self.assertEqual(sorted(self.p.missions),[1,2,3,4,5,9]);self.assertEqual(self.w.Pages(),2)
self.assertEqual(rows[0]['name'].text,'击杀怪物');self.assertEqual(rows[0]['value'].text,'150 / 20,000')
self.assertEqual(rows[0]['reward'].text,'奖励:夺宝手套 x1,经验药水 x5')
self.assertFalse(rows[0]['button'].enabled);self.assertEqual(rows[0]['button'].text,'未完成')
self.assertTrue(rows[1]['button'].enabled);self.assertEqual(rows[1]['button'].text,'领取')
self.assertEqual(rows[2]['button'].text,'已领取');self.assertFalse(rows[2]['button'].enabled)
self.assertEqual(rows[3]['value'].text,'1.5 / 48 小时')
self.assertEqual(rows[4]['name'].text,'钓到 珍珠蚌')
self.assertIn('2026 年 10 月',self.w.season.text);self.assertIn('剩余 29 天 22 小时',self.w.season.text)
self.w.next.event();self.assertEqual(self.w.page,1)
self.assertEqual(rows[0]['name'].text,'向 NPC 出售 白珍珠');self.assertEqual(rows[0]['reward'].text,'无奖励')
self.assertFalse(rows[0]['button'].enabled);self.assertFalse(rows[1]['name'].shown)
self.assertFalse(self.w.finalButton.enabled);self.assertEqual(self.w.finalState.text,'完成全部任务后可领取')
def test_progress_done_and_claim(self):
self.open()
self.feed('MTBP1 progress 1 151');self.assertEqual(self.w.rows[0]['value'].text,'151 / 20,000')
self.feed('MTBP1 done 1');self.assertEqual(self.chat[-1],'战令任务完成:击杀怪物,按 F6 领取奖励')
self.assertTrue(self.w.rows[0]['button'].enabled)
self.clock+=1;self.w.rows[0]['button'].event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 1')
self.assertEqual(self.w.rows[0]['button'].text,'领取中');self.assertFalse(self.w.rows[1]['button'].enabled)
self.w.rows[1]['button'].event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 1') # one claim at a time
self.feed('MTBP1 claimed 1');self.assertEqual(self.chat[-1],'战令奖励已放入背包')
self.assertEqual(self.w.rows[0]['button'].text,'已领取')
self.feed(*INFO) # the server sends the full state after a claim
self.clock+=1;self.w.rows[1]['button'].event();self.feed('MTBP1 error space')
self.assertEqual(self.chat[-1],'背包空间不足,请整理后再领取');self.assertIn('背包空间不足',self.w.note.text)
self.assertTrue(self.w.rows[1]['button'].enabled)
def test_final(self):
self.open()
for slot in (1,4,5,9):self.feed('MTBP1 done %d'%slot)
self.assertTrue(self.p.AllDone());self.w.Refresh()
self.assertTrue(self.w.finalButton.enabled);self.assertEqual(self.w.finalState.text,'全部任务完成')
self.clock+=1;self.w.finalButton.event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 0')
self.feed('MTBP1 claimed 0');self.assertEqual(self.chat[-1],'战令最终奖励已放入背包')
self.assertEqual(self.w.finalButton.text,'已领取');self.assertFalse(self.w.finalButton.enabled)
def test_close_none_and_timeout(self):
self.open();self.w.OnPressEscapeKey();self.assertEqual(self.sent[-1],'/mt_bp 1 close');self.assertFalse(self.w.IsShow())
self.feed('MTBP1 done 2');self.assertEqual(self.chat[-1],'战令任务完成:击杀 Boss,按 F6 领取奖励')
self.clock+=1;self.w.Open();self.feed('MTBP1 none')
self.assertIsNone(self.p.season);self.assertEqual(self.w.note.text,'当前没有进行中的战令赛季')
self.assertFalse(self.w.rows[0]['name'].shown);self.assertFalse(self.w.finalButton.shown)
self.w.Hide();self.assertEqual(self.sent[-1],'/mt_bp 1 info') # no season, nothing to close
self.clock+=1;self.w.Open();self.clock+=3.5;self.w.OnUpdate()
self.assertTrue(self.p.unsupported);self.assertEqual(self.w.note.text,'!服务器尚未启用战令')
def test_bad_lines_are_dropped(self):
self.feed('MTBP1 season 1 2 3 4 0','MTBP1 end');self.assertIsNone(self.p.season)
self.feed(INFO[0],INFO[1],'MTBP1 end');self.assertIsNone(self.p.season) # count mismatch
self.feed(INFO[0],'MTBP1 mission 1 1 0 x 0 0',*INFO[2:]);self.assertIsNone(self.p.season)
self.feed('MTBP1 mission 1 1 0 5 0 0','MTBP1 end');self.assertIsNone(self.p.season) # no season line
self.feed('MTBP1 progress 99 1','MTBP1 error nope','MTBP1 bogus')
self.feed(*INFO);self.assertEqual(self.p.season[0],202610)
self.assertEqual(self.ns['Grouped'](50000000),'50,000,000');self.assertEqual(self.ns['Amount'](4,45,90),'0.8')
self.assertEqual(self.ns['Unit'](4,90),' 分钟');self.assertEqual(self.ns['Amount'](4,7200,172800),'2');self.assertEqual(self.ns['SeasonName'](7),'第 7 期')
def test_registered(self):
boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text()
self.assertIn('#include "BattlePassScript.inc"',boot)
self.assertLess(boot.index('install_script_module("mt_preview"'),boot.index('install_script_module("mt_battlepass"'))
self.game.GameWindow.Close(self.gw);self.assertIsNone(self.ns['g_window'])
if __name__=='__main__':unittest.main()
+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()
+1 -1
View File
@@ -41,7 +41,7 @@ class SwitchbotTests(unittest.TestCase):
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
it=mods['item'];it.ITEM_TYPE_WEAPON=1;it.ITEM_TYPE_ARMOR=2;it.ITEM_TYPE_USE=3;self.selected=None
it=mods['item'];it.ITEM_TYPE_WEAPON=1;it.ITEM_TYPE_ARMOR=2;it.ITEM_TYPE_USE=3;it.ARMOR_PENDANT=7;self.selected=None
it.SelectItem=lambda v:setattr(self,'selected',v);it.GetItemType=lambda:protos[self.selected][0];it.GetItemSubType=lambda:protos[self.selected][1]
it.GetItemName=lambda:'N%d'%self.selected
pl=mods['player'];pl.INVENTORY=1;pl.INVENTORY_PAGE_SIZE=45;pl.INVENTORY_PAGE_COUNT=5
+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()
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Install items-server.tsv (from make_items.py) into share/conf/item_proto.txt + item_names.txt.
Rows with the same vnum are replaced, so a rerun is idempotent. The db reads the tables only at boot:
restart db and every core afterwards. mobs-server.tsv (optional 4th arg) goes into mob_proto.txt + mob_names.txt
the same way. Usage: install_items.py <server root> <items-server.tsv> <fresh backup dir> [<mobs-server.tsv>]
"""
import shutil,sys
from pathlib import Path
server,rows,backup=map(Path,sys.argv[1:4])
mobrows=Path(sys.argv[4]) if len(sys.argv)>4 else None
if backup.exists():raise SystemExit('use a fresh backup directory')
load=lambda p:{int(l.split(b'\t',1)[0]):l for l in p.read_bytes().splitlines() if l}
backup.mkdir(parents=True)
conf=server/'share/conf'
def install(proto,names,rows):
for filename,row in ((proto,lambda v:rows[v]),(names,lambda v:str(v).encode()+b'\t'+rows[v].split(b'\t')[1])):
path=conf/filename;raw=path.read_bytes();shutil.copy2(path,backup/filename)
newline=b'\r\n' if b'\r\n' in raw else b'\n'
kept=[l for l in raw.replace(b'\r\n',b'\n').split(b'\n') if l and not (l.split(b'\t',1)[0].isdigit() and int(l.split(b'\t',1)[0]) in rows)]
tmp=path.with_name(filename+'.tmp');tmp.write_bytes(newline.join(kept+[row(v) for v in sorted(rows)])+newline);tmp.replace(path)
items=load(rows);install('item_proto.txt','item_names.txt',items)
print('AVA2 items installed:',len(items),'backup:',backup)
if mobrows:
mobs=load(mobrows);install('mob_proto.txt','mob_names.txt',mobs)
print('AVA2 mobs installed:',len(mobs))
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Stage the AVA2 drop items that the 203 server lacks as ordinary 40250 items (server rows + client patch pack).
Only types/subtypes/flags the 40250 server understands (plus the ported AVA2 systems: sash, mount, costume
enchants, talismans, new rings) survive; items of the AVA2-only systems not ported yet (pets, weapon/sash-skin/effect
costume enchants, new skill books) become plain ITEM_NONE items. Also writes mobs-server.tsv (MOUNT_MOBS) for install_items.py. Usage: see build_zh_locale.build_ava2_items and --help.
"""
import argparse,json,re,shutil,subprocess
from pathlib import Path
HERE=Path(__file__).resolve().parent
RANGES=((54001,54027,'时装强化石'),(54028,54078,'发型强化石'),(54079,54123,'武器外观强化石'),
(54124,54174,'坐骑外观强化石'),(54175,54219,'披风外观强化石'),(54220,54267,'特效强化石'))
# Dragon armor: +0..+9 per class (the refine chain needs every step).
DRAGON={21210:'暗龙铠甲',21230:'风龙铠甲',21250:'魔龙铠甲',21270:'神龙铠甲'}
# Talismans (P7, server port_pendant.cpp): +0..+200 per element, refine sets 440-499 (../pendant/refine.sql).
PENDANT={9600:'火之护符',9830:'冰之护符',10060:'土之护符',10290:'暗之护符',10520:'风之护符',10750:'雷之护符'}
PENDANT_STEPS=201
# New rings (P8, server port_rings.cpp): +0..+9 per family, 203's refine sets 46-54.
RINGS={19500:'红宝石戒指',19520:'石榴石戒指',19540:'祖母绿戒指',19560:'蓝宝石戒指'}
# Talisman attribute items: 71302 / 53996 reroll, 71301 / 53997 add (53996 / 53997 are generic on AVA2).
PENDANT_SCROLLS={53996,53997,71301,71302}
# Models of the armor shapes the 40250 race msm lack (registered by the client's mt_shapes script): shape ->
# model stem ('%s' = race); RACE_PATHS = the ShapeData PathName of each race msm.
ARMOR_SHAPES={32:'%s_7_1'}
RACE_PATHS={'warrior':('pc','pc2'),'assassin':('pc2','pc'),'sura':('pc','pc2'),'shaman':('pc2','pc')} # (male, female)
# Kept with their 40250 meaning; everything else becomes ITEM_NONE (see module doc).
KEEP_TYPES={'ITEM_MATERIAL','ITEM_QUEST','ITEM_GIFTBOX','ITEM_NONE','ITEM_TREASURE_BOX','ITEM_TREASURE_KEY','ITEM_NEW_RINGS'}
KEEP_SUBTYPES={('ITEM_WEAPON',s) for s in ('WEAPON_SWORD','WEAPON_DAGGER','WEAPON_BOW','WEAPON_TWO_HANDED','WEAPON_BELL','WEAPON_FAN')}|{('ITEM_ARMOR','ARMOR_FOOTS'),('ITEM_ARMOR','ARMOR_BODY'),('ITEM_ARMOR','ARMOR_PENDANT'),('ITEM_METIN','METIN_NORMAL')}|{('ITEM_COSTUME',s) for s in ('COSTUME_SASH','COSTUME_MOUNT','COSTUME_MOUNT_SKIN')}
DESC={'ITEM_METIN':'可镶嵌到武器的空孔中。','ITEM_MATERIAL':'制作与升级材料。','ITEM_QUEST':'任务物品。','ITEM_GIFTBOX':'打开后可获得物品。','ITEM_TREASURE_BOX':'需要对应的钥匙才能打开。','ITEM_TREASURE_KEY':'可以打开对应的宝箱。','ITEM_WEAPON':'','ITEM_ARMOR':'','ITEM_COSTUME':'披风。可在披风工匠处合成升级,或吸收武器、铠甲的部分属性。',
'ITEM_NEW_RINGS':'戒指。装备在两个戒指栏之一。可用钻头开孔(最多 3 孔)并镶嵌对应的宝石,可用转换道具重洗属性、用追加道具增加属性。'}
# Sash system (metin2-server docs/FEATURE-AVA2-UNLOCK.md P4): effects registered by the client's mt_sash script
# (files they reference are staged too), the window images, and item_scale.txt (model scale per job/sex; a row
# covers vnum..vnum+4).
SASH_EFFECTS=('d:/ymir work/effect/etc/buff/buff_item6.mse','d:/ymir work/effect/etc/buff/buff_item7.mse',
'd:/ymir work/pc/common/effect/armor/acc_01.mse')
SASH_LOCALE={'locale/en/ui/acce/acce_combine.tga':'locale/zh/ui/acce/acce_combine.tga',
'locale/en/ui/acce/acce_absorb.tga':'locale/zh/ui/acce/acce_absorb.tga'}
# Per-subtype descriptions (override DESC).
# Costume enchants (P6, server port_costume_attr.cpp): only the body / hair / mount-skin ranges have a 40250
# target; AVA2 apply numbers in value0 equal 203's (P2 aligned 139/140). The rest stay locked.
ENCHANT_KEEP=((54001,54078),(54124,54174))
DESC_SUB={('ITEM_USE','USE_CHANGE_ATTRIBUTE'):'拖到未穿戴的对应时装上,附加一条固定属性(每件时装最多 3 条,不能重复)。',
('ITEM_COSTUME','COSTUME_MOUNT'):'坐骑时装。装备后召唤该坐骑(需要已拥有马匹),装备期间属性生效。',
('ITEM_COSTUME','COSTUME_MOUNT_SKIN'):'坐骑外观。需先装备坐骑时装,装备后改变坐骑外观。',
('ITEM_ARMOR','ARMOR_PENDANT'):'护符。装备在护符栏,对相应属性的怪物伤害提高(强化等级 +1 = +1%)。可用护符强化道具重洗属性,用护符加持道具追加属性(最多 5 条)。'}
# Per-vnum descriptions (override DESC_SUB).
DESC_VNUM={53996:'拖到未穿戴的护符上,重新随机全部属性。',71302:'拖到未穿戴的护符上,重新随机全部属性。',
53997:'拖到未穿戴的护符上,追加一条属性(最多 5 条)。',71301:'拖到未穿戴的护符上,追加一条属性(最多 5 条)。'}
# Mount system (P5): races of the mount skins that 203 and the 40250 client lack -> (zh name, npc_mount folder).
# Their AVA2 mob_proto rows go to mobs-server.tsv, the whole model folder into the pack; the client's mt_mount
# script registers the race names (npclist.txt is read from the root pack only).
MOUNT_MOBS={32012:('蓝色巴里','christmas_2017_sleddog'),32028:('白色冬日羊驼','christmas_2020_alpaca')}
LOCKED='该物品所属的系统尚未开放,暂时无法使用。'
def enums(proto_reader):
src=proto_reader.read_text(encoding='latin1')
def arr(name):
m=re.search(r'(?:static\s+)?string\s+'+name+r'\[\]\s*=\s*\{(.*?)\};',src,re.S)
return re.findall(r'"([^"]+)"',m.group(1))
types=arr('arType');m=re.search(r'arSubType\[\]\s*=\s*\{(.*?)\};',src,re.S)
subs=[arr(t) if t!='0' else [] for t in re.findall(r'\b(0|arSub\d+)\b',re.sub(r'//[^\n]*','',m.group(1)))]
assert len(subs)==len(types),(len(subs),len(types))
return dict(type=types,sub=subs,anti=arr('arAntiFlag'),flag=arr('arFlag'),wear=arr('arWearrFlag'),
imm=arr('arImmune'),limit=arr('arLimitType'),apply=arr('arApplyType'))
def tokens(text):return [t.strip() for t in text.split('|') if t.strip() and t.strip() not in ('NONE','0')]
def number(text):
try:return int(text)
except ValueError:return 0
def names():
result={}
for line in (HERE/'names_zh.tsv').read_text().splitlines():
if line and not line.startswith('#'):
v,n=line.split('\t');result[int(v)]=n
for lo,hi,n in RANGES:
for v in range(lo,hi+1):result[v]=n
for base,n in DRAGON.items():
for k in range(10):result[base+k]='%s+%d'%(n,k)
for base,n in PENDANT.items():
for k in range(PENDANT_STEPS):result[base+k]='%s+%d'%(n,k)
for base,n in RINGS.items():
for k in range(10):result[base+k]='%s+%d'%(n,k)
return result
def convert(cols,E,imported):
"""AVA2 item_proto.txt row (str columns) -> (server row columns, client numeric fields, kept?)."""
c=[x.strip() for x in cols[:33]]
t,st=c[2],c[3]
kept=t in KEEP_TYPES or (t,st) in KEEP_SUBTYPES or ((t,st)==('ITEM_USE','USE_CHANGE_ATTRIBUTE') and
any(lo<=int(c[0])<=hi for lo,hi in ENCHANT_KEEP)) or (
(t,st) in (('ITEM_USE','USE_CHANGE_ATTRIBUTE'),('ITEM_USE','USE_ADD_ATTRIBUTE')) and int(c[0]) in PENDANT_SCROLLS)
anti=tokens(c[5]);flags=tokens(c[6])
if 'ITEM_STACKABLE' in anti:anti.remove('ITEM_STACKABLE');flags.append('ITEM_STACKABLE')
anti=[a for a in dict.fromkeys(anti) if a in E['anti']];flags=[f for f in dict.fromkeys(flags) if f in E['flag']]
if not kept:
t,st='ITEM_NONE','0';wear=imm=[];applies=[('APPLY_NONE','0')]*3;values=['0']*6
flags=[f for f in flags if f in ('ITEM_STACKABLE','LOG')]
else:
if t in ('ITEM_QUEST','ITEM_GIFTBOX','ITEM_NONE','ITEM_MATERIAL') and st not in E['sub'][E['type'].index(t)]:st='0'
wear=[w for w in tokens(c[7]) if w in E['wear']] if t in ('ITEM_WEAPON','ITEM_ARMOR','ITEM_METIN') else []
imm=[i for i in tokens(c[8]) if i in E['imm']]
applies=[(a,v) if a in E['apply'] else ('APPLY_NONE','0') for a,v in zip(c[18:24:2],c[19:24:2])]
values=[str(number(v)) for v in c[24:30]]
refine=(c[11],c[12]) if number(c[11]) in imported else ('0','0')
limits=[(l if l in E['limit'] else 'LIMIT_NONE',str(number(v))) for l,v in zip(c[14:18:2],c[15:18:2])]
j=lambda xs:' | '.join(xs) if xs else 'NONE'
server=[c[0],c[1],t,st,c[4],j(anti),j(flags),j(wear),j(imm),c[9],c[10],refine[0],refine[1],c[13]]
for l,v in limits:server+=[l,v]
for a,v in applies:server+=[a,str(number(v))]
server+=values+[str(number(c[30])),str(number(c[31])),str(number(c[32])) if len(c)>32 and c[32] else '0']
bits=lambda xs,k:sum(1<<E[k].index(x) for x in xs)
ti=E['type'].index(t);si=E['sub'][ti].index(st) if not st.isdigit() else int(st)
client=[int(c[0]),ti,si,number(c[4]),bits(anti,'anti'),bits(flags,'flag'),bits(wear,'wear'),bits(imm,'imm'),
number(c[9]),number(c[10]),number(refine[0]),number(refine[1]),number(c[13])]
for l,v in limits:client+=[E['limit'].index(l),int(v)]
for a,v in applies:client+=[E['apply'].index(a),number(v)]
client+=[int(v) for v in values]+[number(c[30]),number(c[31])]
return server,client,kept
def shape_models():
"""Pack paths of the ARMOR_SHAPES models (+ LODs) and skins of every race."""
out=[]
for race,dirs in RACE_PATHS.items():
for d in dict.fromkeys(dirs):
for stem in ARMOR_SHAPES.values():
out+=['d:/ymir work/%s/%s/%s%s'%(d,race,stem%race,e) for e in ('.gr2','.dds','_lod_01.gr2','_lod_02.gr2','_lod_03.gr2')]
return out
def effect_files(files):
"""SASH_EFFECTS and every file (mde/textures/models) they name, recursively; names are absolute
(d:/ymir work/...) or relative to the referring file."""
out=[];todo=list(SASH_EFFECTS)
while todo:
q=todo.pop()
if q in out:continue
if q not in files:raise SystemExit('missing AVA2 effect file %s'%q)
out.append(q)
if q.endswith(('.mse','.mde')):
for name in re.findall(rb'[\w \-:./\\]+?\.(?:dds|tga|jpg|mde|gr2|spt|msf)',files[q].read_bytes(),re.I):
name=name.decode('latin1').strip().strip('"').lower().replace('\\','/')
todo.append(name if name.startswith('d:/') else q.rsplit('/',1)[0]+'/'+name.split('/')[-1])
return out
def pack_index(pack_maker):
"""ArchivedPath (lowercase) -> source file, from the AVA2 pack manifests."""
index={}
for xml in sorted(pack_maker.glob('*_create.xml')):
for archived,source in re.findall(r'ArchivedPath="([^"]+)"><!\[CDATA\[([^\]]+)\]\]>',xml.read_text(encoding='latin1')):
index.setdefault(archived.lower().replace('\\','/'),pack_maker.parent/source)
return index
def prepare(ava,proto_reader,locale,output,tool,packer,wrap=lambda text:text):
"""wrap: the locale's itemdesc line wrapping (build_zh_locale.wrap_description)."""
E=enums(proto_reader);zh=names();wanted=set(zh)
db=ava/'2. Backup SF+SRC and SQL/home/srv1/db';pack_maker=ava/'1. Client DEV/pack_maker'
rows={}
for line in (db/'item_proto.txt').read_bytes().splitlines():
cols=line.split(b'\t')
if cols[0].strip().isdigit() and int(cols[0]) in wanted:rows[int(cols[0])]=[x.decode('latin1') for x in cols]
assert rows.keys()==wanted,sorted(wanted-rows.keys())
listrows={}
for line in (pack_maker/'locale/locale/es/item_list.txt').read_bytes().splitlines():
c=[x.strip() for x in line.split(b'\t')]
if c and c[0].isdigit() and int(c[0]) in wanted:listrows[int(c[0])]=[x.decode('latin1') for x in c if x]
files=pack_index(pack_maker)
for v in wanted-listrows.keys(): # 16 items have no AVA2 item_list row; their icons ship under the vnum
listrows[v]=[str(v),'ETC','icon/item/%05d.tga'%v]
output.mkdir(parents=True,exist_ok=True)
assets=output/'assets'
if assets.exists():shutil.rmtree(assets)
server=[];client=[];report={'kept':[],'locked':[]}
for v in sorted(wanted):
s,n,kept=convert(rows[v],E,wanted)
name=zh[v];s[1]=name
server.append('\t'.join(s).encode('gbk'))
client.append(('\t'.join(map(str,n))+'\t'+name).encode('gbk'))
report['kept' if kept else 'locked'].append(v)
copied=0
for v,row in listrows.items():
for path in row[2:4]:
p=path.lower().replace('\\','/')
sources=[p]
if p.endswith('.gr2'):sources+=[s for s in MODEL_TEXTURES.get(p.rsplit('/',1)[1],())]
for q in sources:
if q not in files:raise SystemExit('missing AVA2 asset %s for %d'%(q,v))
target=assets/q;target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():shutil.copy2(files[q],target);copied+=1
mount_files=[q for q in files for _,d in MOUNT_MOBS.values() if q.startswith('d:/ymir work/npc_mount/%s/'%d)]
if len({q.split('/')[3] for q in mount_files})!=len(MOUNT_MOBS):raise SystemExit('missing AVA2 mount models')
for q in shape_models()+effect_files(files)+mount_files:
if q not in files:raise SystemExit('missing AVA2 asset %s'%q)
target=assets/q;target.parent.mkdir(parents=True,exist_ok=True)
if not target.exists():shutil.copy2(files[q],target);copied+=1
(output/'items-server.tsv').write_bytes(b'\n'.join(server)+b'\n')
(output/'items-client.tsv').write_bytes(b'\n'.join(client)+b'\n')
mobs={}
for line in (db/'mob_proto.txt').read_bytes().splitlines():
cols=line.split(b'\t')
if cols[0].strip().isdigit() and int(cols[0]) in MOUNT_MOBS:
v=int(cols[0]);cols[1]=MOUNT_MOBS[v][0].encode('gbk');mobs[v]=b'\t'.join(cols)
assert mobs.keys()==MOUNT_MOBS.keys(),sorted(MOUNT_MOBS.keys()-mobs.keys())
(output/'mobs-server.tsv').write_bytes(b'\n'.join(mobs[v] for v in sorted(mobs))+b'\n')
target=assets/'locale/zh';target.mkdir(parents=True,exist_ok=True)
subprocess.run([str(tool),str(locale/'item_proto'),str(output/'items-client.tsv'),str(target/'item_proto')],check=True)
for source,dest in SASH_LOCALE.items():
(assets/dest).parent.mkdir(parents=True,exist_ok=True);shutil.copy2(files[source],assets/dest);copied+=1
scale=[l for l in (pack_maker/'locale/locale/en/item_scale.txt').read_bytes().splitlines()
if l[:5].isdigit() and wanted & set(range(int(l[:5]),int(l[:5])+5))]
(target/'item_scale.txt').write_bytes(b'\n'.join(scale)+b'\n')
for filename in ('item_list.txt','itemdesc.txt'):
result=[l for l in (locale/filename).read_bytes().splitlines() if not (l.split(b'\t',1)[0].isdigit() and int(l.split(b'\t',1)[0]) in wanted)]
for v in sorted(wanted):
if filename=='item_list.txt':result.append('\t'.join(listrows[v]).encode('gbk'))
else:
t=rows[v][2].strip()
desc=DESC_VNUM.get(v) or DESC_SUB.get((t,rows[v][3].strip()),DESC.get(t,'')) if v in report['kept'] else LOCKED
result.append(('%d\t%s\t%s'%(v,zh[v],wrap(desc) if desc else '')).encode('gbk'))
(target/filename).write_bytes(b'\n'.join(result)+b'\n')
subprocess.run([str(packer),str(output/'ava2items'),str(assets),''],check=True)
report['assets']=copied
(output/'items.json').write_text(json.dumps(report,indent=1)+'\n')
return report
# Textures named inside the (BitKnit-compressed) AVA2 models, read with sdk/gr2.
MODEL_TEXTURES={**{'%05d.gr2'%v:('d:/ymir work/item/weapon/weapon_7th_01.dds',) for v in (320,340,1190,2210,3230,5170,7310)},
'boss_box.gr2':('d:/ymir work/item/etc/boss_box.dds',),
'acce_01_85_004.gr2':('d:/ymir work/item/wing/acce_85_001-008.dds',),'acce_02_85_008.gr2':('d:/ymir work/item/wing/acce_85_001-008.dds',),
'acce_03_85_014.gr2':('d:/ymir work/item/wing/acce_85_011-018.dds',),'acce_04_85_018.gr2':('d:/ymir work/item/wing/acce_85_011-018.dds',)}
if __name__=='__main__':
p=argparse.ArgumentParser(description=__doc__)
for a in ('ava','proto_reader','locale','output','tool','packer'):p.add_argument(a,type=Path)
a=p.parse_args();print(json.dumps({k:len(v) if isinstance(v,list) else v for k,v in prepare(a.ava,a.proto_reader,a.locale,a.output,a.tool.resolve(),a.packer.resolve()).items()}))
+208
View File
@@ -0,0 +1,208 @@
# AVA2 drop items imported as 40250 items (vnum<TAB>zh name). 54001-54267 are named by range in make_items.py.
320 守护者之剑+0
340 守护者之刃+0
1190 守护者匕首+0
2210 守护者之弓+0
3230 守护者大刀+0
5170 守护者之铃+0
6884 稀有度转换器
7310 守护者之扇+0
9600 火之护符+0
9830 冰之护符+0
10060 土之护符+0
10290 暗之护符+0
10520 风之护符+0
10750 雷之护符+0
15440 炎晶之鞋+0
15450 海洋之鞋+0
15460 锆石之鞋+0
15470 孔雀石之鞋+0
19500 红宝石戒指+0
19520 石榴石戒指+0
19540 祖母绿戒指+0
19560 蓝宝石戒指+0
25163 缩小的头颅
25164 蜘蛛钥匙
25165 螺旋钥匙
25166 缩小的头颅+
25167 拉扎多尔入场券
25168 奈梅尔入场券
25169 九头蛇护身符
28444 破碑之石+4
28445 克首领之石+4
30400 盖亚宝石
30600 图样
30601 铠甲设计图
30602 蓝色染料
30603 红色染料
30604 灰色染料
30605 金板
30606 银板
30607 铜板
30608 钢板
30609 青铜板
30610 秘银
30611 坚韧绑带
30612 结实绳索
30614 黑色染料
30615 白色染料
30616 二氧化钛
30617 玛瑙
30618 月长石
30620 红龙之角
30621 红龙鳞片
30623 智慧之书
30624 魔法宝珠
30625 帕德玛刚玉
30626 蓝晶石
30627 神之果实
30628 神圣之花
30629 众神灵药
30630 龙之翼
33031 吊坠宝石
39070 灰色碎片
39071 白色碎片
39072 绿色碎片
39073 黄色碎片
50275 金色欧凯宝箱
50276 银色欧凯宝箱
50277 铜色欧凯宝箱
50569 秘籍·地震
50570 秘籍·星光
50571 秘籍·风暴射击
50572 秘籍·地狱打击
50573 秘籍·致命波动
50574 秘籍·流星
50575 秘籍·灵气护盾
50928 金王战利品
50929 银王战利品
50930 铜王战利品
51110 阿拉多尔宝箱
51111 奈梅尔宝箱
51112 贝兰塞陶宝箱
51115 远古守护者宝箱
51116 红龙宝箱
51117 远古巨龙宝箱
53004 鲁弗斯封印(永久)
53996 护符附魔道具
53997 护符加持道具
54705 九头蛇宝箱
54706 65级武器宝箱
54707 70级武器宝箱
54708 75级武器宝箱
54709 防具宝箱
54710 装备宝箱
54711 饰品宝箱
54712 生物学家礼包
54713 生物学家袋子
54714 生物学家袋子+
54715 强化药剂包(小)
54716 强化药剂包(中)
54717 强化药剂包(特大)
55001 宠物蛋白补充剂
55004 宠物秘籍·幼年
55005 宠物秘籍·野性
55006 宠物秘籍·勇敢
55009 宠物秘籍宝箱
55010 宠物书·石碑
55011 宠物书·怪物
55012 宠物书·首领
55013 宠物书·力量
55014 宠物书·智力
55015 宠物书·敏捷
55016 宠物书·体力
55017 宠物书·野兽
55018 宠物书·兽人
55019 宠物书·秘法
55020 宠物书·不死
55021 宠物书·恶魔
55022 宠物书·经验
55023 宠物书·掉宝
55024 宠物书·金币
55025 宠物书·半兽人
55026 宠物书·抗半兽人
55027 宠物书·技能伤害
55028 宠物书·抗技能伤害
55029 宠物书·平均伤害
55030 宠物书·抗平均伤害
55031 宠物书·抗暴击
55033 宠物技能清除剂
55035 宠物经验增幅剂
55036 宠物栏位解锁器
55038 宠物书·抗穿透
55039 宠物书·施法速度
55040 宠物书·格挡
55041 宠物书·抗毒
55042 宠物书·冰
55043 宠物书·土
55044 宠物书·暗
55101 活力药剂(小)
55102 活力药剂(中)
55103 活力药剂(大)
55104 活力药剂(特大)
55108 战斗药剂(小)
55109 战斗药剂(中)
55110 战斗药剂(大)
55111 战斗药剂(特大)
55115 力量药剂(小)
55116 力量药剂(中)
55117 力量药剂(大)
55118 力量药剂(特大)
55401 猴子蛋
55404 奈梅尔蛋
55407 阿兹雷尔蛋
71097 炼金附魔道具
71234 蓝色巴里
71254 白色冬日羊驼
71301 护符加持道具
71302 护符强化道具
71502 狮子封印(永久)
71505 野猪封印(永久)
71508 狼封印(永久)
71511 老虎封印(永久)
72064 小型扩充券
72065 中型扩充券
72066 大型扩充券
72067 强效扩充券
72068 天界扩充券
72319 金钥匙
72320 银钥匙
72346 6-7属性附魔道具
77900 药剂宝箱(小)
77901 药剂宝箱(中)
77902 药剂宝箱(大)
77903 药剂宝箱(特大)
77904 阅读宝箱
77905 护符宝箱
77906 耳环宝箱
77907 手镯宝箱
77908 项链宝箱
77909 戒指宝箱
77910 强化宝箱(小)
77911 强化宝箱(中)
77912 强化宝箱(大)
77913 炼金宝箱(小)
77914 炼金宝箱(中)
77915 炼金宝箱(大)
77916 战令宝箱
77917 任务书宝箱
77918 青铜宝箱
77919 白银宝箱
77920 黄金宝箱
77921 青铜钥匙
77922 白银钥匙
77923 黄金钥匙
77924 钥匙宝箱
80019 精致布料
80025 时装兑换券
80030 男爵夫人的宝箱
85004 领主披风(个人)
85008 大师披风(个人)
85014 王朝披风(个人)
85018 皇家披风(个人)
96001 火之护符碎片
96002 冰之护符碎片
96003 土之护符碎片
96004 暗之护符碎片
96005 风之护符碎片
96006 雷之护符碎片
1 # AVA2 drop items imported as 40250 items (vnum<TAB>zh name). 54001-54267 are named by range in make_items.py.
2 320 守护者之剑+0
3 340 守护者之刃+0
4 1190 守护者匕首+0
5 2210 守护者之弓+0
6 3230 守护者大刀+0
7 5170 守护者之铃+0
8 6884 稀有度转换器
9 7310 守护者之扇+0
10 9600 火之护符+0
11 9830 冰之护符+0
12 10060 土之护符+0
13 10290 暗之护符+0
14 10520 风之护符+0
15 10750 雷之护符+0
16 15440 炎晶之鞋+0
17 15450 海洋之鞋+0
18 15460 锆石之鞋+0
19 15470 孔雀石之鞋+0
20 19500 红宝石戒指+0
21 19520 石榴石戒指+0
22 19540 祖母绿戒指+0
23 19560 蓝宝石戒指+0
24 25163 缩小的头颅
25 25164 蜘蛛钥匙
26 25165 螺旋钥匙
27 25166 缩小的头颅+
28 25167 拉扎多尔入场券
29 25168 奈梅尔入场券
30 25169 九头蛇护身符
31 28444 破碑之石+4
32 28445 克首领之石+4
33 30400 盖亚宝石
34 30600 图样
35 30601 铠甲设计图
36 30602 蓝色染料
37 30603 红色染料
38 30604 灰色染料
39 30605 金板
40 30606 银板
41 30607 铜板
42 30608 钢板
43 30609 青铜板
44 30610 秘银
45 30611 坚韧绑带
46 30612 结实绳索
47 30614 黑色染料
48 30615 白色染料
49 30616 二氧化钛
50 30617 玛瑙
51 30618 月长石
52 30620 红龙之角
53 30621 红龙鳞片
54 30623 智慧之书
55 30624 魔法宝珠
56 30625 帕德玛刚玉
57 30626 蓝晶石
58 30627 神之果实
59 30628 神圣之花
60 30629 众神灵药
61 30630 龙之翼
62 33031 吊坠宝石
63 39070 灰色碎片
64 39071 白色碎片
65 39072 绿色碎片
66 39073 黄色碎片
67 50275 金色欧凯宝箱
68 50276 银色欧凯宝箱
69 50277 铜色欧凯宝箱
70 50569 秘籍·地震
71 50570 秘籍·星光
72 50571 秘籍·风暴射击
73 50572 秘籍·地狱打击
74 50573 秘籍·致命波动
75 50574 秘籍·流星
76 50575 秘籍·灵气护盾
77 50928 金王战利品
78 50929 银王战利品
79 50930 铜王战利品
80 51110 阿拉多尔宝箱
81 51111 奈梅尔宝箱
82 51112 贝兰塞陶宝箱
83 51115 远古守护者宝箱
84 51116 红龙宝箱
85 51117 远古巨龙宝箱
86 53004 鲁弗斯封印(永久)
87 53996 护符附魔道具
88 53997 护符加持道具
89 54705 九头蛇宝箱
90 54706 65级武器宝箱
91 54707 70级武器宝箱
92 54708 75级武器宝箱
93 54709 防具宝箱
94 54710 装备宝箱
95 54711 饰品宝箱
96 54712 生物学家礼包
97 54713 生物学家袋子
98 54714 生物学家袋子+
99 54715 强化药剂包(小)
100 54716 强化药剂包(中)
101 54717 强化药剂包(特大)
102 55001 宠物蛋白补充剂
103 55004 宠物秘籍·幼年
104 55005 宠物秘籍·野性
105 55006 宠物秘籍·勇敢
106 55009 宠物秘籍宝箱
107 55010 宠物书·石碑
108 55011 宠物书·怪物
109 55012 宠物书·首领
110 55013 宠物书·力量
111 55014 宠物书·智力
112 55015 宠物书·敏捷
113 55016 宠物书·体力
114 55017 宠物书·野兽
115 55018 宠物书·兽人
116 55019 宠物书·秘法
117 55020 宠物书·不死
118 55021 宠物书·恶魔
119 55022 宠物书·经验
120 55023 宠物书·掉宝
121 55024 宠物书·金币
122 55025 宠物书·半兽人
123 55026 宠物书·抗半兽人
124 55027 宠物书·技能伤害
125 55028 宠物书·抗技能伤害
126 55029 宠物书·平均伤害
127 55030 宠物书·抗平均伤害
128 55031 宠物书·抗暴击
129 55033 宠物技能清除剂
130 55035 宠物经验增幅剂
131 55036 宠物栏位解锁器
132 55038 宠物书·抗穿透
133 55039 宠物书·施法速度
134 55040 宠物书·格挡
135 55041 宠物书·抗毒
136 55042 宠物书·冰
137 55043 宠物书·土
138 55044 宠物书·暗
139 55101 活力药剂(小)
140 55102 活力药剂(中)
141 55103 活力药剂(大)
142 55104 活力药剂(特大)
143 55108 战斗药剂(小)
144 55109 战斗药剂(中)
145 55110 战斗药剂(大)
146 55111 战斗药剂(特大)
147 55115 力量药剂(小)
148 55116 力量药剂(中)
149 55117 力量药剂(大)
150 55118 力量药剂(特大)
151 55401 猴子蛋
152 55404 奈梅尔蛋
153 55407 阿兹雷尔蛋
154 71097 炼金附魔道具
155 71234 蓝色巴里
156 71254 白色冬日羊驼
157 71301 护符加持道具
158 71302 护符强化道具
159 71502 狮子封印(永久)
160 71505 野猪封印(永久)
161 71508 狼封印(永久)
162 71511 老虎封印(永久)
163 72064 小型扩充券
164 72065 中型扩充券
165 72066 大型扩充券
166 72067 强效扩充券
167 72068 天界扩充券
168 72319 金钥匙
169 72320 银钥匙
170 72346 6-7属性附魔道具
171 77900 药剂宝箱(小)
172 77901 药剂宝箱(中)
173 77902 药剂宝箱(大)
174 77903 药剂宝箱(特大)
175 77904 阅读宝箱
176 77905 护符宝箱
177 77906 耳环宝箱
178 77907 手镯宝箱
179 77908 项链宝箱
180 77909 戒指宝箱
181 77910 强化宝箱(小)
182 77911 强化宝箱(中)
183 77912 强化宝箱(大)
184 77913 炼金宝箱(小)
185 77914 炼金宝箱(中)
186 77915 炼金宝箱(大)
187 77916 战令宝箱
188 77917 任务书宝箱
189 77918 青铜宝箱
190 77919 白银宝箱
191 77920 黄金宝箱
192 77921 青铜钥匙
193 77922 白银钥匙
194 77923 黄金钥匙
195 77924 钥匙宝箱
196 80019 精致布料
197 80025 时装兑换券
198 80030 男爵夫人的宝箱
199 85004 领主披风(个人)
200 85008 大师披风(个人)
201 85014 王朝披风(个人)
202 85018 皇家披风(个人)
203 96001 火之护符碎片
204 96002 冰之护符碎片
205 96003 土之护符碎片
206 96004 暗之护符碎片
207 96005 风之护符碎片
208 96006 雷之护符碎片
+115
View File
@@ -0,0 +1,115 @@
import sys,unittest
from pathlib import Path
HERE=Path(__file__).resolve().parents[1];sys.path.insert(0,str(HERE))
import make_items as m
READER=HERE.parents[3]/'metin2-server/src/server/db/src/ProtoReader.cpp'
def row(vnum,t,st,anti='NONE',flags='NONE',wear='NONE',applies=('APPLY_NONE','0')*3,values=('0',)*6,refine=('0','0')):
return [str(vnum),'x',t,st,'1',anti,flags,wear,'NONE','10','20',refine[0],refine[1],'0','LEVEL','120','LIMIT_NONE','0',*applies,*values,'0','0','-1','']
@unittest.skipUnless(READER.is_file(),'needs ../metin2-server')
class ConvertTests(unittest.TestCase):
E=m.enums(READER) if READER.is_file() else None
def test_weapon_kept_without_unknown_flags(self):
s,c,kept=m.convert(row(320,'ITEM_WEAPON','WEAPON_SWORD','ANTI_MUDANG | ANTI_WOLFMAN','ITEM_TUNABLE','WEAR_WEAPON',
('APPLY_ATT_SPEED','18','APPLY_ATTBONUS_BOSS','5','APPLY_NONE','0'),refine=('321','81')),self.E,{320})
self.assertTrue(kept);self.assertEqual(s[5],'ANTI_MUDANG');self.assertEqual(s[7],'WEAR_WEAPON')
self.assertEqual(s[18:22],['APPLY_ATT_SPEED','18','APPLY_ATTBONUS_BOSS','5']) # ported apply kept
self.assertEqual(s[11:13],['0','0']) # the +1 item is not imported
self.assertEqual(c[1:3],[self.E['type'].index('ITEM_WEAPON'),0]);self.assertEqual(c[4],1<<self.E['anti'].index('ANTI_MUDANG'))
self.assertEqual(len(s),33);self.assertEqual(len(c),31)
def test_costume_enchant_kept_only_for_ported_targets(self):
for vnum,mask in ((54001,'2'),(54078,'4'),(54130,'16')): # body / hair / mount skin (port_costume_attr.cpp)
s,c,kept=m.convert(row(vnum,'ITEM_USE','USE_CHANGE_ATTRIBUTE',flags='ITEM_STACKABLE',values=('23','10',mask,'0','0','0')),self.E,set())
self.assertTrue(kept);self.assertEqual(s[2:4],['ITEM_USE','USE_CHANGE_ATTRIBUTE']);self.assertEqual(s[24:27],['23','10',mask])
for vnum,mask in ((54100,'8'),(54200,'32'),(54250,'64')): # weapon costume / sash skin / effect
s,c,kept=m.convert(row(vnum,'ITEM_USE','USE_CHANGE_ATTRIBUTE',flags='ITEM_STACKABLE',values=('23','10',mask,'0','0','0')),self.E,set())
self.assertFalse(kept);self.assertEqual(s[2:4],['ITEM_NONE','0']);self.assertEqual(s[24:30],['0']*6)
self.assertEqual(c[1:3],[0,0]);self.assertEqual(c[5],1<<self.E['flag'].index('ITEM_STACKABLE'))
def test_unknown_type_and_stackable_anti(self):
s,_,kept=m.convert(row(55010,'ITEM_PET','PET_SKILL','ITEM_STACKABLE','LOG','WEAR_PENDANT'),self.E,set())
self.assertFalse(kept);self.assertEqual(s[5:8],['NONE','LOG | ITEM_STACKABLE','NONE'])
def test_quest_subtype(self):
s,c,kept=m.convert(row(30400,'ITEM_QUEST','QUEST_NONE',values=('APPLY_NONE','0','0','0','0','0')),self.E,set())
self.assertTrue(kept);self.assertEqual(s[3],'0');self.assertEqual(s[24],'0')
def test_treasure_box_and_key_kept(self):
for vnum,t in ((77918,'ITEM_TREASURE_BOX'),(77921,'ITEM_TREASURE_KEY')):
s,c,kept=m.convert(row(vnum,t,'0',values=('3','0','0','0','0','0')),self.E,set())
self.assertTrue(kept);self.assertEqual(s[2:4],[t,'0']);self.assertEqual(s[24],'3') # key value0 == box value0 opens it
self.assertEqual(c[1],self.E['type'].index(t))
def test_stone_and_boss_stones_kept(self):
s,c,kept=m.convert(row(28444,'ITEM_METIN','METIN_NORMAL',flags='ITEM_STACKABLE',wear='WEAR_WEAPON',
applies=('APPLY_ATTBONUS_STONE','8','APPLY_NONE','0','APPLY_NONE','0'),values=('0','0','0','0','0','31')),self.E,set())
self.assertTrue(kept);self.assertEqual(s[7],'WEAR_WEAPON');self.assertEqual(s[18:20],['APPLY_ATTBONUS_STONE','8'])
self.assertEqual(c[17:19],[139,8]) # AVA2 numbering, shared with the server and client enums
self.assertEqual(self.E['apply'].index('APPLY_ATTBONUS_BOSS'),140);self.assertEqual(s[29],'31')
def test_dragon_armor_kept_with_refine_chain(self):
s,c,kept=m.convert(row(21210,'ITEM_ARMOR','ARMOR_BODY','ANTI_ASSASSIN | ANTI_SURA | ANTI_MUDANG | ANTI_WOLFMAN','ITEM_TUNABLE','WEAR_BODY',
('APPLY_MOV_SPEED','5','APPLY_RESIST_MAGIC','25','APPLY_MAX_HP_PCT','20'),('0','210','0','32','0','0'),('21211','81')),self.E,{21210,21211})
self.assertTrue(kept);self.assertEqual(s[3],'ARMOR_BODY');self.assertEqual(s[7],'WEAR_BODY');self.assertEqual(s[11:13],['21211','81'])
self.assertEqual(s[5],'ANTI_ASSASSIN | ANTI_SURA | ANTI_MUDANG');self.assertEqual(s[27],'32') # value3 = armor shape
models=m.shape_models();self.assertEqual(len(models),40)
self.assertIn('d:/ymir work/pc2/warrior/warrior_7_1_lod_03.gr2',models)
def test_sash_kept(self):
s,c,kept=m.convert(row(85004,'ITEM_COSTUME','COSTUME_SASH',wear='WEAR_COSTUME_BODY',
values=('4','0','0','0','0','0')),self.E,{85004})
self.assertTrue(kept);self.assertEqual(s[2:4],['ITEM_COSTUME','COSTUME_SASH']);self.assertEqual(s[24],'4') # value0 = grade
self.assertEqual(c[2],3) # COSTUME_SASH, after BODY/HAIR/MOUNT
def test_mount_and_skin_kept(self):
s,c,kept=m.convert(row(71502,'ITEM_COSTUME','COSTUME_MOUNT','ANTI_STACK',applies=('APPLY_ATTBONUS_MONSTER','30','APPLY_NONE','0','APPLY_NONE','0'),
values=('20113','0','0','0','0','0')),self.E,{71502})
self.assertTrue(kept);self.assertEqual(s[3],'COSTUME_MOUNT');self.assertEqual(s[18:20],['APPLY_ATTBONUS_MONSTER','30'])
self.assertEqual(s[24],'20113');self.assertEqual(c[2],2) # value0 = horse race
s,c,kept=m.convert(row(71234,'ITEM_COSTUME','COSTUME_MOUNT_SKIN',values=('32012','0','0','0','0','0')),self.E,{71234})
self.assertTrue(kept);self.assertEqual(s[3],'COSTUME_MOUNT_SKIN');self.assertEqual(c[2],4)
self.assertEqual(set(m.MOUNT_MOBS),{32012,32028})
def test_pendant_and_scrolls_kept(self):
s,c,kept=m.convert(row(9601,'ITEM_ARMOR','ARMOR_PENDANT',flags='ITEM_TUNABLE',wear='WEAR_PENDANT',
applies=('APPLY_ENCHANT_FIRE','1','APPLY_NONE','0','APPLY_NONE','0'),refine=('9602','441')),self.E,{9601,9602})
self.assertTrue(kept);self.assertEqual(s[3],'ARMOR_PENDANT');self.assertEqual(s[7],'NONE') # no WEAR_PENDANT flag: FindEquipCell
self.assertEqual(s[11:13],['9602','441']);self.assertEqual(s[18:20],['APPLY_ENCHANT_FIRE','1']);self.assertEqual(c[17],124)
self.assertEqual(c[2],self.E['sub'][self.E['type'].index('ITEM_ARMOR')].index('ARMOR_PENDANT'))
for vnum,st in ((53996,'USE_CHANGE_ATTRIBUTE'),(71302,'USE_CHANGE_ATTRIBUTE'),(53997,'USE_ADD_ATTRIBUTE'),(71301,'USE_ADD_ATTRIBUTE')):
s,c,kept=m.convert(row(vnum,'ITEM_USE',st,flags='ITEM_STACKABLE'),self.E,set())
self.assertTrue(kept);self.assertEqual(s[2:4],['ITEM_USE',st])
s,c,kept=m.convert(row(71303,'ITEM_USE','USE_ADD_ATTRIBUTE'),self.E,set());self.assertFalse(kept)
n=m.names();self.assertEqual([n[v] for v in (9600,9800,10950)],['火之护符+0','火之护符+200','雷之护符+200'])
def test_new_rings_kept_with_refine_chain(self):
s,c,kept=m.convert(row(19500,'ITEM_NEW_RINGS','0',
applies=('APPLY_INT','1','APPLY_RESIST_WARRIOR','1','APPLY_NONE','0'),refine=('19501','46')),self.E,{19500,19501})
self.assertTrue(kept);self.assertEqual(s[2:4],['ITEM_NEW_RINGS','0']);self.assertEqual(c[1],36)
self.assertEqual(s[11:13],['19501','46']);self.assertEqual(s[18:22],['APPLY_INT','1','APPLY_RESIST_WARRIOR','1'])
n=m.names();self.assertEqual([n[v] for v in (19500,19509,19569)],['红宝石戒指+0','红宝石戒指+9','蓝宝石戒指+9'])
def test_install_items_with_mobs(self):
import subprocess,tempfile
with tempfile.TemporaryDirectory() as d:
d=Path(d);conf=d/'share/conf';conf.mkdir(parents=True)
(conf/'item_proto.txt').write_bytes(b'VNUM\tNAME\r\n71502\told\r\n');(conf/'item_names.txt').write_bytes(b'VNUM\tNAME\r\n')
(conf/'mob_proto.txt').write_bytes(b'VNUM\tNAME\n20110\tboar\n');(conf/'mob_names.txt').write_bytes(b'20110\tboar\n')
(d/'i.tsv').write_bytes(b'71502\tnew\n');(d/'m.tsv').write_bytes(b'32012\tbari\tKING\n')
subprocess.run([sys.executable,str(HERE/'install_items.py'),str(d),str(d/'i.tsv'),str(d/'bk'),str(d/'m.tsv')],check=True,capture_output=True)
self.assertEqual((conf/'item_proto.txt').read_bytes(),b'VNUM\tNAME\r\n71502\tnew\r\n')
self.assertEqual((conf/'mob_proto.txt').read_bytes(),b'VNUM\tNAME\n20110\tboar\n32012\tbari\tKING\n')
self.assertEqual((conf/'mob_names.txt').read_bytes(),b'20110\tboar\n32012\tbari\n')
self.assertTrue((d/'bk/mob_proto.txt').is_file())
def test_sash_effect_closure(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
d=Path(d);files={}
def put(name,data=b'x'):
f=d/str(len(files));f.write_bytes(data);files[name]=f
put(m.SASH_EFFECTS[0],b'MeshFileName "buff_item1.mde"\nTextureFileName "d:/ymir work/effect/a.dds"')
for q in m.SASH_EFFECTS[1:]:put(q)
put('d:/ymir work/effect/etc/buff/buff_item1.mde',b'"light010.dds"');put('d:/ymir work/effect/a.dds')
put('d:/ymir work/effect/etc/buff/light010.dds')
got=set(m.effect_files(files))
self.assertEqual(got,set(files))
del files['d:/ymir work/effect/etc/buff/light010.dds']
with self.assertRaises(SystemExit):m.effect_files(files)
def test_names_cover_ranges(self):
n=m.names();self.assertEqual(len(n),514+6*200+4*9);self.assertEqual(n[54100],'武器外观强化石')
self.assertEqual([n[v] for v in (21210,21219,21279)],['暗龙铠甲+0','暗龙铠甲+9','神龙铠甲+9'])
self.assertTrue(all(len(v.encode('gbk'))<=24 for v in n.values()))
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 [宝石]
+14
View File
@@ -0,0 +1,14 @@
# Talisman refine sets
AVA2 talismans (护符, metin2-server docs/FEATURE-AVA2-UNLOCK.md P7) refine +0..+200 through 201 items per element. Every step uses one of ten refine sets per element: fragment ×1 + 30031 ×1 + 33031 ×10, 50000 yang, 100 % (AVA2's own rows).
| Element | Items | Sets | Fragment |
|---|---|---|---|
| fire | 9600–9800 | 440–449 | 96001 |
| ice | 9830–10030 | 460–469 | 96002 |
| earth | 10060–10260 | 480–489 | 96003 |
| dark | 10290–10490 | 490–499 | 96004 |
| wind | 10520–10720 | 470–479 | 96005 |
| elect | 10750–10950 | 450–459 | 96006 |
`install.sh` (on the server) backs up rows 440–499, replaces them from `refine.sql`, and checks 60 rows. The db loads refine_proto at boot, so restart the db afterwards. The items themselves come from `../ava2-items`.
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Installs the talisman refine sets (README.md) into player.refine_proto; the db reads them at boot, so restart the db.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
MYSQL=${MYSQL:-mysql -umetin2 -ppassword}
BACKUP=/root/backup-pendant/$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP"
$MYSQL player -e 'SELECT * FROM refine_proto WHERE id BETWEEN 440 AND 499' > "$BACKUP/refine_440_499.tsv"
echo "backup: $BACKUP"
$MYSQL player < "$HERE/refine.sql"
[ "$($MYSQL -N player -e 'SELECT COUNT(*) FROM refine_proto WHERE id BETWEEN 440 AND 499 AND cost=50000')" -eq 60 ] || { echo "refine rows missing" >&2; exit 1; }
echo "done; restart the db"
+63
View File
@@ -0,0 +1,63 @@
-- AVA2 talisman refine sets 440-499 (AVA2 player.refine_proto): element fragment x1 + 30031 x1 + 33031 x10, 50000 yang, 100 %.
-- Step n of a talisman chain uses set base + n % 10 (fire 440, elect 450, ice 460, wind 470, earth 480, dark 490).
REPLACE INTO refine_proto (id,vnum0,vnum1,vnum2,vnum3,vnum4,count0,count1,count2,count3,count4,cost,src_vnum,result_vnum,prob) VALUES
(440,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(441,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(442,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(443,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(444,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(445,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(446,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(447,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(448,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(449,96001,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(450,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(451,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(452,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(453,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(454,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(455,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(456,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(457,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(458,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(459,96006,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(460,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(461,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(462,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(463,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(464,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(465,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(466,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(467,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(468,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(469,96002,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(470,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(471,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(472,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(473,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(474,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(475,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(476,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(477,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(478,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(479,96005,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(480,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(481,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(482,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(483,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(484,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(485,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(486,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(487,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(488,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(489,96003,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(490,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(491,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(492,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(493,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(494,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(495,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(496,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(497,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(498,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100),
(499,96004,30031,33031,0,0,1,1,10,0,0,50000,0,0,100);
+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
+12
View File
@@ -0,0 +1,12 @@
# 披风 (sash) quest
AVA2 `Globales/sash.lua` ported to 王铁匠 (20016; 203 has no NPC 60003):
披风说明 / 披风合成 (`pc.open_sash(true)`) / 属性吸收 (`pc.open_sash(false)`).
Needs the sash game binary (server `port_sash.cpp`, CG 211 / GC 215) and a client with `mt_sash`.
sh install.sh # on the server, from this directory; then restart the game cores
Adds `pc.open_sash` to quest_functions, compiles only sash.quest in an isolated staging dir, copies its
objects (state/sash + 20016/chat/sash.start.{0,1,2}.*) into the live tree, and adds it to locale_list.
Backups go to /root/backup-sash/<time>.
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Installs quest sash (README.md): run on the server from this directory, then restart the game cores.
# Adds pc.open_sash to quest_functions (needs the sash game binary; an older core would fail the call, not the boot),
# compiles only sash.quest in an isolated staging directory and copies its objects into the live quest tree.
set -eu
SERVER=${SERVER:-/usr/metin2/server}
LOCALE=${LOCALE:-newcibn}
QUEST="$SERVER/share/locale/$LOCALE/quest"
HERE=$(cd "$(dirname "$0")" && pwd)
BACKUP=/root/backup-sash/$(date +%Y%m%d-%H%M%S)
export LC_ALL=C
mkdir -p "$BACKUP/stage"
cp -p "$QUEST/quest_functions" "$QUEST/locale_list" "$BACKUP/"
[ -f "$QUEST/sash.quest" ] && cp -p "$QUEST/sash.quest" "$BACKUP/"
echo "backup: $BACKUP"
# A GBK trail byte 0x5C would be a backslash to qc.
iconv -f UTF-8 -t GBK "$HERE/sash.quest" > "$BACKUP/sash.gbk"
if grep -q '\\' "$BACKUP/sash.gbk"; then echo "sash.quest: backslash byte in GBK" >&2; exit 1; fi
cp "$QUEST/quest_functions" "$BACKUP/stage/"
grep -qx 'pc.open_sash' "$BACKUP/stage/quest_functions" || echo 'pc.open_sash' >> "$BACKUP/stage/quest_functions"
cp "$BACKUP/sash.gbk" "$BACKUP/stage/sash.quest"
(cd "$BACKUP/stage" && "$QUEST/qc" sash.quest) > "$BACKUP/qc.log" 2>&1 || { cat "$BACKUP/qc.log" >&2; exit 1; }
test -s "$BACKUP/stage/object/state/sash"
[ "$(cd "$BACKUP/stage/object/20016/chat" && ls | grep -c '^sash\.')" -eq 9 ] || { echo "unexpected qc output" >&2; exit 1; }
cat "$BACKUP/stage/quest_functions" > "$QUEST/quest_functions"
cat "$BACKUP/sash.gbk" > "$QUEST/sash.quest"
grep -qx 'sash.quest' "$QUEST/locale_list" || echo 'sash.quest' >> "$QUEST/locale_list"
(cd "$BACKUP/stage/object" && find . -type f) | while read -r f; do
mkdir -p "$QUEST/object/$(dirname "$f")"
cp "$BACKUP/stage/object/$f" "$QUEST/object/$f"
done
echo "done; restart the game cores"
+50
View File
@@ -0,0 +1,50 @@
-- 披风 (AVA2 Globales/sash.lua): 合成 / 属性吸收 窗口由 pc.open_sash(true/false) 打开。
-- AVA2 用 NPC 60003, 203 没有这个 NPC, 挂在 王铁匠 20016。
quest sash begin
state start begin
when 20016.chat."披风说明" begin
say_title(mob_name(20016))
say("")
say("披风可以吸收武器或防具的属性.")
say("")
say("[披风合成]")
say("放入两件同等级的披风合成, 成功后")
say("得到吸收率更高的披风, 失败则材料消失.")
say("")
say("[属性吸收]")
say("放入披风和一件武器或防具, 披风按吸收率")
say("获得其属性, 被吸收的装备会消失.")
say("")
end
when 20016.chat."披风合成" begin
say_title(mob_name(20016))
say("")
say("要打开披风合成窗口吗?")
say("")
local confirm = select("确定", "取消")
if confirm == 2 then
return
end
setskin(NOWINDOW)
pc.open_sash(true)
end
when 20016.chat."属性吸收" begin
say_title(mob_name(20016))
say("")
say("被吸收的装备会消失, 披风原有的吸收")
say("属性会被替换.")
say("要打开属性吸收窗口吗?")
say("")
local confirm = select("确定", "取消")
if confirm == 2 then
return
end
setskin(NOWINDOW)
pc.open_sash(false)
end
end
end
+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"