8 Commits
Author SHA1 Message Date
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
46 changed files with 3097 additions and 49 deletions
+7
View File
@@ -54,6 +54,13 @@
服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级), 服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级),
由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。 由 `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) ## 物品说明(itemdesc_translate_zh.tsv)
40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译, 40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译,
+2 -2
View File
@@ -685,8 +685,8 @@ VNUM LOCALE_NAME
31092 超冷怪的皮肤碎片 31092 超冷怪的皮肤碎片
35000 药草+ 35000 药草+
35001 虎肝+ 35001 虎肝+
38001 VIP特权 38001 VIP会员卡(7天)
38002 抽奖券(今日) 38002 VIP会员卡(30天)
38003 抽奖券(明日) 38003 抽奖券(明日)
38004 抽奖重抽券 38004 抽奖重抽券
38005 高级抽奖转盘券 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
+2 -1
View File
@@ -41,4 +41,5 @@
- 50037 剩余 20 个开全部:`done 50037 20 20 done 0`,获得 11 种; - 50037 剩余 20 个开全部:`done 50037 20 20 done 0`,获得 11 种;
- 不按 Ctrl 右键:原单个使用,礼盒正常扣除; - 不按 Ctrl 右键:原单个使用,礼盒正常扣除;
- 日志库只有原路径写的 `SYSTEM`、`TREASURE_EXP`、`TREASURE_GOLD` 行;syserr 没有新错误(只有原有的 `new_quest_lv52` LUA_ERROR)。 - 日志库只有原路径写的 `SYSTEM`、`TREASURE_EXP`、`TREASURE_GOLD` 行;syserr 没有新错误(只有原有的 `new_quest_lv52` LUA_ERROR)。
- 未部署 203 正式环境,未做 Android 真机测试;触屏没有 Ctrl,暂无触屏入口。 - 2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1(13000)用同一场景复验通过(50037 开 10 个 / 剩余 20 个、50070 跨 4 格、71036 拒绝、限速、普通右键不变)。
- 未做 Android 真机测试;触屏没有 Ctrl,暂无触屏入口。
+2 -2
View File
@@ -115,7 +115,7 @@
2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。 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。 2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。
@@ -127,4 +127,4 @@
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 世界 Boss 追踪(AVA2 uitrack 世界 Boss 页,只读,不含传送):服务端各核心用原 HEADER_GG_RELAY(名字 #mt_boss)共享定时 Boss 刷新点,旧核心忽略;客户端 mt_boss 窗口 + 刷新提醒,查询种类 bosses(能力位 256);已在 203 两个 channel5 测试核心上做跨核心联机验收,正式核心尚未部署。见 AVA2-BOSS.md。
2026-10-15 属性锁定(阶段 6 后半,参考 AVA2 ENABLE_LOCK_ATTR):服务端新表 mt_item_attr_lock(按物品 ID 存锁定掩码,各核心启动时自动建表;不改动物品结构、db 缓存和客户端封包),CItem::ChangeAttribute 保留锁定槽位,手动、自动、GM 洗练全部生效;精炼时锁定随属性复制;最多 2 组,加成武器的 0/1 槽算一组;锁定免费。客户端在自动洗练窗口里加 5 个锁定按钮。已在 203 channel5 测试核心联机验收(洗 10 次锁定的 2 条不变,重新登录后仍在);2026-10-15 16:08 部署到 203 正式环境(服务端 dc71593),CH1 复验通过;未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.md。 2026-10-15 属性锁定(阶段 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 测试核心联机验收;未部署 203 正式环境,未做 Android 真机测试和触屏入口。见 AVA2-CHEST.md 和服务端 FEATURE-CHEST.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。
+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" limits = names_dir / "item_limits_server.tsv"
if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file(): if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file():
raise ValueError("stone overlay requires the item limits table argument") 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( result = subprocess.run(
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"), [str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
str(item_names), str(mob_names), str(locale_dir / "item_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(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, check=True, capture_output=True, text=True,
) )
report["proto"] = { report["proto"] = {
@@ -393,7 +397,10 @@ def build_biolog(output: Path, pack_tool: Path):
root = REPO / "tools/server/biolog" root = REPO / "tools/server/biolog"
ava = REPO.parent / "AVA2" ava = REPO.parent / "AVA2"
archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz" 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") raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference")
proto_tool = tool("biolog_proto_patch") proto_tool = tool("biolog_proto_patch")
if not proto_tool.is_file(): if not proto_tool.is_file():
@@ -401,11 +408,14 @@ def build_biolog(output: Path, pack_tool: Path):
stage = output.parent / "biolog" stage = output.parent / "biolog"
stage.mkdir(parents=True, exist_ok=True) stage.mkdir(parents=True, exist_ok=True)
proto = stage / "ava2-item-proto.txt" proto = stage / "ava2-item-proto.txt"
with tarfile.open(archive, "r:gz") as source: if not archive.is_file():
member = source.extractfile("home/srv1/db/item_proto.txt") shutil.copyfile(extracted, proto)
if member is None: else:
raise ValueError("AVA2 item table missing") with tarfile.open(archive, "r:gz") as source:
proto.write_bytes(member.read()) 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") spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py")
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) 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} 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): def build_wiki(output: Path, pack_tool: Path):
"""A versioned public server catalogue; original script and proto packs stay intact.""" """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) args.proto_tool, args.name_tables, args.pack_tool)
report = json.loads(report_path.read_text()) report = json.loads(report_path.read_text())
report["biolog"] = build_biolog(args.output, args.pack_tool) 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["wiki"] = build_wiki(args.output, args.pack_tool)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n", encoding="utf-8") 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") 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; RenderTarget* target = nullptr;
if (draw.render_target) { if (draw.render_target) {
target = get_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 (!target) continue;
} }
if (draw.clear_flags && target) { 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, 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. // 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 bool bind_tex1 = !draw.texture1.empty() && draw.color_op[0] > 1 && draw.color_op[1] > 1;
const auto descriptor = get_texture_descriptor( 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); auto constants = make_push_constants(draw, skin_offset_encoded);
if (draw.pretransformed) { if (draw.pretransformed) {
const float screen_width = float(logical_width()); // PORT: a pretransformed draw into a render target is in the target's pixels.
const float screen_height = float(logical_height()); 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. // 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, constants.mvp = {2.0f / screen_width, 0, 0, 0,
0, -2.0f / screen_height, 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();) { for (std::size_t i = 0; i < frame.offscreen_draws.size();) {
RenderTarget* target = frame.offscreen_draws[i].target; RenderTarget* target = frame.offscreen_draws[i].target;
VkRenderPassBeginInfo offscreen_begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; 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.framebuffer = target->framebuffer;
offscreen_begin.renderArea.extent = {target->width, target->height}; offscreen_begin.renderArea.extent = {target->width, target->height};
vkCmdBeginRenderPass(f_->command, &offscreen_begin, VK_SUBPASS_CONTENTS_INLINE); 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"); const char* dump_rt = std::getenv("MT_DUMP_RT");
if (readback.bmp && dump_rt && *dump_rt) { if (readback.bmp && dump_rt && *dump_rt) {
readback.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_) { for (auto& entry : render_targets_) {
RenderTarget& rt = entry.second; 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}; VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
info.size = VkDeviceSize(rt.width) * rt.height * texel; info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; 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"); 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); for (auto& sink : readback.sinks) sink(bgra.data(), w, h);
} }
vkUnmapMemory(device_, readback.memory); 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) { for (std::size_t n = 0; n < readback.rt_dumps.size(); ++n) {
const auto& dump = readback.rt_dumps[n]; const auto& dump = readback.rt_dumps[n];
const std::uint32_t texel = dump.texel;
void* data = nullptr; void* data = nullptr;
check(vkMapMemory(device_, dump.memory, 0, VK_WHOLE_SIZE, 0, &data), "vkMapMemory rt dump"); check(vkMapMemory(device_, dump.memory, 0, VK_WHOLE_SIZE, 0, &data), "vkMapMemory rt dump");
const auto* bytes = static_cast<const std::uint8_t*>(data); 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; VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT;
offscreen_format_ = (properties.optimalTilingFeatures & wanted) == wanted offscreen_format_ = (properties.optimalTilingFeatures & wanted) == wanted
? VK_FORMAT_R5G6B5_UNORM_PACK16 : VK_FORMAT_R8G8B8A8_UNORM; ? 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]{}; 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].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].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_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.attachmentCount = 2; pass_info.pAttachments = attachments;
pass_info.subpassCount = 1; pass_info.pSubpasses = &subpass; pass_info.subpassCount = 1; pass_info.pSubpasses = &subpass;
pass_info.dependencyCount = 2; pass_info.pDependencies = dependencies; 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, 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{}; std::array<std::uint32_t, 17> key{};
key[0] = std::uint32_t(cull) | (std::uint32_t(depth) << 8) | key[0] = std::uint32_t(cull) | (std::uint32_t(depth) << 8) |
(std::uint32_t(blend) << 16) | (lines ? (1u << 24) : 0u) | (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; static const bool specialize = std::getenv("MT_GENERIC_SHADERS") == nullptr;
key[1] = specialize ? 1u : 0u; key[1] = specialize ? 1u : 0u;
if (specialize && draw) { 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.pMultisampleState = &multisample; pipeline_info.pDepthStencilState = &depth_stencil;
pipeline_info.pColorBlendState = &blending; pipeline_info.pColorBlendState = &blending;
pipeline_info.pDynamicState = &dynamic_info; 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; VkPipeline handle = VK_NULL_HANDLE;
check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &handle), "vkCreateGraphicsPipelines"); check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &handle), "vkCreateGraphicsPipelines");
pipelines_.emplace(key, handle); 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) { 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; 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; 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]; RenderTarget& target = render_targets_[name];
target.width = w; target.width = w;
target.height = h; target.height = h;
target.color = color;
target.last_used_frame = frame_number_;
auto make_image = [&](VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect, 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}; VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
info.imageType = VK_IMAGE_TYPE_2D; info.imageType = VK_IMAGE_TYPE_2D;
info.format = format; 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}; 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.image = image; view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; view_info.format = format;
view_info.subresourceRange = {aspect, 0, 1, 0, 1}; 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"); 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_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT |
VK_IMAGE_USAGE_TRANSFER_SRC_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, make_image(VK_FORMAT_D32_SFLOAT,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, 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); VK_IMAGE_ASPECT_DEPTH_BIT, target.depth_image, target.depth_memory, target.depth_view);
const VkImageView views[2] = {target.texture.view, target.depth_view}; const VkImageView views[2] = {target.texture.view, target.depth_view};
VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; 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.attachmentCount = 2; fb.pAttachments = views;
fb.width = w; fb.height = h; fb.layers = 1; fb.width = w; fb.height = h; fb.layers = 1;
check(vkCreateFramebuffer(device_, &fb, nullptr, &target.framebuffer), "vkCreateFramebuffer render target"); 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}; 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, vkCmdPipelineBarrier(f_->command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 2, barriers); 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}; const VkClearDepthStencilValue far{1.0f, 0};
vkCmdClearColorImage(f_->command, target.texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &white, 1, vkCmdClearColorImage(f_->command, target.texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &white, 1,
&barriers[0].subresourceRange); &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; for (int i = 0; i < 16; ++i) m[i] = i % 5 == 0 ? 1.0f : 0.0f;
PreparedDraw composite{}; PreparedDraw composite{};
composite.geometry = &scene_quad_; 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.descriptor = scene_texture_.descriptor;
composite.constants = make_push_constants(quad, 0.0f); composite.constants = make_push_constants(quad, 0.0f);
// Unit square (y down) to D3D NDC (y up), as the pretransformed draws map screen pixels. // 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); it = textures_.erase(it);
} else ++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 } // namespace mt_host
+1
View File
@@ -125,6 +125,7 @@ VulkanWindow::~VulkanWindow() {
for (auto& entry : textures_) release_texture(entry.second); for (auto& entry : textures_) release_texture(entry.second);
for (auto& entry : render_targets_) release_render_target(entry.second); for (auto& entry : render_targets_) release_render_target(entry.second);
if (offscreen_pass_) vkDestroyRenderPass(device_, offscreen_pass_, nullptr); if (offscreen_pass_) vkDestroyRenderPass(device_, offscreen_pass_, nullptr);
if (offscreen_color_pass_) vkDestroyRenderPass(device_, offscreen_color_pass_, nullptr);
destroy_scene_target(); destroy_scene_target();
release_geometry(scene_quad_); release_geometry(scene_quad_);
if (scene_pass_) vkDestroyRenderPass(device_, scene_pass_, nullptr); if (scene_pass_) vkDestroyRenderPass(device_, scene_pass_, nullptr);
+13 -6
View File
@@ -56,8 +56,9 @@ class VulkanWindow {
VkDescriptorSet set = VK_NULL_HANDLE; VkDescriptorSet set = VK_NULL_HANDLE;
std::uint64_t last_used_frame = 0; std::uint64_t last_used_frame = 0;
}; };
// An offscreen D3D render-target texture (the 40250 character shadow map, "rt:<id>:<w>x<h>"): // An offscreen D3D render-target texture (the 40250 character shadow map, "rt:<id>:<w>x<h>", or a
// drawn by the frame's offscreen pass ahead of the back-buffer pass and sampled by that pass. // 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 // 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. // DEPTH_STENCIL_ATTACHMENT_OPTIMAL; the render pass keeps (LOAD/STORE) both, like a D3D surface.
struct RenderTarget { struct RenderTarget {
@@ -67,7 +68,9 @@ class VulkanWindow {
VkImageView depth_view = VK_NULL_HANDLE; VkImageView depth_view = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE; VkFramebuffer framebuffer = VK_NULL_HANDLE;
std::uint32_t width = 0, height = 0; 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 { struct PreparedDraw {
Geometry* geometry = nullptr; 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. // Host copies of the swapchain image (--screenshot-out) and, with MT_DUMP_RT, of every render target.
struct Readback { 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 screenshot = false; // the swapchain image is copied (a BMP or a frame sink wants it)
bool bmp = false; // --screenshot-out bool bmp = false; // --screenshot-out
std::vector<FrameSink> sinks; 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 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. // 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(); 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 // 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 // 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. // the layouts offscreen_pass_ expects. Recorded outside any render pass.
void initialize_render_target(RenderTarget& target); 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, 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); const Render3DDraw* draw = nullptr);
VkDescriptorSet allocate_texture_descriptor_set(VkImageView view0, VkImageView view1, 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, PairedDescriptor> paired_descriptors_;
std::unordered_map<std::string, RenderTarget> render_targets_; std::unordered_map<std::string, RenderTarget> render_targets_;
VkRenderPass offscreen_pass_ = VK_NULL_HANDLE; VkRenderPass offscreen_pass_ = VK_NULL_HANDLE;
VkRenderPass offscreen_color_pass_ = VK_NULL_HANDLE;
float render_scale_ = 1.0f; float render_scale_ = 1.0f;
bool scene_supported_ = false; bool scene_supported_ = false;
VkRenderPass scene_pass_ = VK_NULL_HANDLE; 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; 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 surface_desc(UINT width, UINT height, D3DFORMAT format, DWORD usage, D3DPOOL pool)
{ {
D3DSURFACE_DESC desc = {}; D3DSURFACE_DESC desc = {};
@@ -454,7 +461,10 @@ public:
return S_OK; return S_OK;
} }
auto* surface = static_cast<CpuSurface*>(m_renderTarget); 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; return S_OK;
if (g_gpu_render_targets) if (g_gpu_render_targets)
{ {
@@ -463,6 +473,7 @@ public:
clear.render_target = surface->parent->id; clear.render_target = surface->parent->id;
clear.target_width = surface->desc.Width; clear.target_width = surface->desc.Width;
clear.target_height = surface->desc.Height; clear.target_height = surface->desc.Height;
clear.target_color = colorTarget;
clear.clear_flags = flags & (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER); clear.clear_flags = flags & (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER);
clear.clear_color = color; clear.clear_color = color;
clear.clear_z = depth; clear.clear_z = depth;
@@ -1140,11 +1151,30 @@ private:
if (m_renderTarget == m_backBuffer) if (m_renderTarget == m_backBuffer)
Render3DAdd(std::move(draw)); Render3DAdd(std::move(draw));
else if (g_gpu_render_targets) 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 else
rasterize_shadow(draw); 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 // 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, // 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. // 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; const D3DSURFACE_DESC& desc = entry.second->levels[0].desc;
if (g_gpu_render_targets && (desc.Usage & D3DUSAGE_RENDERTARGET) && desc.Format == D3DFMT_R5G6B5) if (g_gpu_render_targets && (desc.Usage & D3DUSAGE_RENDERTARGET) && desc.Format == D3DFMT_R5G6B5)
return Render3DRenderTargetName(entry.first, desc.Width, desc.Height); 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()) if (entry.second == handle && !entry.second->levels.empty())
return "mem:cpu_" + std::to_string(entry.first) + "@" + std::to_string(entry.second->revision); 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; } void SetGpuRenderTargetsEnabled(bool enabled) { g_gpu_render_targets = enabled; }
bool IsGpuRenderTargetsEnabled() { return g_gpu_render_targets; } 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() 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. // 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 render_target = 0;
std::uint32_t target_width = 0, target_height = 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 // 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. // "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. // default (the Godot consumer has no offscreen pass); the native Vulkan renderer turns it on.
void SetGpuRenderTargetsEnabled(bool enabled); void SetGpuRenderTargetsEnabled(bool enabled);
bool IsGpuRenderTargetsEnabled(); 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 Render3DBeginFrame();
void Render3DAdd(Render3DDraw draw); void Render3DAdd(Render3DDraw draw);
+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";
@@ -0,0 +1,255 @@
// PORT: model preview, from AVA2's render-target window (uirendertarget.py). A wearable item is shown on a
// character of every race that may wear it, a monster with its motions; drag rotates, buttons zoom. Entry points:
// X + click on an inventory or shop item, and a "模型预览" line in the wiki detail. The native side is the
// renderTarget module (UserInterface/PythonRenderTargetModule.cpp). Design: docs/FEATURE-MODEL-PREVIEW.md.
static const char c_szModelPreviewScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import game
import grp
import item
import player
import renderTarget
import ui
import wndMgr
from mt_biolog import Action, Text, L
INDEX=1
VIEW_W=300
VIEW_H=330
MARGIN=15
TOP=58
ROW=54
ZOOM_MIN=-8
ZOOM_MAX=12
ITEM_ZOOM=2
PITCH_LIMIT=60.
# Paired by job: warrior, assassin, sura, shaman; male first. race%4 is the job, these races are male.
RACES=(0,4,5,1,2,6,7,3)
MALE=(0,5,2,7)
JOBS=('武士','刺客','修罗','法师')
MOTIONS=((1,'待机'),(13,'攻击'),(5,'受击'),(11,'死亡'),(33,'技能1'),(34,'技能2'),(35,'技能3'),(36,'技能4'))
g_window=None
def RaceName(race):
return '%s(%s)'%(JOBS[race%4],'男' if race in MALE else '女')
def ItemSpec(vnum):
# (slot, races that may wear it) for a wearable item with a model, None otherwise.
try:
item.SelectItem(vnum)
kind=item.GetItemType();sub=item.GetItemSubType()
except (RuntimeError,TypeError):return None
if kind==item.ITEM_TYPE_WEAPON and sub!=item.WEAPON_ARROW:slot='weapon'
elif kind==item.ITEM_TYPE_ARMOR and sub==item.ARMOR_BODY:slot='armor'
elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_BODY:slot='armor'
elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_HAIR:slot='hair'
else:return None
jobs=(item.ITEM_ANTIFLAG_WARRIOR,item.ITEM_ANTIFLAG_ASSASSIN,item.ITEM_ANTIFLAG_SURA,item.ITEM_ANTIFLAG_SHAMAN)
races=[r for r in RACES if not item.IsAntiFlag(jobs[r%4]) and
not item.IsAntiFlag(item.ITEM_ANTIFLAG_MALE if r in MALE else item.ITEM_ANTIFLAG_FEMALE)]
if not races:return None
return slot,races
def Previewable(kind,vnum):
return kind=='mob' or ItemSpec(vnum) is not None
class View(ui.Window):
# The render target is drawn over this window; a left drag turns the camera around the model.
def __init__(self,owner):
ui.Window.__init__(self);self.owner=owner;self.drag=None
def OnMouseLeftButtonDown(self):
x,y=wndMgr.GetMousePosition();o=self.owner;self.drag=(x,y,o.yaw,o.pitch);return True
def OnMouseLeftButtonUp(self):
self.drag=None;return True
def OnUpdate(self):
if self.drag:
x,y=wndMgr.GetMousePosition();x0,y0,yaw,pitch=self.drag
self.owner.Turn(yaw+(x-x0)*.5,pitch+(y-y0)*.3)
def OnRender(self):
x,y=self.GetGlobalPosition();w=self.GetWidth();h=self.GetHeight()
grp.SetColor(0xff000000);grp.RenderBar(x,y,w,h)
renderTarget.RenderTexture(INDEX,x,y,x+w,y+h)
grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,h)
class PreviewWindow(ui.Window):
def __init__(self):
ui.Window.__init__(self);self.SetWindowName('ModelPreviewWindow');self.AddFlag('float');self.AddFlag('movable')
width=VIEW_W+2*MARGIN;self.SetSize(width,TOP+VIEW_H+ROW+15)
self.heading=Text(self,18,18,'模型预览',0xffffd68b)
self.closeButton=Action(self,width-76,4,64,'关闭',self.Close)
self.view=View(self);self.view.SetParent(self);self.view.SetPosition(MARGIN,TOP);self.view.SetSize(VIEW_W,VIEW_H);self.view.Show()
self.note=Text(self,MARGIN+8,TOP+8,'',0xffc3b7a1)
y=TOP+VIEW_H+6
self.prevRace=Action(self,MARGIN,y,56,'<',lambda:self.StepRace(-1))
self.raceName=Text(self,width/2,y+16,'',0xffe8dec8);self.raceName.SetHorizontalAlignCenter()
self.nextRace=Action(self,width-MARGIN-56,y,56,'>',lambda:self.StepRace(1))
step=(VIEW_W+6)/4
self.motions=[Action(self,MARGIN+(i%4)*step,y+(i//4)*ROW,step-6,name,(lambda m=motion:self.Motion(m)))
for i,(motion,name) in enumerate(MOTIONS)]
self.controls=[Action(self,MARGIN+i*step,0,step-6,name,event) for i,(name,event) in enumerate((
('放大',lambda:self.Zoom(-1)),('缩小',lambda:self.Zoom(1)),('自动旋转',self.ToggleSpin),('复位',self.Apply)))]
self.kind=None;self.vnum=0;self.slot=None;self.races=[];self.race=0
self.yaw=0.;self.pitch=0.;self.zoom=0;self.spin=False;self.ready=False
self.Hide()
def Open(self,kind,vnum):
if kind=='mob':slot,races=None,[]
else:
spec=ItemSpec(vnum)
if not spec:return False
slot,races=spec
renderTarget.Remove(INDEX)
if not renderTarget.Create(INDEX,VIEW_W,VIEW_H):return False
renderTarget.SetVisibility(INDEX,True)
self.kind=kind;self.vnum=vnum;self.slot=slot;self.races=races
if races:
try:own=player.GetRace()
except RuntimeError:own=-1
self.race=own if own in races else races[0]
self.Layout();self.Apply()
if not self.IsShow():self.SetCenterPosition()
ui.Window.Show(self);self.SetTop()
return True
def Layout(self):
item_mode=self.kind!='mob';rows=1 if item_mode else (len(MOTIONS)+3)//4
y=TOP+VIEW_H+6
for w in (self.prevRace,self.nextRace,self.raceName):
if item_mode:w.Show()
else:w.Hide()
for b in self.motions:
if item_mode:b.Hide()
else:b.Show()
bottom=y+rows*ROW
step=(VIEW_W+6)/4
for i,b in enumerate(self.controls):b.SetPosition(MARGIN+i*step,bottom)
self.SetSize(VIEW_W+2*MARGIN,bottom+ROW+10)
many=len(self.races)>1
self.prevRace.SetEnabled(many);self.nextRace.SetEnabled(many)
def Apply(self):
# Rebuilds the model: also the reset button, since SelectModel puts the camera back.
renderTarget.ResetModel(INDEX)
self.yaw=0.;self.pitch=0.;self.zoom=0
if self.kind=='mob':
self.ready=bool(renderTarget.SelectModel(INDEX,self.vnum))
note='' if self.ready else '该怪物没有模型'
else:
self.ready=bool(renderTarget.SelectModel(INDEX,self.race))
if self.ready:
if self.slot=='armor':renderTarget.SetArmor(INDEX,self.vnum)
elif self.slot=='weapon':renderTarget.SetWeapon(INDEX,self.vnum)
else:renderTarget.SetHair(INDEX,self.vnum)
# The default camera cuts the feet of a player race in this view; start two zoom steps out.
if self.ready:
for _ in range(ITEM_ZOOM):renderTarget.Zoom(INDEX,1)
note='' if self.ready else '模型加载失败'
self.raceName.SetText(L(RaceName(self.race)))
renderTarget.SetRotation(INDEX,self.spin)
renderTarget.RotateEyeAroundTarget(INDEX,0.,0.)
self.note.SetText(L(note))
def StepRace(self,delta):
if len(self.races)<2:return
self.race=self.races[(self.races.index(self.race)+delta)%len(self.races)];self.Apply()
def Motion(self,motion):
if self.ready and not renderTarget.SetMotionIndex(INDEX,motion):self.note.SetText(L('该怪物没有此动作'))
else:self.note.SetText('')
def Zoom(self,direction):
if not self.ready or not ZOOM_MIN<=self.zoom+direction<=ZOOM_MAX:return
self.zoom+=direction;renderTarget.Zoom(INDEX,direction)
def Turn(self,yaw,pitch):
self.yaw=yaw%360.;self.pitch=max(-PITCH_LIMIT,min(PITCH_LIMIT,pitch))
renderTarget.RotateEyeAroundTarget(INDEX,self.pitch,self.yaw)
def ToggleSpin(self):
self.spin=not self.spin;renderTarget.SetRotation(INDEX,self.spin)
self.controls[2].SetText('停止旋转' if self.spin else '自动旋转')
def Close(self):
self.Hide()
def Hide(self):
# The target holds a GPU texture and a model; drop both whenever the window is not shown.
# ui.Window.__init__ hides the window before the view exists.
if getattr(self,'view',None):self.view.drag=None
self.ready=False
renderTarget.Remove(INDEX)
ui.Window.Hide(self)
def OnRender(self):
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=None;self.prevRace.event=self.nextRace.event=None
for b in self.motions+self.controls:b.event=None
self.view.owner=None
def Preview(kind,vnum):
if not g_window:return False
if not g_window.Open(kind,vnum):
chat.AppendChat(chat.CHAT_TYPE_INFO,L('该物品没有可预览的模型'));return False
return True
def KeyHeld():
return app.IsPressed(app.DIK_X)
_open=game.GameWindow.Open
_close=game.GameWindow.Close
def Open(self):
global g_window
_open(self);g_window=PreviewWindow()
def Close(self):
global g_window
if g_window:g_window.Destroy();g_window=None
renderTarget.Destroy()
_close(self)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
try:
import uiInventory
_inventorySelect=uiInventory.InventoryWindow.SelectItemSlot
def InventorySelect(self,slot):
if KeyHeld():
vnum=player.GetItemIndex(self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot))
if vnum and ItemSpec(vnum):Preview('item',vnum);return
return _inventorySelect(self,slot)
uiInventory.InventoryWindow.SelectItemSlot=InventorySelect
except (ImportError,AttributeError):pass
try:
import shop
import uiShop
_shopSelect=uiShop.ShopDialog.SelectItemSlot
def ShopSelect(self,slot):
if KeyHeld():
real=getattr(self,'_ShopDialog__GetRealIndex',None)
vnum=shop.GetItemID(real(slot) if real else slot)
if vnum and ItemSpec(vnum):Preview('item',vnum);return
return _shopSelect(self,slot)
uiShop.ShopDialog.SelectItemSlot=ShopSelect
except (ImportError,AttributeError):pass
)PY";
+9
View File
@@ -44,6 +44,7 @@
#include "UserInterface/PythonSystem.h" #include "UserInterface/PythonSystem.h"
#include "UserInterface/AbstractApplication.h" #include "UserInterface/AbstractApplication.h"
#include "UserInterface/PythonApplication.h" #include "UserInterface/PythonApplication.h"
#include "UserInterface/CRenderTargetManager.h"
#include "EterBase/Timer.h" #include "EterBase/Timer.h"
#include "PythonBoot.h" #include "PythonBoot.h"
#include "../UserInterface/UserInterface.h" #include "../UserInterface/UserInterface.h"
@@ -157,6 +158,7 @@ struct GameSingletons
CGuildMarkDownloader kGuildMarkDownloader; CGuildMarkDownloader kGuildMarkDownloader;
CGuildMarkUploader kGuildMarkUploader; CGuildMarkUploader kGuildMarkUploader;
CPythonSystem pySystem; // last CPythonApplication member (m_pySystem) 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; std::unique_ptr<GameSingletons> g_game_singletons;
bool g_host_hardware_cursor_enabled = false; bool g_host_hardware_cursor_enabled = false;
@@ -911,6 +913,7 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
initsafebox(); initsafebox();
initguild(); initguild();
initServerStateChecker(); initServerStateChecker();
initRenderTarget(); // PORT: AVA2 model preview
init_2v0_gameplay_stubs(); init_2v0_gameplay_stubs();
ClearLogBoxMessage(); ClearLogBoxMessage();
@@ -1005,6 +1008,9 @@ namespace PythonBoot
#include "DisplayScript.inc" #include "DisplayScript.inc"
#include "ChannelScript.inc" #include "ChannelScript.inc"
#include "BossScript.inc" #include "BossScript.inc"
#include "VipScript.inc"
#include "ModelPreviewScript.inc"
#include "BattlePassScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript) static void install_script_module(const char* c_szModule, const char* c_szScript)
{ {
@@ -1084,6 +1090,9 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
install_script_module("mt_display", c_szDisplayScript); install_script_module("mt_display", c_szDisplayScript);
install_script_module("mt_channel", c_szChannelScript); install_script_module("mt_channel", c_szChannelScript);
install_script_module("mt_boss", c_szBossScript); 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);
if (g_touch_input) if (g_touch_input)
install_script_module("mt_mobile_hud", c_szMobileHUDScript); install_script_module("mt_mobile_hud", c_szMobileHUDScript);
} }
+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) if row:self.Select('mobs',row)
else:self.QueryRemote('mob',vnum[1],0) else:self.QueryRemote('mob',vnum[1],0)
elif vnum[0]=='items':self.Open(vnum[1]) elif vnum[0]=='items':self.Open(vnum[1])
elif vnum[0]=='preview':
import mt_preview
mt_preview.Preview(vnum[1],vnum[2])
return return
row=self.catalogue.FindItem(vnum) row=self.catalogue.FindItem(vnum)
if row:self.Select('items',row) if row:self.Select('items',row)
@@ -232,12 +235,15 @@ class WikiWindow(ui.Window):
else: else:
cost=self.catalogue.FindItem(offer['cost']) 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)) 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))) lines.append((u'查看获取来源',('query','sources',vnum)))
if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum))) if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum)))
else: 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), 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['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() if self.remote:lines=self.RemoteLines()
self.links=lines self.links=lines
pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount) pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount)
@@ -23,6 +23,7 @@
#include "UserInterface/PythonSystem.h" #include "UserInterface/PythonSystem.h"
#include "../ScriptLib/PythonBoot.h" #include "../ScriptLib/PythonBoot.h"
#include "ServerClock.h" #include "ServerClock.h"
#include "UserInterface/CRenderTargetManager.h"
#include "../EterBase/PerfCounters.h" #include "../EterBase/PerfCounters.h"
#if defined(__linux__) || defined(__ANDROID__) #if defined(__linux__) || defined(__ANDROID__)
@@ -213,6 +214,7 @@ void CPythonApplication::RenderGame()
CCullingManager::Instance().Process(); CCullingManager::Instance().Process();
CPythonCharacterManager::Instance().Deform(); CPythonCharacterManager::Instance().Deform();
CRenderTargetManager::Instance().DeformModels(); // PORT: AVA2 model preview
CEffectManager::Instance().Update(); CEffectManager::Instance().Update();
if (!SkipDiagnosticRender("characters")) CPythonBackground::Instance().RenderCharacterShadowToTexture(); if (!SkipDiagnosticRender("characters")) CPythonBackground::Instance().RenderCharacterShadowToTexture();
@@ -239,6 +241,7 @@ void CPythonApplication::RenderGame()
CPythonBackground::Instance().SetCharacterDirLight(); CPythonBackground::Instance().SetCharacterDirLight();
if (!SkipDiagnosticRender("characters")) CPythonCharacterManager::Instance().Render(); if (!SkipDiagnosticRender("characters")) CPythonCharacterManager::Instance().Render();
CRenderTargetManager::Instance().RenderModels(); // PORT: AVA2, under the character light
CPythonBackground::Instance().SetBackgroundDirLight(); CPythonBackground::Instance().SetBackgroundDirLight();
CPythonBackground::Instance().RenderWater(); CPythonBackground::Instance().RenderWater();
@@ -279,6 +282,8 @@ void CPythonApplication::UpdateGame()
s.BuildViewFrustum(); s.BuildViewFrustum();
} }
CRenderTargetManager::Instance().UpdateModels(); // PORT: AVA2 model preview
TPixelPosition kPPosMainActor; TPixelPosition kPPosMainActor;
CPythonPlayer::Instance().NEW_GetMainActorPosition(&kPPosMainActor); CPythonPlayer::Instance().NEW_GetMainActorPosition(&kPPosMainActor);
+4
View File
@@ -328,12 +328,16 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py) COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py)
add_test(NAME port.chest add_test(NAME port.chest
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_chest.py) 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() endif()
# Offline locale data builder: retains 40250 proto layout and gameplay fields. # 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) add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)
target_link_libraries(locale_proto_patch PRIVATE port_platform) target_link_libraries(locale_proto_patch PRIVATE port_platform)
add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp) add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp)
target_link_libraries(biolog_proto_patch PRIVATE port_platform) 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). # 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) add_executable(eterpack_pack ${PROJECT_SOURCE_DIR}/tests/port/eterpack_pack.cpp)
target_link_libraries(eterpack_pack PRIVATE port_platform) target_link_libraries(eterpack_pack PRIVATE port_platform)
+1
View File
@@ -163,6 +163,7 @@ class CGraphicBase
void SetOrtho3D(float hres, float vres, float zmin, float zmax); void SetOrtho3D(float hres, float vres, float zmin, float zmax);
void SetPerspective(float fov, float aspect, float nearz, float farz); void SetPerspective(float fov, float aspect, float nearz, float farz);
float GetFOV(); float GetFOV();
float GetAspect() { return ms_fAspect; } // PORT: AVA2 model preview restores the perspective
void GetClipPlane(float * fNearY, float * fFarY) void GetClipPlane(float * fNearY, float * fFarY)
{ {
*fNearY = ms_fNearY; *fNearY = ms_fNearY;
+131
View File
@@ -0,0 +1,131 @@
#include "StdAfx.h"
#include "../EterBase/Stl.h"
#include "GrpRenderTargetTexture.h"
#include "StateManager.h"
CGraphicRenderTargetTexture::CGraphicRenderTargetTexture(): m_d3dFormat{ D3DFMT_UNKNOWN }, m_depthStencilFormat{ D3DFMT_UNKNOWN }
{
Initialize();
memset(&m_renderRect, 0, sizeof(m_renderRect));
}
CGraphicRenderTargetTexture::~CGraphicRenderTargetTexture()
{
Reset();
}
void CGraphicRenderTargetTexture::ReleaseTextures()
{
safe_release(m_lpd3dRenderTexture);
safe_release(m_lpd3dRenderTargetSurface);
safe_release(m_lpd3dDepthSurface);
safe_release(m_lpd3dOriginalRenderTarget);
safe_release(m_lpd3dOldDepthBufferSurface);
memset(&m_renderRect, 0, sizeof(m_renderRect));
}
bool CGraphicRenderTargetTexture::Create(const int width, const int height, const D3DFORMAT texFormat, const D3DFORMAT depthFormat)
{
Reset();
m_height = height;
m_width = width;
if (!CreateRenderTexture(width, height, texFormat))
return false;
if (!CreateRenderDepthStencil(width, height, depthFormat))
return false;
return true;
}
void CGraphicRenderTargetTexture::CreateTextures()
{
if (CreateRenderTexture(m_width, m_height, m_d3dFormat))
CreateRenderDepthStencil(m_width, m_height, m_depthStencilFormat);
}
bool CGraphicRenderTargetTexture::CreateRenderTexture(const int width, const int height, const D3DFORMAT format)
{
m_d3dFormat = format;
// PORT: one level (AVA2 asked for a full chain, 0). Only level 0 is a render target on the recording
// device, and the preview is drawn 1:1.
if (FAILED(ms_lpd3dDevice->CreateTexture(width, height, 1, D3DUSAGE_RENDERTARGET, m_d3dFormat, D3DPOOL_DEFAULT, &m_lpd3dRenderTexture)))
return false;
if (FAILED(m_lpd3dRenderTexture->GetSurfaceLevel(0, &m_lpd3dRenderTargetSurface)))
return false;
return true;
}
bool CGraphicRenderTargetTexture::CreateRenderDepthStencil(const int width, const int height, const D3DFORMAT format)
{
m_depthStencilFormat = format;
return (ms_lpd3dDevice->CreateDepthStencilSurface(width, height, m_depthStencilFormat, D3DMULTISAMPLE_NONE, &m_lpd3dDepthSurface)) == D3D_OK;
}
void CGraphicRenderTargetTexture::SetRenderTarget()
{
ms_lpd3dDevice->GetRenderTarget(&m_lpd3dOriginalRenderTarget);
ms_lpd3dDevice->GetDepthStencilSurface(&m_lpd3dOldDepthBufferSurface);
ms_lpd3dDevice->SetRenderTarget(m_lpd3dRenderTargetSurface, m_lpd3dDepthSurface);
}
void CGraphicRenderTargetTexture::ResetRenderTarget()
{
ms_lpd3dDevice->SetRenderTarget(m_lpd3dOriginalRenderTarget, m_lpd3dOldDepthBufferSurface);
safe_release(m_lpd3dOriginalRenderTarget);
safe_release(m_lpd3dOldDepthBufferSurface);
}
void CGraphicRenderTargetTexture::Clear()
{
ms_lpd3dDevice->Clear(0, nullptr, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, 0x00000000 /* PORT: D3DCOLOR_ARGB(0, 0, 0, 0) */, 1.0f, 0);
}
void CGraphicRenderTargetTexture::Render() const
{
D3DSURFACE_DESC desc;
m_lpd3dRenderTargetSurface->GetDesc(&desc);
const auto imgWidth = static_cast<float>(m_renderRect.right - m_renderRect.left);
const auto imgHeight = static_cast<float>(m_renderRect.bottom - m_renderRect.top);
const auto su = 0.0f;
const auto sv = 0.0f;
const auto eu = imgWidth / static_cast<float>(desc.Width);
const auto ev = imgHeight / static_cast<float>(desc.Height);
TPDTVertex pVertices[4];
pVertices[0].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.top - 0.5f, 0.0f);
pVertices[0].texCoord = TTextureCoordinate(su, sv);
pVertices[0].diffuse = 0xffffffff;
pVertices[1].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.top - 0.5f, 0.0f);
pVertices[1].texCoord = TTextureCoordinate(eu, sv);
pVertices[1].diffuse = 0xffffffff;
pVertices[2].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.bottom - 0.5f, 0.0f);
pVertices[2].texCoord = TTextureCoordinate(su, ev);
pVertices[2].diffuse = 0xffffffff;
pVertices[3].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.bottom - 0.5f, 0.0f);
pVertices[3].texCoord = TTextureCoordinate(eu, ev);
pVertices[3].diffuse = 0xffffffff;
if (SetPDTStream(pVertices, 4))
{
SetDefaultIndexBuffer(DEFAULT_IB_FILL_RECT);
STATEMANAGER.SetTexture(0, GetRenderTargetTexture());
STATEMANAGER.SetTexture(1, nullptr);
STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE);
STATEMANAGER.DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 4, 0, 2);
}
}
void CGraphicRenderTargetTexture::Reset()
{
Destroy();
ReleaseTextures();
m_d3dFormat = D3DFMT_UNKNOWN;
m_depthStencilFormat = D3DFMT_UNKNOWN;
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
// PORT: AVA2 CGraphicRenderTargetTexture (model preview). On the recording device a 32-bit render-target
// texture is a GPU colour target ("rt:<id>:<w>x<h>:c"); see platform/EterLib/RecordingDevice.cpp.
#include "GrpBase.h"
#include "GrpTexture.h"
class CGraphicRenderTargetTexture : public CGraphicTexture
{
public:
CGraphicRenderTargetTexture();
virtual ~CGraphicRenderTargetTexture();
public:
bool Create(int width, int height, D3DFORMAT texFormat, D3DFORMAT depthFormat);
void CreateTextures();
bool CreateRenderTexture(int width, int height, D3DFORMAT format);
bool CreateRenderDepthStencil(int width, int height, D3DFORMAT format);
void SetRenderTarget();
void ResetRenderTarget();
void SetRenderingRect(RECT* rect){ m_renderRect = *rect; }
RECT* GetRenderingRect(){ return &m_renderRect; }
LPDIRECT3DTEXTURE8 GetRenderTargetTexture() const { return m_lpd3dRenderTexture; }
void ReleaseTextures();
static void Clear();
void Render() const;
protected:
void Reset();
LPDIRECT3DTEXTURE8 m_lpd3dRenderTexture{};
LPDIRECT3DSURFACE8 m_lpd3dRenderTargetSurface{};
LPDIRECT3DSURFACE8 m_lpd3dDepthSurface{};
LPDIRECT3DSURFACE8 m_lpd3dOriginalRenderTarget{};
LPDIRECT3DSURFACE8 m_lpd3dOldDepthBufferSurface{};
D3DFORMAT m_d3dFormat;
D3DFORMAT m_depthStencilFormat;
RECT m_renderRect{};
};
+324
View File
@@ -0,0 +1,324 @@
#include "StdAfx.h"
#include "CRenderTarget.h"
#include "InstanceBase.h"
#include "../EterLib/Camera.h"
#include "../EterLib/GrpImageInstance.h"
#include "../EterLib/ResourceManager.h"
#include "../EterLib/StateManager.h"
#include "../EterPythonLib/PythonGraphic.h"
CRenderTarget::~CRenderTarget() {}
CRenderTarget::CRenderTarget(const DWORD width, const DWORD height) :
m_pModel(nullptr),
m_background(nullptr),
m_modelRotation(0),
m_pmodelRotationActive(true),
m_visible(false),
m_bShowModel(true),
m_bScale(1.0),
m_v3AngularVelocity(0.0f, 0.0f, 0.0f),
m_v3Eye(0.0f, 0.0f, 0.0f),
m_v3Target(0.0f, 0.0f, 0.0f),
m_v3Up(0.0f, 0.0f, 1.0f)
{
auto pTex = std::make_unique<CGraphicRenderTargetTexture>();
if (!pTex->Create(width, height, D3DFMT_X8R8G8B8, D3DFMT_D16))
{
TraceError("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture %dx%d", width, height);
throw std::runtime_error("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture");
}
m_renderTargetTexture = std::move(pTex);
}
void CRenderTarget::RenderTexture() const
{
m_renderTargetTexture->Render();
}
void CRenderTarget::SetRenderingRect(RECT* rect) const
{
m_renderTargetTexture->SetRenderingRect(rect);
}
void CRenderTarget::CreateTextures() const
{
m_renderTargetTexture->CreateTextures();
}
void CRenderTarget::ReleaseTextures() const
{
m_renderTargetTexture->ReleaseTextures();
}
void CRenderTarget::ResetModel()
{
// PORT: AVA2 also cleared its wiki affects here; the port has none.
m_pModel.reset();
}
void CRenderTarget::SetScale(float fScale)
{
m_bScale = fScale;
if (IsReady())
SelectModel(m_pModel->GetRace(), true);
}
void CRenderTarget::SelectModel(const int index, bool isFromScale)
{
if (m_pModel && !isFromScale && m_pModel->GetRace() == index)
return;
if (isFromScale && m_pModel)
m_pModel.reset();
CInstanceBase::SCreateData kCreateData{};
// PORT: AVA2 created every model as TYPE_NPC. Here CActorInstance reserves the armor/weapon/hair parts for
// TYPE_PC only, so a player race created as an NPC crashes on the first ChangeArmor.
kCreateData.m_bType = index < NRaceData::JOB_MAX_NUM * 2 ? CActorInstance::TYPE_PC : CActorInstance::TYPE_NPC;
kCreateData.m_dwRace = index;
auto model = std::make_unique<CInstanceBase>();
if (!model->Create(kCreateData))
{
ResetModel();
return;
}
m_pModel = std::move(model);
// PORT: the preview has no name; the empty text tail Create attaches would follow the world origin.
m_pModel->DetachTextTail();
m_pModel->NEW_SetPixelPosition(TPixelPosition(0, 0, 0));
m_pModel->SetRotation(0.0f);
float fTargetHeight = 110.0f;
m_v3Eye.x = 0.0f;
m_v3Eye.y = -1000.0f;
m_v3Eye.z = 600.0f;
m_v3Target.x = 0.0f;
m_v3Target.y = 0.0f;
m_v3Target.z = 95.0f;
if (index > (NRaceData::JOB_MAX_NUM * 2))
{
const auto ptrGraphicInstance = m_pModel->GetGraphicThingInstancePtr();
if (ptrGraphicInstance) {
const float fRaceHeight = (ptrGraphicInstance->GetHeight()/m_bScale);
fTargetHeight = fRaceHeight / 2.0f;
m_v3Eye.y = -(fRaceHeight * 8.9f);
m_v3Eye.z = 0.0f;
m_v3Target.z = fRaceHeight / 2.0f;
}
}
auto& camera_manager = CCameraManager::instance();
camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA);
camera_manager.GetCurrentCamera()->SetTargetHeight(fTargetHeight);
camera_manager.ResetToPreviousCamera();
m_modelRotation = 0.0f;
m_v3AngularVelocity = D3DXVECTOR3(0.0f, 0.0f, 0.0f);
}
bool CRenderTarget::CreateBackground(const char* imgPath, const DWORD width, const DWORD height)
{
if (m_background)
return false;
m_background = std::make_unique<CGraphicImageInstance>();
const auto graphic_image = dynamic_cast<CGraphicImage*>(CResourceManager::instance().GetResourcePointer(imgPath));
if (!graphic_image)
{
m_background.reset();
return false;
}
m_background->SetImagePointer(graphic_image);
return true;
}
// PORT: AVA2 drew its background in a pass of its own through CGraphicImageInstance::Render. The port's image
// instance goes to the UI command list, not the device, so RenderModel draws the background into the target
// itself: one textured quad that fills it under a target-sized ortho.
void CRenderTarget::RenderBackground() const
{
if (!m_background || m_background->IsEmpty())
return;
CGraphicTexture* texture = m_background->GetTexturePointer();
if (!texture)
return;
const auto& rectRender = *m_renderTargetTexture->GetRenderingRect();
const auto width = static_cast<float>(rectRender.right - rectRender.left);
const auto height = static_cast<float>(rectRender.bottom - rectRender.top);
auto& python_graphic = CPythonGraphic::Instance();
python_graphic.PushState();
STATEMANAGER.SaveTransform(D3DTS_WORLD, &CGraphicBase::GetIdentityMatrix());
STATEMANAGER.SetTransform(D3DTS_VIEW, &CGraphicBase::GetIdentityMatrix());
python_graphic.SetOrtho2D(width, height, 1.0f);
STATEMANAGER.SaveRenderState(D3DRS_ZENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_ZWRITEENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_LIGHTING, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_FOGENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_ALPHABLENDENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_ALPHATESTENABLE, FALSE);
STATEMANAGER.SaveRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MINFILTER, D3DTEXF_LINEAR);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MAGFILTER, D3DTEXF_LINEAR);
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MIPFILTER, D3DTEXF_NONE);
STATEMANAGER.SaveTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
TPDTVertex vertices[4];
const float x[4] = { 0.0f, width, 0.0f, width };
const float y[4] = { 0.0f, 0.0f, height, height };
for (int i = 0; i < 4; ++i)
{
vertices[i].position = TPosition(x[i], y[i], 0.0f);
vertices[i].texCoord = TTextureCoordinate(x[i] / width, y[i] / height);
vertices[i].diffuse = 0xffffffff;
}
STATEMANAGER.SetTexture(0, texture->GetD3DTexture());
STATEMANAGER.SetTexture(1, nullptr);
STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE);
STATEMANAGER.DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, vertices, sizeof(TPDTVertex));
STATEMANAGER.RestoreTextureStageState(1, D3DTSS_COLOROP);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MIPFILTER);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MAGFILTER);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MINFILTER);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_ALPHAOP);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLOROP);
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLORARG1);
STATEMANAGER.RestoreRenderState(D3DRS_CULLMODE);
STATEMANAGER.RestoreRenderState(D3DRS_ALPHATESTENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_ALPHABLENDENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_FOGENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_LIGHTING);
STATEMANAGER.RestoreRenderState(D3DRS_ZWRITEENABLE);
STATEMANAGER.RestoreRenderState(D3DRS_ZENABLE);
STATEMANAGER.RestoreTransform(D3DTS_WORLD);
python_graphic.PopState();
}
void CRenderTarget::UpdateModel()
{
if (!IsReady())
return;
if (m_pmodelRotationActive && m_bShowModel)
{
if (m_modelRotation < 360.0f)
m_modelRotation += 1.0f / 60 * 60.0f;
else
m_modelRotation = 0.0f / 60 * 60.0f;
m_pModel->SetRotation(m_modelRotation);
}
else
m_pModel->SetRotation(0.0f);
m_pModel->Transform();
if(m_pmodelRotationActive)
m_pModel->GetGraphicThingInstanceRef().RotationProcess();
}
void CRenderTarget::DeformModel()
{
if (!IsReady())
return;
// PORT: stands in for AVA2's SetAlwaysRender. The model sits at the world origin, so the culling pass hides it.
m_pModel->GetGraphicThingInstanceRef().Show();
m_pModel->Deform();
}
void CRenderTarget::RenderModel()
{
if (!IsReady())
return;
auto& python_graphic = CPythonGraphic::Instance();
auto& camera_manager = CCameraManager::instance();
auto& state_manager = CStateManager::Instance();
auto& rectRender = *m_renderTargetTexture->GetRenderingRect();
const auto width = static_cast<float>(rectRender.right - rectRender.left);
const auto height = static_cast<float>(rectRender.bottom - rectRender.top);
// PORT: nothing to draw before the window has given its rect.
if (width <= 0.0f || height <= 0.0f)
return;
m_renderTargetTexture->SetRenderTarget();
m_renderTargetTexture->Clear();
python_graphic.SetViewport(0.0f, 0.0f, width, height);
RenderBackground();
python_graphic.ClearDepthBuffer();
const auto fov = python_graphic.GetFOV();
const auto aspect = python_graphic.GetAspect();
float near_y, far_y;
python_graphic.GetClipPlane(&near_y, &far_y);
state_manager.SetRenderState(D3DRS_FOGENABLE, FALSE);
python_graphic.PushState();
camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA);
camera_manager.GetCurrentCamera()->SetViewParams(m_v3Eye, m_v3Target, m_v3Up);
camera_manager.GetCurrentCamera()->RotateEyeAroundTarget(m_v3AngularVelocity.z, m_v3AngularVelocity.x);
python_graphic.UpdateViewMatrix();
python_graphic.SetPerspective(10.0f, width / height, 100.0f, 15000.0f);
if (m_bShowModel)
{
m_pModel->GetGraphicThingInstanceRef().Show();
m_pModel->Update();
m_pModel->Render();
}
// PORT: AVA2's RenderAllAttachingEffectWiki (attached effects) is not ported.
camera_manager.ResetToPreviousCamera();
python_graphic.RestoreViewport();
python_graphic.PopState();
python_graphic.SetPerspective(fov, aspect, near_y, far_y);
m_renderTargetTexture->ResetRenderTarget();
state_manager.SetRenderState(D3DRS_FOGENABLE, TRUE);
}
void CRenderTarget::SetModelV3Eye(float x, float y, float z)
{
m_v3Eye.x = x;
m_v3Eye.y = y;
m_v3Eye.z = z;
}
void CRenderTarget::SetModelV3Target(float x, float y, float z)
{
m_v3Target.x = x;
m_v3Target.y = y;
m_v3Target.z = z;
}
void CRenderTarget::RotateEyeAroundTarget(float fPitchDegree, float fRollDegree)
{
m_v3AngularVelocity.x = fRollDegree;
m_v3AngularVelocity.z = fPitchDegree;
}
void CRenderTarget::Zoom(float fRatio)
{
if (fRatio == 1.0f)
return;
D3DXVECTOR3 v3Temp = m_v3Eye - m_v3Target;
v3Temp *= fRatio;
m_v3Eye = v3Temp + m_v3Target;
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// PORT: AVA2 CRenderTarget (EterLib/CRenderTarget.h): one model drawn into its own colour render target, which
// a UI window then shows. It lives here, not in EterLib, because it owns a CInstanceBase.
#include <memory>
#include "../EterLib/GrpRenderTargetTexture.h"
class CInstanceBase;
class CGraphicImageInstance;
class CRenderTarget
{
public:
CRenderTarget(DWORD width, DWORD height);
~CRenderTarget();
void SetVisibility(bool isShow){ m_visible = isShow; }
void SetRotation(bool bRot) { m_pmodelRotationActive = bRot; }
bool IsReady() { return (m_visible && m_pModel); }
std::unique_ptr<CInstanceBase>& GetModel() { return m_pModel; }
void RotateEyeAroundTarget(float fPitchDegree, float fRollDegree);
void Zoom(float fRatio);
void SelectModel(int index, bool isFromScale= false);
void ResetModel();
void UpdateModel();
void DeformModel();
void RenderModel();
void SetRenderingRect(RECT* rect) const;
void CreateTextures() const;
void ReleaseTextures() const;
void RenderTexture() const;
bool CreateBackground(const char* imgPath, DWORD width, DWORD height);
void RenderBackground() const;
void SetModelV3Eye(float x, float y, float z);
void SetModelV3Target(float x, float y, float z);
void SetModelStatus(bool bStatus) { m_bShowModel = bStatus; }
float GetScale() { return m_bScale; }
void SetScale(float fScale);
bool IsShow() { return m_visible; }
private:
std::unique_ptr<CInstanceBase> m_pModel;
std::unique_ptr<CGraphicImageInstance> m_background;
std::unique_ptr<CGraphicRenderTargetTexture> m_renderTargetTexture;
float m_modelRotation;
bool m_pmodelRotationActive;
bool m_visible;
bool m_bShowModel;
float m_bScale;
D3DXVECTOR3 m_v3AngularVelocity;
D3DXVECTOR3 m_v3Eye;
D3DXVECTOR3 m_v3Target;
D3DXVECTOR3 m_v3Up;
};
@@ -0,0 +1,74 @@
#include "StdAfx.h"
#include "CRenderTargetManager.h"
#include "../EterBase/Stl.h"
#include <memory>
CRenderTargetManager::CRenderTargetManager(){}
CRenderTargetManager::~CRenderTargetManager()
{
Destroy();
}
void CRenderTargetManager::Destroy()
{
m_renderTargets.clear();
}
void CRenderTargetManager::DeformModels()
{
for (const auto& elem : m_renderTargets)
elem.second->DeformModel();
}
void CRenderTargetManager::UpdateModels()
{
for (auto& elem : m_renderTargets)
elem.second->UpdateModel();
}
// PORT: the background is drawn into the target by RenderModel (CRenderTarget::RenderBackground).
void CRenderTargetManager::RenderBackgrounds()
{
}
void CRenderTargetManager::RenderModels()
{
for (const auto& elem : m_renderTargets)
elem.second->RenderModel();
}
void CRenderTargetManager::CreateRenderTargetTextures()
{
for (const auto& elem : m_renderTargets)
elem.second->CreateTextures();
}
void CRenderTargetManager::ReleaseRenderTargetTextures()
{
for (const auto& elem : m_renderTargets)
elem.second->ReleaseTextures();
}
bool CRenderTargetManager::CreateRenderTarget(const int index, const int width, const int height)
{
// PORT: the size check guards the recording device, which allocates the target's bytes up front.
if (index < 1 || GetRenderTarget(index) || width < 1 || height < 1 || width > 2048 || height > 2048)
return false;
try
{
m_renderTargets.emplace(index, std::make_shared<CRenderTarget>(width, height));
}
catch (const std::exception&)
{
return false;
}
return true;
}
void CRenderTargetManager::RemoveRenderTarget(const int index)
{
const auto it = m_renderTargets.find(index);
if (it != m_renderTargets.end())
{
it->second->ResetModel();
m_renderTargets.erase(index);
}
}
std::shared_ptr<CRenderTarget> CRenderTargetManager::GetRenderTarget(const int index)
{
const auto it = m_renderTargets.find(index);
if (it != m_renderTargets.end())
return it->second;
return nullptr;
}
@@ -0,0 +1,31 @@
#pragma once
// PORT: AVA2 CRenderTargetManager (EterLib/CRenderTargetManager.h).
#include <unordered_map>
#include <cstdint>
#include "../EterBase/Singleton.h"
#include <memory>
#include "CRenderTarget.h"
class CRenderTargetManager : public CSingleton<CRenderTargetManager>
{
public:
CRenderTargetManager();
virtual ~CRenderTargetManager();
std::shared_ptr<CRenderTarget> GetRenderTarget(int index);
bool CreateRenderTarget(int index, int width, int height);
void CreateRenderTargetTextures();
void ReleaseRenderTargetTextures();
void RemoveRenderTarget(const int index);
void Destroy();
void DeformModels();
void UpdateModels();
void RenderBackgrounds();
void RenderModels();
protected:
std::unordered_map<int, std::shared_ptr<CRenderTarget>> m_renderTargets;
};
@@ -0,0 +1,282 @@
// PORT: AVA2 CPythonRenderTargetModule (module renderTarget), the subset the model preview uses. AVA2's window
// manager created the target for a registered window and drew it; here Create and RenderTexture do that, called
// from the script's own window (ScriptLib/ModelPreviewScript.inc).
#include "StdAfx.h"
#include "CRenderTargetManager.h"
#include "InstanceBase.h"
#include "../GameLib/ItemManager.h"
namespace
{
// The default of CPythonApplication's camera zoom speed (PythonApplicationCamera.cpp), which AVA2 read.
const float c_fRenderTargetZoomSpeed = 0.05f;
std::shared_ptr<CRenderTarget> Target(PyObject* poArgs)
{
int iIndex;
if (!PyTuple_GetInteger(poArgs, 0, &iIndex))
return nullptr;
return CRenderTargetManager::Instance().GetRenderTarget(iIndex);
}
bool Integer(PyObject* poArgs, int pos, int* value)
{
return PyTuple_GetInteger(poArgs, pos, value);
}
}
PyObject* renderTargetCreate(PyObject* poSelf, PyObject* poArgs)
{
int iIndex, iWidth, iHeight;
if (!Integer(poArgs, 0, &iIndex) || !Integer(poArgs, 1, &iWidth) || !Integer(poArgs, 2, &iHeight))
return Py_BadArgument();
return Py_BuildValue("i", CRenderTargetManager::Instance().CreateRenderTarget(iIndex, iWidth, iHeight) ? 1 : 0);
}
PyObject* renderTargetRemove(PyObject* poSelf, PyObject* poArgs)
{
int iIndex;
if (!Integer(poArgs, 0, &iIndex))
return Py_BadArgument();
CRenderTargetManager::Instance().RemoveRenderTarget(iIndex);
return Py_BuildNone();
}
PyObject* renderTargetExists(PyObject* poSelf, PyObject* poArgs)
{
int iIndex;
if (!Integer(poArgs, 0, &iIndex))
return Py_BadArgument();
return Py_BuildValue("i", CRenderTargetManager::Instance().GetRenderTarget(iIndex) ? 1 : 0);
}
PyObject* renderTargetRenderTexture(PyObject* poSelf, PyObject* poArgs)
{
int l, t, r, b;
if (!Integer(poArgs, 1, &l) || !Integer(poArgs, 2, &t) || !Integer(poArgs, 3, &r) || !Integer(poArgs, 4, &b))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsShow())
{
RECT rect = { l, t, r, b };
target->SetRenderingRect(&rect);
target->RenderTexture();
}
return Py_BuildNone();
}
PyObject* renderTargetSelectModel(PyObject* poSelf, PyObject* poArgs)
{
int modelIndex;
if (!Integer(poArgs, 1, &modelIndex))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SelectModel(modelIndex);
return Py_BuildValue("i", target && target->GetModel() ? 1 : 0);
}
PyObject* renderTargetSetArmor(PyObject* poSelf, PyObject* poArgs)
{
int iItemVnum;
if (!Integer(poArgs, 1, &iItemVnum))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->GetModel()->ChangeArmor(iItemVnum);
return Py_BuildNone();
}
PyObject* renderTargetSetWeapon(PyObject* poSelf, PyObject* poArgs)
{
int iItemVnum;
if (!Integer(poArgs, 1, &iItemVnum))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->GetModel()->ChangeWeapon(iItemVnum);
return Py_BuildNone();
}
PyObject* renderTargetSetHair(PyObject* poSelf, PyObject* poArgs)
{
int iItemVnum;
if (!Integer(poArgs, 1, &iItemVnum))
return Py_BadArgument();
bool isValue = false;
const auto target = Target(poArgs);
if (target)
{
PyTuple_GetBoolean(poArgs, 2, &isValue);
if (!isValue)
{
CItemData* pItemData;
if (!CItemManager::Instance().GetItemDataPointer(iItemVnum, &pItemData))
return Py_BuildNone();
iItemVnum = pItemData->GetValue(3);
}
if (target->IsReady())
target->GetModel()->SetHair(iItemVnum);
}
return Py_BuildNone();
}
PyObject* renderTargetSetMount(PyObject* poSelf, PyObject* poArgs)
{
int iRace;
if (!Integer(poArgs, 1, &iRace))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->GetModel()->MountHorse(iRace);
return Py_BuildNone();
}
PyObject* renderTargetSetRotation(PyObject* poSelf, PyObject* poArgs)
{
bool bRot;
if (!PyTuple_GetBoolean(poArgs, 1, &bRot))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetRotation(bRot);
return Py_BuildNone();
}
PyObject* renderTargetSetVisibility(PyObject* poSelf, PyObject* poArgs)
{
bool isShow;
if (!PyTuple_GetBoolean(poArgs, 1, &isShow))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetVisibility(isShow);
return Py_BuildNone();
}
PyObject* renderTargetSetBackground(PyObject* poSelf, PyObject* poArgs)
{
char* szPathName;
if (!PyTuple_GetString(poArgs, 1, &szPathName))
return Py_BadArgument();
const auto target = Target(poArgs);
// PORT: the background is stretched over the target when drawn, so the sizes AVA2 passed are unused.
return Py_BuildValue("i", target && target->CreateBackground(szPathName, 0, 0) ? 1 : 0);
}
PyObject* renderTargetResetModel(PyObject* poSelf, PyObject* poArgs)
{
const auto target = Target(poArgs);
if (target)
target->ResetModel();
return Py_BuildNone();
}
PyObject* renderTargetDestroy(PyObject* poSelf, PyObject* poArgs)
{
CRenderTargetManager::Instance().Destroy();
return Py_BuildNone();
}
PyObject* renderTargetSetModelV3Eye(PyObject* poSelf, PyObject* poArgs)
{
float x, y, z;
if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetModelV3Eye(x, y, z);
return Py_BuildNone();
}
PyObject* renderTargetSetModelV3Target(PyObject* poSelf, PyObject* poArgs)
{
float x, y, z;
if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetModelV3Target(x, y, z);
return Py_BuildNone();
}
PyObject* renderTargetZoom(PyObject* poSelf, PyObject* poArgs)
{
int iDirection;
if (!Integer(poArgs, 1, &iDirection))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->Zoom(1.0f + c_fRenderTargetZoomSpeed * float(iDirection));
return Py_BuildNone();
}
PyObject* renderTargetRotateEyeAroundTarget(PyObject* poSelf, PyObject* poArgs)
{
float fPitch, fRoll;
if (!PyTuple_GetFloat(poArgs, 1, &fPitch) || !PyTuple_GetFloat(poArgs, 2, &fRoll))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
target->RotateEyeAroundTarget(fPitch, fRoll);
return Py_BuildNone();
}
PyObject* renderTargetSetScale(PyObject* poSelf, PyObject* poArgs)
{
float fScale;
if (!PyTuple_GetFloat(poArgs, 1, &fScale))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target)
target->SetScale(fScale);
return Py_BuildNone();
}
PyObject* renderTargetIsShow(PyObject* poSelf, PyObject* poArgs)
{
const auto target = Target(poArgs);
return Py_BuildValue("i", target ? target->IsShow() : 0);
}
PyObject* renderTargetSetMotionIndex(PyObject* poSelf, PyObject* poArgs)
{
int iMotionIndex;
if (!Integer(poArgs, 1, &iMotionIndex))
return Py_BadArgument();
const auto target = Target(poArgs);
if (target && target->IsReady())
return Py_BuildValue("i", target->GetModel()->GetGraphicThingInstanceRef().InterceptOnceMotion(iMotionIndex) ? 1 : 0);
return Py_BuildValue("i", 0);
}
void initRenderTarget()
{
static PyMethodDef s_methods[] =
{
{ "Create", renderTargetCreate, METH_VARARGS },
{ "Remove", renderTargetRemove, METH_VARARGS },
{ "Exists", renderTargetExists, METH_VARARGS },
{ "RenderTexture", renderTargetRenderTexture, METH_VARARGS },
{ "SelectModel", renderTargetSelectModel, METH_VARARGS },
{ "SetVisibility", renderTargetSetVisibility, METH_VARARGS },
{ "SetBackground", renderTargetSetBackground, METH_VARARGS },
{ "SetArmor", renderTargetSetArmor, METH_VARARGS },
{ "SetWeapon", renderTargetSetWeapon, METH_VARARGS },
{ "SetHair", renderTargetSetHair, METH_VARARGS },
{ "SetMount", renderTargetSetMount, METH_VARARGS },
{ "SetRotation", renderTargetSetRotation, METH_VARARGS },
{ "ResetModel", renderTargetResetModel, METH_VARARGS },
{ "Destroy", renderTargetDestroy, METH_VARARGS },
{ "SetModelV3Eye", renderTargetSetModelV3Eye, METH_VARARGS },
{ "SetModelV3Target", renderTargetSetModelV3Target, METH_VARARGS },
{ "Zoom", renderTargetZoom, METH_VARARGS },
{ "RotateEyeAroundTarget", renderTargetRotateEyeAroundTarget, METH_VARARGS },
{ "SetScale", renderTargetSetScale, METH_VARARGS },
{ "IsShow", renderTargetIsShow, METH_VARARGS },
{ "SetMotionIndex", renderTargetSetMotionIndex, METH_VARARGS },
{ NULL, NULL, 0 },
};
Py_InitModule("renderTarget", s_methods);
}
+1
View File
@@ -70,6 +70,7 @@ void initMessenger();
void initsafebox(); void initsafebox();
void initguild(); void initguild();
void initServerStateChecker(); void initServerStateChecker();
void initRenderTarget(); // PORT: AVA2 CPythonRenderTargetModule
extern const std::string& ApplicationStringTable_GetString(DWORD dwID); extern const std::string& ApplicationStringTable_GetString(DWORD dwID);
extern const std::string& ApplicationStringTable_GetString(DWORD dwID, LPCSTR szKey); extern const std::string& ApplicationStringTable_GetString(DWORD dwID, LPCSTR szKey);
+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. // 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 // 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 // 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/StdAfx.h"
#include "GameLib/ItemData.h" #include "GameLib/ItemData.h"
#include "UserInterface/StdAfx.h" #include "UserInterface/StdAfx.h"
@@ -25,6 +27,7 @@ namespace {
using Item = CItemData::TItemTable; using Item = CItemData::TItemTable;
using Mob = CPythonNonPlayer::TMobTable; using Mob = CPythonNonPlayer::TMobTable;
std::map<DWORD, std::array<long, 11>> stones; 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> item_key{{173217, 72619434, 408587239, 27973291}};
constexpr std::array<DWORD, 4> mob_key{{4813894, 18955, 552631, 6822045}}; 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]; 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]; 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); const auto found = limits.find(record.dwVnum);
if (found == limits.end()) return; if (found == limits.end()) return;
std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits)); 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 <> template <>
bool limits_field<Item>(std::size_t offset, DWORD vnum, const std::map<DWORD, Limits>& limits) { 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) || 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, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) ||
(offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) || (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 } // namespace
int main(int argc, char** argv) { 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" 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; return 2;
} }
try { try {
static CLZO lzo; 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]); std::ifstream stream(argv[8]);
if (!stream) throw std::runtime_error("cannot read stone overlay"); if (!stream) throw std::runtime_error("cannot read stone overlay");
std::string line; 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()
+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()
+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()
+18
View File
@@ -0,0 +1,18 @@
#!/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. Usage: install_items.py <server root> <items-server.tsv> <fresh backup dir>
"""
import shutil,sys
from pathlib import Path
server,rows,backup=map(Path,sys.argv[1:4])
if backup.exists():raise SystemExit('use a fresh backup directory')
items={int(l.split(b'\t',1)[0]):l for l in rows.read_bytes().splitlines() if l}
backup.mkdir(parents=True)
conf=server/'share/conf'
for filename,row in (('item_proto.txt',lambda v:items[v]),('item_names.txt',lambda v:str(v).encode()+b'\t'+items[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 items)]
tmp=path.with_name(filename+'.tmp');tmp.write_bytes(newline.join(kept+[row(v) for v in sorted(items)])+newline);tmp.replace(path)
print('AVA2 items installed:',len(items),'backup:',backup)
+147
View File
@@ -0,0 +1,147 @@
#!/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 survive; items of AVA2-only systems (pets, pendants,
new rings, mounts, sashes, costume enchants, talisman bonus items, new skill books) become plain ITEM_NONE
items until those systems are ported. 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,'特效强化石'))
# Kept with their 40250 meaning; everything else becomes ITEM_NONE (see module doc).
KEEP_TYPES={'ITEM_MATERIAL','ITEM_QUEST','ITEM_GIFTBOX','ITEM_NONE'}
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')}
DESC={'ITEM_MATERIAL':'制作与升级材料。','ITEM_QUEST':'任务物品。','ITEM_GIFTBOX':'打开后可获得物品。','ITEM_WEAPON':'','ITEM_ARMOR':''}
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
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
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') 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 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
(output/'items-server.tsv').write_bytes(b'\n'.join(server)+b'\n')
(output/'items-client.tsv').write_bytes(b'\n'.join(client)+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 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.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()}))
+212
View File
@@ -0,0 +1,212 @@
# 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
21210 暗龙铠甲+0
21230 风龙铠甲+0
21250 魔龙铠甲+0
21270 神龙铠甲+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 21210 暗龙铠甲+0
25 21230 风龙铠甲+0
26 21250 魔龙铠甲+0
27 21270 神龙铠甲+0
28 25163 缩小的头颅
29 25164 蜘蛛钥匙
30 25165 螺旋钥匙
31 25166 缩小的头颅+
32 25167 拉扎多尔入场券
33 25168 奈梅尔入场券
34 25169 九头蛇护身符
35 28444 破碑之石+4
36 28445 克首领之石+4
37 30400 盖亚宝石
38 30600 图样
39 30601 铠甲设计图
40 30602 蓝色染料
41 30603 红色染料
42 30604 灰色染料
43 30605 金板
44 30606 银板
45 30607 铜板
46 30608 钢板
47 30609 青铜板
48 30610 秘银
49 30611 坚韧绑带
50 30612 结实绳索
51 30614 黑色染料
52 30615 白色染料
53 30616 二氧化钛
54 30617 玛瑙
55 30618 月长石
56 30620 红龙之角
57 30621 红龙鳞片
58 30623 智慧之书
59 30624 魔法宝珠
60 30625 帕德玛刚玉
61 30626 蓝晶石
62 30627 神之果实
63 30628 神圣之花
64 30629 众神灵药
65 30630 龙之翼
66 33031 吊坠宝石
67 39070 灰色碎片
68 39071 白色碎片
69 39072 绿色碎片
70 39073 黄色碎片
71 50275 金色欧凯宝箱
72 50276 银色欧凯宝箱
73 50277 铜色欧凯宝箱
74 50569 秘籍·地震
75 50570 秘籍·星光
76 50571 秘籍·风暴射击
77 50572 秘籍·地狱打击
78 50573 秘籍·致命波动
79 50574 秘籍·流星
80 50575 秘籍·灵气护盾
81 50928 金王战利品
82 50929 银王战利品
83 50930 铜王战利品
84 51110 阿拉多尔宝箱
85 51111 奈梅尔宝箱
86 51112 贝兰塞陶宝箱
87 51115 远古守护者宝箱
88 51116 红龙宝箱
89 51117 远古巨龙宝箱
90 53004 鲁弗斯封印(永久)
91 53996 护符附魔道具
92 53997 护符加持道具
93 54705 九头蛇宝箱
94 54706 65级武器宝箱
95 54707 70级武器宝箱
96 54708 75级武器宝箱
97 54709 防具宝箱
98 54710 装备宝箱
99 54711 饰品宝箱
100 54712 生物学家礼包
101 54713 生物学家袋子
102 54714 生物学家袋子+
103 54715 强化药剂包(小)
104 54716 强化药剂包(中)
105 54717 强化药剂包(特大)
106 55001 宠物蛋白补充剂
107 55004 宠物秘籍·幼年
108 55005 宠物秘籍·野性
109 55006 宠物秘籍·勇敢
110 55009 宠物秘籍宝箱
111 55010 宠物书·石碑
112 55011 宠物书·怪物
113 55012 宠物书·首领
114 55013 宠物书·力量
115 55014 宠物书·智力
116 55015 宠物书·敏捷
117 55016 宠物书·体力
118 55017 宠物书·野兽
119 55018 宠物书·兽人
120 55019 宠物书·秘法
121 55020 宠物书·不死
122 55021 宠物书·恶魔
123 55022 宠物书·经验
124 55023 宠物书·掉宝
125 55024 宠物书·金币
126 55025 宠物书·半兽人
127 55026 宠物书·抗半兽人
128 55027 宠物书·技能伤害
129 55028 宠物书·抗技能伤害
130 55029 宠物书·平均伤害
131 55030 宠物书·抗平均伤害
132 55031 宠物书·抗暴击
133 55033 宠物技能清除剂
134 55035 宠物经验增幅剂
135 55036 宠物栏位解锁器
136 55038 宠物书·抗穿透
137 55039 宠物书·施法速度
138 55040 宠物书·格挡
139 55041 宠物书·抗毒
140 55042 宠物书·冰
141 55043 宠物书·土
142 55044 宠物书·暗
143 55101 活力药剂(小)
144 55102 活力药剂(中)
145 55103 活力药剂(大)
146 55104 活力药剂(特大)
147 55108 战斗药剂(小)
148 55109 战斗药剂(中)
149 55110 战斗药剂(大)
150 55111 战斗药剂(特大)
151 55115 力量药剂(小)
152 55116 力量药剂(中)
153 55117 力量药剂(大)
154 55118 力量药剂(特大)
155 55401 猴子蛋
156 55404 奈梅尔蛋
157 55407 阿兹雷尔蛋
158 71097 炼金附魔道具
159 71234 蓝色巴里
160 71254 白色冬日羊驼
161 71301 护符加持道具
162 71302 护符强化道具
163 71502 狮子封印(永久)
164 71505 野猪封印(永久)
165 71508 狼封印(永久)
166 71511 老虎封印(永久)
167 72064 小型扩充券
168 72065 中型扩充券
169 72066 大型扩充券
170 72067 强效扩充券
171 72068 天界扩充券
172 72319 金钥匙
173 72320 银钥匙
174 72346 6-7属性附魔道具
175 77900 药剂宝箱(小)
176 77901 药剂宝箱(中)
177 77902 药剂宝箱(大)
178 77903 药剂宝箱(特大)
179 77904 阅读宝箱
180 77905 护符宝箱
181 77906 耳环宝箱
182 77907 手镯宝箱
183 77908 项链宝箱
184 77909 戒指宝箱
185 77910 强化宝箱(小)
186 77911 强化宝箱(中)
187 77912 强化宝箱(大)
188 77913 炼金宝箱(小)
189 77914 炼金宝箱(中)
190 77915 炼金宝箱(大)
191 77916 战令宝箱
192 77917 任务书宝箱
193 77918 青铜宝箱
194 77919 白银宝箱
195 77920 黄金宝箱
196 77921 青铜钥匙
197 77922 白银钥匙
198 77923 黄金钥匙
199 77924 钥匙宝箱
200 80019 精致布料
201 80025 时装兑换券
202 80030 男爵夫人的宝箱
203 85004 领主披风(个人)
204 85008 大师披风(个人)
205 85014 王朝披风(个人)
206 85018 皇家披风(个人)
207 96001 火之护符碎片
208 96002 冰之护符碎片
209 96003 土之护符碎片
210 96004 暗之护符碎片
211 96005 风之护符碎片
212 96006 雷之护符碎片
@@ -0,0 +1,35 @@
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_NONE','0'])
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_is_locked(self):
s,c,kept=m.convert(row(54001,'ITEM_USE','USE_CHANGE_ATTRIBUTE',flags='ITEM_STACKABLE',values=('23','10','2','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_names_cover_ranges(self):
n=m.names();self.assertEqual(len(n),478);self.assertEqual(n[54100],'武器外观强化石')
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 | | +6 镶嵌石(28700~28713,全部 14 种,宝石页) | 各 1 |
| 移动戒指(不限次数、无冷却,见 `../ring-warp`) | 1 | | 移动戒指(不限次数、无冷却,见 `../ring-warp`) | 1 |
| 追加物品属性秘籍、龙神的祝福书 | 各 100 万 | | 追加物品属性秘籍、龙神的祝福书 | 各 100 万 |
| VIP 会员卡 7 天 / 30 天(7 项会员特权全部延长,见 `../vip-card`) | 500 万 / 1800 万 |
## 坐骑(mount) ## 坐骑(mount)
@@ -43,6 +43,8 @@
71032 1 1000000 龙神的祝福书 71032 1 1000000 龙神的祝福书
70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp) 70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp)
50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set) 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 超级属性转换秘籍 71052 1 1 超级属性转换秘籍
71051 1 1 超级属性追加秘籍 71051 1 1 超级属性追加秘籍
[宝石] [宝石]
1 # [tab name] starts a shop page (shop_table_ex.txt, at most 3 pages of 39); then vnum count price name (duration: unique items count worn play minutes, 仓库扩张卷 real time)
43 71032 1 1000000 龙神的祝福书
44 70058 1 1 移动戒指 不限次数、无冷却(../../ring-warp)
45 50307 1 0 小狩猎套装礼包 1级+9全套、满属性(../../hunt-set)
46 38001 1 5000000 VIP会员卡 7天,7项会员特权全部延长(../../vip-card)
47 38002 1 18000000 VIP会员卡 30天(../../vip-card)
48 71052 1 1 超级属性转换秘籍
49 71051 1 1 超级属性追加秘籍
50 [宝石]
+1
View File
@@ -106,6 +106,7 @@ data)
sh "$HERE/ring-warp/install.sh" sh "$HERE/ring-warp/install.sh"
sh "$HERE/support-skills/install.sh" sh "$HERE/support-skills/install.sh"
sh "$HERE/hunt-set/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/item-mall"
sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/mount" sh "$HERE/npc-shops/install.sh" "$HERE/npc-shops/mount"
start_server start_server
+42
View File
@@ -0,0 +1,42 @@
# VIP 会员卡(203 服务端)
服务端原有 7 种会员特权(`account.account` 的 `*_expire` 列,登录时读入,左上角图标 affect 500~506):
| 序号 | 特权 | 列 |
|---|---|---|
| 0 | 经验加成 | `silver_expire` |
| 1 | 物品掉落翻倍 | `gold_expire` |
| 2 | 仓库扩展到 3 页 | `safebox_expire` |
| 3 | 金币直接进背包 | `autoloot_expire` |
| 4 | 钓鱼成功率提升 | `fish_mind_expire` |
| 5 | 结婚亲密度加快 | `marriage_fast_expire` |
| 6 | 金币掉落翻倍 | `money_drop_rate_expire` |
40250 里没有游戏内开通的途径。VIP 卡用 40250 没用到的两个物品:
| 物品 | 名称 | 时长 | 百店老板娘「道具」页价格 |
|---|---|---|---|
| 38001 | VIP会员卡(7天) | 7 天 | 500 万金币 |
| 38002 | VIP会员卡(30天) | 30 天 | 1800 万金币 |
价格在 `../npc-shops/item-mall/items.tsv`,改完重跑 npc-shops 的 install.sh 即可。
右键使用:7 项特权同时从「现在」和「当前到期时间」中较晚的那个往后延长,立即生效(图标、自动拾取、仓库页数)。
剩余时间加上卡的时长超过 400 天时拒绝,卡不扣。两次使用至少间隔 1 秒。客户端小地图旁的「VIP」按钮打开面板,
显示每项剩余时间。协议见 metin2-server `docs/FEATURE-VIP.md`。
## 经验加成 +100%
newcibn 的会员经验在 `char_battle.cpp` 里固定 ×1.2,之后再乘 `GetMobExpRate` 返回的事件标记
`mob_exp_buyer`(默认 100)。`/e mob_exp_buyer 167` 后 1.2 × 1.67 ≈ 2.0,即 +100%。事件标记存在
`player.quest`(pid 0),所有核心共享,部署时用 GM 角色设一次即可,重启后仍在。
## 文件
- `install.sh`:`item_proto.txt` 里 38001/38002 改为占 1 格、可堆叠、记日志、不能卖给 NPC(40250 原来是
size 0,两张卡会叠在同一格、无法移动),服务端改名(`item_names.txt` + `item_proto.locale_name`,GB2312)。
需要重启 db 和 game。备份在 `/root/backup-vip-card/<时间>/`。
- 游戏端代码在 metin2-server(`port_premium.h`,分支 feature/vip-card)。
客户端:名称 `data/zh_40250/item_names_zh_overlay.txt`;占格和堆叠 `item_shapes_server.tsv`(由
`locale_proto_patch` 写进客户端 item_proto,与服务端一致),由 `scripts/build_zh_locale.py` 打进 locale_zh。
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Installs the VIP cards (README.md): run on the server from this directory with a game built from
# metin2-server feature/vip-card (port_premium.h), then restart db + game. 40250 ships 38001/38002 as unused
# ITEM_NONE rows of size 0, which the inventory cannot hold (two of them share a cell, neither can move): this
# gives them one cell, stacking, an item log and no NPC sale, and names them on the server (item_names.txt and
# item_proto.locale_name; the client has its own names and the same size/flags, data/zh_40250).
set -eu
SERVER=${SERVER:-/usr/metin2/server}
PROTO="$SERVER/share/conf/item_proto.txt"
NAMES="$SERVER/share/conf/item_names.txt"
BACKUP=/root/backup-vip-card/$(date +%Y%m%d-%H%M%S)
export LC_ALL=C
mkdir -p "$BACKUP"
cp -p "$PROTO" "$NAMES" "$BACKUP/"
mysqldump -uroot --no-create-info --replace player item_proto --where="vnum in (38001,38002)" > "$BACKUP/item_proto-rows.sql"
echo "backup: $BACKUP"
# item_proto.txt: column 5 size, 6 anti flags, 7 flags.
awk -F'\t' -v OFS='\t' '$1 == 38001 || $1 == 38002 { $5 = 1; $6 = "ANTI_SELL"; $7 = "ITEM_STACKABLE | LOG"; n++ } { print }
END { if (n != 2) { print "item_proto.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \
"$PROTO" > "$BACKUP/item_proto.new"
cat "$BACKUP/item_proto.new" > "$PROTO"
week=$(printf 'VIP会员卡(7天)' | iconv -f UTF-8 -t GB2312)
month=$(printf 'VIP会员卡(30天)' | iconv -f UTF-8 -t GB2312)
awk -F'\t' -v OFS='\t' -v week="$week" -v month="$month" '$1 == 38001 { $2 = week; n++ } $1 == 38002 { $2 = month; n++ } { print }
END { if (n != 2) { print "item_names.txt: " n " of 2 cards found" > "/dev/stderr"; exit 1 } }' \
"$NAMES" > "$BACKUP/item_names.new"
cat "$BACKUP/item_names.new" > "$NAMES"
# ITEM_STACKABLE | ITEM_LOG = 4 | 8192, ITEM_ANTIFLAG_SELL = 256.
mysql -uroot player -e "UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$week' WHERE vnum = 38001;
UPDATE item_proto SET size = 1, flag = 8196, antiflag = 256, locale_name = '$month' WHERE vnum = 38002"
echo "done; restart db and game"