♻ 重写抽卡模拟器插件

This commit is contained in:
洛水居室 2022-10-15 21:06:42 +08:00 committed by GitHub
parent 242826121d
commit 6a2096d1ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 251 additions and 209 deletions

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import functools
from typing import Any, Generic, ItemsView, Iterator, KeysView, TypeVar
from typing import Any, Generic, ItemsView, Iterator, KeysView, TypeVar, Optional
import ujson as json
@ -21,6 +21,8 @@ __all__ = [
"honey_id_to_game_id",
"game_id_to_role_id",
"Data",
"weapon_to_game_id",
"avatar_to_game_id"
]
K = TypeVar("K")
@ -99,3 +101,15 @@ def game_id_to_role_id(gid: str) -> int | None:
return next(
(int(key.split("-")[0]) for key, value in AVATAR_DATA.items() if value["icon"].split("_")[-1] == gid), None
)
def weapon_to_game_id(name: str) -> Optional[int]:
return next(
(int(key) for key, value in WEAPON_DATA.items() if value['name'] == name), None
)
def avatar_to_game_id(name: str) -> Optional[int]:
return next(
(int(key) for key, value in AVATAR_DATA.items() if value['name'] == name), None
)

View File

@ -1,6 +1,7 @@
import asyncio
import re
import time
from datetime import datetime
from json import JSONDecodeError
from typing import List, Optional, Dict
@ -9,7 +10,6 @@ from genshin.utility.ds import generate_dynamic_secret
from genshin.utility.uid import recognize_genshin_server
from httpx import AsyncClient
from pydantic import BaseModel, validator
from datetime import datetime
from modules.apihelper.base import ArtworkImage, PostInfo
from modules.apihelper.helpers import get_device_id
@ -190,6 +190,9 @@ class GachaInfo:
self.cache[gacha_id] = req
return req
async def close(self):
await self.client.shutdown()
class SignIn:
LOGIN_URL = "https://webapi.account.mihoyo.com/Api/login_by_mobilecaptcha"

View File

@ -30,6 +30,9 @@ class GachaBanner:
15304,
]
# 硬编码三星武器
title: str = ""
html_title: str = ""
banner_id: str = ""
banner_type: BannerType = BannerType.STANDARD
wish_max_progress: int = 0
pool_balance_weights4: Tuple[int] = ((1, 255), (17, 255), (21, 10455))

View File

@ -1,19 +1,25 @@
import asyncio
import os
import re
from datetime import datetime
from typing import Dict
from typing import Optional, Union, Any, List
import ujson as json
from bs4 import BeautifulSoup
from pyppeteer import launch
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filters
from core.base.redisdb import RedisDB
from core.baseplugin import BasePlugin
from core.plugin import Plugin, handler
from core.template import TemplateService
from modules.apihelper.hyperion import GachaInfo
from plugins.genshin.gacha.wish import WishCountInfo, get_one
from metadata.genshin import weapon_to_game_id, avatar_to_game_id, WEAPON_DATA, AVATAR_DATA
from metadata.shortname import weaponToName
from modules.apihelper.hyperion import GachaInfo, GachaInfoObject
from modules.gacha.banner import BannerType, GachaBanner
from modules.gacha.player.info import PlayerGachaInfo
from modules.gacha.system import BannerSystem
from utils.bot import get_all_args
from utils.decorators.error import error_callable
from utils.decorators.restricts import restricts
@ -23,37 +29,126 @@ from utils.log import logger
class GachaNotFound(Exception):
"""卡池未找到"""
def __init__(self, gacha_name):
def __init__(self, gacha_name: str):
self.gacha_name = gacha_name
super().__init__(f"{gacha_name} gacha not found")
class GachaRedis:
def __init__(self, redis: RedisDB):
self.client = redis.client
self.qname = "plugin:gacha:"
async def get(self, user_id: int) -> PlayerGachaInfo:
data = await self.client.get(f"{self.qname}{user_id}")
if data is None:
return PlayerGachaInfo()
return PlayerGachaInfo(**json.loads(data))
async def set(self, user_id: int, player_gacha_info: PlayerGachaInfo):
value = player_gacha_info.json()
await self.client.set(f"{self.qname}{user_id}", value)
class GachaHandle:
def __init__(self, hyperion: Optional[GachaInfo] = None):
if hyperion is None:
self.hyperion = GachaInfo()
else:
self.hyperion = hyperion
async def de_banner(self, gacha_id: str, gacha_type: int) -> Optional[GachaBanner]:
gacha_info = await self.hyperion.get_gacha_info(gacha_id)
banner = GachaBanner()
banner.title, banner.html_title = self.de_title(gacha_info["title"])
for r5_up_items in gacha_info["r5_up_items"]:
if r5_up_items["item_type"] == "角色":
banner.rate_up_items5.append(avatar_to_game_id(r5_up_items["item_name"]))
elif r5_up_items["item_type"] == "武器":
banner.rate_up_items5.append(weapon_to_game_id(r5_up_items["item_name"]))
for r5_prob_list in gacha_info["r5_prob_list"]:
if r5_prob_list["item_type"] == "角色":
banner.fallback_items5_pool1.append(avatar_to_game_id(r5_prob_list["item_name"]))
elif r5_prob_list["item_type"] == "武器":
banner.fallback_items5_pool1.append(weapon_to_game_id(r5_prob_list["item_name"]))
for r4_up_items in gacha_info["r4_up_items"]:
if r4_up_items["item_type"] == "角色":
banner.rate_up_items4.append(avatar_to_game_id(r4_up_items["item_name"]))
elif r4_up_items["item_type"] == "武器":
banner.rate_up_items4.append(weapon_to_game_id(r4_up_items["item_name"]))
for r4_prob_list in gacha_info["r4_prob_list"]:
if r4_prob_list["item_type"] == "角色":
banner.fallback_items4_pool1.append(avatar_to_game_id(r4_prob_list["item_name"]))
elif r4_prob_list["item_type"] == "武器":
banner.fallback_items4_pool1.append(weapon_to_game_id(r4_prob_list["item_name"]))
if gacha_type in (310, 400):
banner.wish_max_progress = 1
banner.banner_type = BannerType.EVENT
banner.weight4 = ((1, 510), (8, 510), (10, 10000))
banner.weight5 = ((1, 60), (73, 60), (90, 10000))
elif gacha_type == 302:
banner.wish_max_progress = 3
banner.banner_type = BannerType.WEAPON
banner.weight4 = ((1, 600), (7, 600), (10, 10000))
banner.weight5 = ((1, 70), (62, 70), (90, 10000))
else:
banner.banner_type = BannerType.STANDARD
return banner
async def gacha_base_info(self, gacha_name: str = "角色活动", default: bool = False) -> GachaInfoObject:
gacha_list_info = await self.hyperion.get_gacha_list_info()
now = datetime.now()
for gacha in gacha_list_info:
if gacha.gacha_name == gacha_name and gacha.begin_time <= now <= gacha.end_time:
return gacha
else: # pylint: disable=W0120
if default and len(gacha_list_info) > 0:
return gacha_list_info[0]
else:
raise GachaNotFound(gacha_name)
@staticmethod
async def de_item_list(item_list: List[int]) -> List[dict]:
gacha_item: List[dict] = []
for item_id in item_list:
if 10000 <= item_id <= 100000:
gacha_item.append(WEAPON_DATA.get(str(item_id)))
if 10000000 <= item_id <= 19999999:
gacha_item.append(AVATAR_DATA.get(str(item_id)))
return gacha_item
@staticmethod
def de_title(title: str) -> Union[tuple[str, None], tuple[str, Any]]:
title_html = BeautifulSoup(title, "lxml")
re_color = re.search(r"<color=#(.*?)>", title, flags=0)
if re_color is None:
return title_html.text, None
color = re_color.group(1)
title_html.color.name = "span"
title_html.span["style"] = f"color:#{color};"
return title_html.text, title_html.p
class Gacha(Plugin, BasePlugin):
"""抽卡模拟器(非首模拟器/减寿模拟器)"""
def __init__(self, template_service: TemplateService = None):
self.gacha = GachaInfo()
def __init__(self, template_service: TemplateService = None, redis: RedisDB = None):
self.gacha_db = GachaRedis(redis)
self.handle = GachaHandle()
self.banner_system = BannerSystem()
self.template_service = template_service
self.browser: launch = None
self.current_dir = os.getcwd()
self.resources_dir = os.path.join(self.current_dir, "resources")
self.character_gacha_card = {}
self.user_time = {}
self.banner_cache = {}
self._look = asyncio.Lock()
async def gacha_info(self, gacha_name: str = "角色活动", default: bool = False):
gacha_list_info = await self.gacha.get_gacha_list_info()
gacha_id = ""
now = datetime.now()
for gacha in gacha_list_info:
if gacha.gacha_name == gacha_name and gacha.begin_time <= now <= gacha.end_time:
gacha_id = gacha.gacha_id
if gacha_id == "":
if default and len(gacha_list_info) > 0:
gacha_id = gacha_list_info[0].gacha_id
else:
raise GachaNotFound(gacha_name)
gacha_info = await self.gacha.get_gacha_info(gacha_id)
gacha_info["gacha_id"] = gacha_id
return gacha_info
async def get_banner(self, gacha_base_info: GachaInfoObject):
async with self._look:
banner = self.banner_cache.get(gacha_base_info.gacha_id)
if banner is None:
banner = await self.handle.de_banner(gacha_base_info.gacha_id, gacha_base_info.gacha_type)
self.banner_cache.setdefault(gacha_base_info.gacha_id, banner)
return banner
@handler(CommandHandler, command="gacha", block=False)
@handler(MessageHandler, filters=filters.Regex("^非首模拟器(.*)"), block=False)
@ -72,66 +167,92 @@ class Gacha(Plugin, BasePlugin):
gacha_name = value
break
try:
gacha_info = await self.gacha_info(gacha_name)
except GachaNotFound:
await message.reply_text(f"没有找到名为 {gacha_name} 的卡池")
gacha_base_info = await self.handle.gacha_base_info(gacha_name)
except GachaNotFound as exc:
await message.reply_text(f"没有找到名为 {exc.gacha_name} 的卡池")
return
else:
gacha_info = await self.gacha_info(default=True)
gacha_base_info = await self.handle.gacha_base_info(default=True)
logger.info(f"用户 {user.full_name}[{user.id}] 抽卡模拟器命令请求 || 参数 {gacha_name}")
# 用户数据储存和处理
gacha_id: str = gacha_info["gacha_id"]
user_gacha: Dict[str, WishCountInfo] = context.user_data.get("gacha")
if user_gacha is None:
user_gacha = context.user_data["gacha"] = {}
user_gacha_count: WishCountInfo = user_gacha.get(gacha_id)
if user_gacha_count is None:
user_gacha_count = user_gacha[gacha_id] = WishCountInfo(user_id=user.id)
# 用户数据储存和处理
title = gacha_info["title"]
re_color = re.search(r"<color=#(.*?)>", title, flags=0)
if re_color is None:
title_html = BeautifulSoup(title, "lxml")
pool_name = title_html.text
logger.warning(f"卡池信息 title 提取 color 失败 title[{title}]")
else:
color = re_color.group(1)
title_html = BeautifulSoup(title, "lxml")
title_html.color.name = "span"
title_html.span["style"] = f"color:#{color};"
pool_name = title_html.p
await message.reply_chat_action(ChatAction.TYPING)
data = {
banner = await self.get_banner(gacha_base_info)
player_gacha_info = await self.gacha_db.get(user.id)
# 检查 wish_item_id
if banner.banner_type == BannerType.WEAPON:
if player_gacha_info.event_weapon_banner.wish_item_id not in banner.rate_up_items5:
player_gacha_info.event_weapon_banner.wish_item_id = 0
# 执行抽卡
item_list = self.banner_system.do_pulls(player_gacha_info, banner, 10)
data = await self.handle.de_item_list(item_list)
player_gacha_banner_info = player_gacha_info.get_banner_info(banner)
template_data = {
"_res_path": f"file://{self.resources_dir}",
"name": f"{user.full_name}",
"info": gacha_name,
"poolName": pool_name,
"banner_name": banner.html_title,
"banner_type": banner.banner_type.name,
"player_gacha_banner_info": player_gacha_banner_info,
"items": [],
"wish_name": "",
}
for _ in range(10):
item = get_one(user_gacha_count, gacha_info)
# 下面为忽略的代码因为metadata未完善具体武器和角色类型无法显示
# item_name = item["item_name"]
# item_type = item["item_type"]
# if item_type == "角色":
# gacha_card = self.character_gacha_card.get(item_name)
# if gacha_card is None:
# await message.reply_text(f"获取角色 {item_name} GachaCard信息失败")
# return
# item["item_character_img"] = await url_to_file(gacha_card)
data["items"].append(item)
if player_gacha_banner_info.wish_item_id != 0:
weapon = WEAPON_DATA.get(str(player_gacha_banner_info.wish_item_id))
if weapon is not None:
template_data["wish_name"] = weapon["name"]
await self.gacha_db.set(user.id, player_gacha_info)
def take_rang(elem: dict):
return elem["rank"]
data["items"].sort(key=take_rang, reverse=True)
data.sort(key=take_rang, reverse=True)
template_data["items"] = data
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
# 因为 gacha_info["title"] 返回的是 HTML 标签 尝试关闭自动转义
png_data = await self.template_service.render(
"genshin/gacha/gacha.html", data, {"width": 1157, "height": 603}, False
"genshin/gacha/gacha.html", template_data, {"width": 1157, "height": 603}, False
)
reply_message = await message.reply_photo(png_data)
if filters.ChatType.GROUPS.filter(message):
self._add_delete_message_job(context, reply_message.chat_id, reply_message.message_id, 300)
self._add_delete_message_job(context, message.chat_id, message.message_id, 300)
@handler(CommandHandler, command="set_wish", block=False)
@handler(MessageHandler, filters=filters.Regex("^非首模拟器定轨(.*)"), block=False)
@restricts(restricts_time=3, restricts_time_of_groups=20)
@error_callable
async def set_wish(self, update: Update, context: CallbackContext) -> None:
message = update.effective_message
user = update.effective_user
args = get_all_args(context)
gacha_base_info = await self.handle.gacha_base_info("武器活动")
banner = await self.get_banner(gacha_base_info)
if len(args) >= 1:
weapon_name = args[0]
else:
reply_message = await message.reply_text("参数错误")
if filters.ChatType.GROUPS.filter(reply_message):
self._add_delete_message_job(context, message.chat_id, message.message_id, 10)
self._add_delete_message_job(context, reply_message.chat_id, reply_message.message_id, 10)
return
weapon_name = weaponToName(weapon_name)
player_gacha_info = await self.gacha_db.get(user.id)
for rate_up_items5 in banner.rate_up_items5:
weapon = WEAPON_DATA.get(str(rate_up_items5))
if weapon is None:
continue
if weapon["name"] == weapon_name:
player_gacha_info.event_weapon_banner.wish_item_id = rate_up_items5
break
else:
reply_message = await message.reply_text(f"没有找到 {weapon_name} 武器或该武器不存在UP卡池中")
if filters.ChatType.GROUPS.filter(reply_message):
self._add_delete_message_job(context, message.chat_id, message.message_id, 10)
self._add_delete_message_job(context, reply_message.chat_id, reply_message.message_id, 10)
return
await self.gacha_db.set(user.id, player_gacha_info)
reply_message = await message.reply_text(f"抽卡模拟器定轨 {weapon_name} 武器成功")
if filters.ChatType.GROUPS.filter(reply_message):
self._add_delete_message_job(context, message.chat_id, message.message_id, 10)
self._add_delete_message_job(context, reply_message.chat_id, reply_message.message_id, 10)
return

View File

@ -1,131 +0,0 @@
import random
from enum import Enum
class GachaType(Enum):
activity = 301 # 限定卡池
activity2 = 400 # 限定卡池
weapon = 302 # 武器卡池
permanent = 200 # 常驻卡池
class WishCountInfo:
def __init__(self, user_id: int):
self.user_id = user_id
self.five_stars_count: int = 1
self.four_stars_count: int = 1
self.is_up: bool = False
self.maximum_fate_points: int = 0
def character_probability(rank, count):
ret = 0
if rank == 5 and count <= 73:
ret = 60
elif rank == 5 and count >= 74:
ret = 60 + 600 * (count - 73)
elif rank == 4 and count <= 8:
ret = 510
elif rank == 4 and count >= 9:
ret = 510 + 5100 * (count - 8)
return ret
def weapon_probability(rank, count):
ret = 0
if rank == 5 and count <= 62:
ret = 70
elif rank == 5 and count <= 73:
ret = 70 + 700 * (count - 62)
elif rank == 5 and count >= 74:
ret = 7770 + 350 * (count - 73)
elif rank == 4 and count <= 7:
ret = 600
elif rank == 4 and count == 8:
ret = 6600
elif rank == 4 and count >= 9:
ret = 6600 + 3000 * (count - 8)
return ret
def is_character_gacha(gacha_type: GachaType) -> bool:
return gacha_type in (GachaType.activity, GachaType.activity2, GachaType.permanent)
def random_int():
return random.randint(0, 10000)
def get_is_up(rank: int, count: WishCountInfo, gacha_type: GachaType):
if gacha_type == GachaType.permanent:
return False
elif gacha_type == GachaType.weapon:
return random_int() <= 7500
else:
return random_int() <= 5000 or (rank == 5 and count.is_up)
def get_rank(count: WishCountInfo, gacha_type: GachaType):
value = random_int()
probability_fn = character_probability if is_character_gacha(gacha_type) else weapon_probability
index_5 = probability_fn(5, count.five_stars_count)
index_4 = probability_fn(4, count.four_stars_count) + index_5
if value <= index_5:
return 5
elif value <= index_4:
return 4
else:
return 3
def get_one(count: WishCountInfo, gacha_info: dict, weapon_name: str = "") -> dict:
gacha_type = GachaType(gacha_info["gacha_type"])
rank = get_rank(count, gacha_type)
is_up = get_is_up(rank, count, gacha_type)
if rank == 5:
count.five_stars_count = 1
if is_up:
data = random.choice(gacha_info["r5_up_items"])
else:
data = random.choice(gacha_info["r5_prob_list"])
if gacha_type == GachaType.weapon:
if data["item_name"] == weapon_name:
count.maximum_fate_points = 0
elif count.maximum_fate_points == 2:
count.maximum_fate_points = 0
for temp_item in gacha_info["r5_up_items"]:
if temp_item["item_name"] == weapon_name:
data = temp_item
break
else:
count.maximum_fate_points += 1
if gacha_type in (GachaType.activity, GachaType.activity2, GachaType.weapon):
count.is_up = not is_up
return {
"item_type": data["item_type"],
"item_name": data["item_name"],
"rank": 5,
}
elif rank == 4:
count.five_stars_count += 1
count.four_stars_count = 1
if is_up:
data = random.choice(gacha_info["r4_up_items"])
else:
data = random.choice(gacha_info["r4_prob_list"])
return {
"item_type": data["item_type"],
"item_name": data["item_name"],
"rank": 4,
}
elif rank == 3:
count.five_stars_count += 1
count.four_stars_count += 1
data = random.choice(gacha_info["r3_prob_list"])
return {
"item_type": data["item_type"],
"item_name": data["item_name"],
"rank": 3,
}
else:
raise ValueError("rank value error")

View File

@ -137,6 +137,10 @@
<div class="command-name">/gacha</div>
<div class="command-description">抽卡模拟器(非洲人模拟器)</div>
</div>
<div class="command">
<div class="command-name">/set_wish</div>
<div class="command-description">抽卡模拟器定轨</div>
</div>
<div class="command">
<div class="command-name">/quiz</div>
<div class="command-description">

View File

@ -54,14 +54,14 @@ body {
z-index: 9999;
}
.poor-bing {
.pity5 {
position: fixed;
top: 85px;
right: 55px;
z-index: 9999;
}
.list-box {
display: flex;
padding-top: 130px;
@ -162,6 +162,20 @@ body {
z-index: 120;
}
.poor-wish {
position: fixed;
top: 500px;
right: 55px;
z-index: 9999;
}
.poor-bing {
position: fixed;
top: 580px;
right: 55px;
z-index: 9999;
}
.logo {
position: absolute;
right: 55px;

View File

@ -10,29 +10,43 @@
<div class="container" id="container">
<div class="info-bg info-name">{{name}}</div>
<div class="info-bg info-count">{{info}}</div>
{% if banner_type == "WEAPON" %}
{% if wish_name %}
<div class="info-bg poor-wish">定轨:{{wish_name}}</div>
<div class="info-bg poor-bing">命定值:{{player_gacha_banner_info.failed_chosen_item_pulls}}</div>
{% else %}
<div class="info-bg poor-bing">当前卡池未定轨</div>
{% endif %}
{% endif %}
<div class="info-bg poor-info">
{% autoescape off %}
{{poolName}}
{{banner_name}}
{% endautoescape %}
</div>
<div class="info-bg pity5">距离上一个五星{{player_gacha_banner_info.pity5}}抽</div>
<div class="list-box">
{% for item in items %}
<div class="item">
<div class="item-bg-box">
<img class="item-bg" src="{{_res_path}}/genshin/gacha/items/bg.png"/>
<img class="item-bg" src="{{_res_path}}/genshin/gacha/items/bg.png" alt=""/>
</div>
<img class="item-shadow" src="{{_res_path}}/genshin/gacha/items/shadow-{{item.rank}}.png"/>
<img class="item-shadow2" src="{{_res_path}}/genshin/gacha/items/bg2.png"/>
{% if item.item_type=='武器' %}
<img class="item-shadow" src="{{_res_path}}/genshin/gacha/items/shadow-{{item.rank}}.png" alt=""/>
<img class="item-shadow2" src="{{_res_path}}/genshin/gacha/items/bg2.png" alt=""/>
{% if item.id <= 100000 %}
<div class="item-weapon-box">
<img class="item-weapon-img" src="{{_res_path}}/genshin/gacha/weapon/{{item.item_name}}.png"/>
<img class="item-weapon-img" src="{{_res_path}}/genshin/gacha/weapon/{{item.name}}.png" alt=""/>
</div>
{% else %}
<div class="item-img-box">
<img class="item-character-img" src="{{_res_path}}/genshin/gacha/character/{{item.item_name}}.png"/>
<img class="item-character-img" src="{{_res_path}}/genshin/gacha/character/{{item.name}}.png" alt=""/>
</div>
{% endif %}
<img class="item-star" src="{{_res_path}}/genshin/gacha/items/s-{{item.rank}}.png"/>
{% if item.id <= 100000 %}
<img class="item-element" src="{{_res_path}}/genshin/gacha/items/{{item.type}}.png" alt=""/>
{% else %}
<img class="item-element" src="{{_res_path}}/genshin/gacha/items/{{item.element}}.png" alt=""/>
{% endif %}
<img class="item-star" src="{{_res_path}}/genshin/gacha/items/s-{{item.rank}}.png" alt=""/>
</div>
{% endfor %}
</div>

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB